test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构

- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
This commit is contained in:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+135 -49
View File
@@ -9,27 +9,32 @@
* 3. 标题不为空
*/
import { chromium } from 'playwright';
import { spawn } from 'child_process';
import path from 'path';
import { chromium } from "playwright";
import { spawn } from "child_process";
import path from "path";
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
const BASE_URL = process.env.E2E_BASE_URL || "http://localhost:3000";
const PAGES = [
{ path: '/', name: '首页' },
{ path: '/about', name: '关于我们' },
{ path: '/products', name: '产品中心' },
{ path: '/products/erp', name: '产品详情-ERP' },
{ path: '/solutions', name: '解决方案' },
{ path: '/solutions/manufacturing', name: '解决方案详情-制造' },
{ path: '/services', name: '服务列表' },
{ path: '/services/software', name: '服务详情-软件开发' },
{ path: '/news', name: '新闻列表' },
{ path: '/contact', name: '联系我们' },
{ path: "/", name: "首页" },
{ path: "/about", name: "关于我们" },
{ path: "/products", name: "产品中心" },
{ path: "/products/erp", name: "产品详情-ERP" },
{ path: "/solutions", name: "解决方案" },
{ path: "/solutions/manufacturing", name: "解决方案详情-制造" },
{ path: "/services", name: "服务列表" },
{ path: "/services/software", name: "服务详情-软件开发" },
{ path: "/news", name: "新闻列表" },
{ path: "/contact", name: "联系我们" },
];
interface HeadingIssue {
type: 'missing_h1' | 'multiple_h1' | 'empty_heading' | 'skipped_level';
type:
| "missing_h1"
| "multiple_h1"
| "empty_heading"
| "skipped_level"
| "http_error";
message: string;
detail?: string;
}
@@ -44,22 +49,48 @@ interface PageResult {
function startServer(): Promise<ReturnType<typeof spawn>> {
return new Promise((resolve, reject) => {
const server = spawn('npm', ['run', 'preview'], {
cwd: path.resolve(__dirname, '../..'),
stdio: 'pipe',
shell: true,
});
// N-24:`npm run preview` = `next start -p 3000`,而本项目是 output:'standalone',
// Next 16 明确告警 `"next start" does not work with "output: standalone"` ⇒ 此前该门禁跑在不受支持的降级路径上。
// 有产物就直起 standalone server(与 check:axe / Jenkinsfile / lighthouserc 同法),否则回退并大声告警。
const standalone = path.resolve(
__dirname,
"../../dist/standalone/server.js",
);
const useStandalone = require("fs").existsSync(standalone);
if (!useStandalone) {
console.warn(
"⚠ 未找到 dist/standalone/server.js,回退到 `npm run preview`(该路径在 standalone 下不受支持,结论仅参考)",
);
}
const server = spawn(
useStandalone ? "node" : "npm",
useStandalone ? [standalone] : ["run", "preview"],
{
env: useStandalone
? {
...process.env,
HOSTNAME: "localhost",
PORT: "3000",
NODE_ENV: "production",
}
: process.env,
cwd: path.resolve(__dirname, "../.."),
stdio: "pipe",
shell: true,
detached: true,
},
);
server.stdout?.on('data', (data: Buffer) => {
server.stdout?.on("data", (data: Buffer) => {
const output = data.toString();
if (output.includes('Ready') || output.includes('3000')) {
if (output.includes("Ready") || output.includes("3000")) {
resolve(server);
}
});
server.stderr?.on('data', (data: Buffer) => {
server.stderr?.on("data", (data: Buffer) => {
const output = data.toString();
if (output.includes('EADDRINUSE') || output.includes('Failed')) {
if (output.includes("EADDRINUSE") || output.includes("Failed")) {
reject(new Error(output));
}
});
@@ -68,29 +99,50 @@ function startServer(): Promise<ReturnType<typeof spawn>> {
});
}
// 负 PID = 杀整个进程组。spawn 的 `npm run preview` 只是包装进程,真正 LISTEN :3000 的是它的
// next-server 孙进程;只 kill 包装进程会让孙进程被 init 收养继续占端口(验收报告 §9 实测残留 39 分钟),
// 叠加 Playwright 的 reuseExistingServer: true 后,下一轮 E2E / Lighthouse 会静默复用它。
let serverStopped = false;
function stopServer(server: ReturnType<typeof spawn> | null | undefined): void {
if (!server?.pid || serverStopped) return;
serverStopped = true;
try {
process.kill(-server.pid, "SIGTERM");
} catch {
/* 进程组已自行退出 */
}
}
async function waitForServer(url: string, retries = 30): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url, { method: 'HEAD' });
if (res.ok || res.status === 404) return;
const res = await fetch(url, { method: "HEAD" });
// N-24:原本 `|| res.status === 404` 把「已响应但站点是错的」当成就绪;
// 而 :3000 上任何别的服务都会立刻满足它,于是整轮量的是错对象。
if (res.ok) return;
} catch {
await new Promise((r) => setTimeout(r, 1000));
// 服务未起:连接被拒
}
// 就绪等待必须每轮都退避:启动期返回 503/500 等非 ok 状态时,
// 若只在 catch 里 sleep,30 次重试会在毫秒内跑完并误报「服务未就绪」。
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`Server at ${url} did not become ready in time`);
}
function checkHeadings(headings: Array<{ level: number; text: string }>): HeadingIssue[] {
function checkHeadings(
headings: Array<{ level: number; text: string }>,
): HeadingIssue[] {
const issues: HeadingIssue[] = [];
const h1Headings = headings.filter((h) => h.level === 1);
if (h1Headings.length === 0) {
issues.push({ type: 'missing_h1', message: '页面缺少 h1 标题' });
issues.push({ type: "missing_h1", message: "页面缺少 h1 标题" });
} else if (h1Headings.length > 1) {
issues.push({
type: 'multiple_h1',
type: "multiple_h1",
message: `页面存在 ${h1Headings.length} 个 h1 标题`,
detail: h1Headings.map((h) => h.text).join(' | '),
detail: h1Headings.map((h) => h.text).join(" | "),
});
}
@@ -98,7 +150,7 @@ function checkHeadings(headings: Array<{ level: number; text: string }>): Headin
const current = headings[i]!;
if (!current.text.trim()) {
issues.push({
type: 'empty_heading',
type: "empty_heading",
message: `h${current.level} 标题内容为空`,
});
continue;
@@ -108,7 +160,7 @@ function checkHeadings(headings: Array<{ level: number; text: string }>): Headin
const previous = headings[i - 1]!;
if (current.level > previous.level + 1) {
issues.push({
type: 'skipped_level',
type: "skipped_level",
message: `标题层级跳跃: h${previous.level} → h${current.level}`,
detail: `"${previous.text}" → "${current.text}"`,
});
@@ -121,13 +173,15 @@ function checkHeadings(headings: Array<{ level: number; text: string }>): Headin
async function runCheck() {
let server;
try {
console.log('🚀 启动预览服务...');
console.log("🚀 启动预览服务...");
server = await startServer();
await waitForServer(BASE_URL);
console.log('🔍 检查页面标题层级...\n');
console.log("🔍 检查页面标题层级...\n");
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
});
const page = await context.newPage();
const results: PageResult[] = [];
@@ -136,14 +190,40 @@ async function runCheck() {
for (const { path: pagePath, name } of PAGES) {
const url = `${BASE_URL}${pagePath}`;
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
// N-24:page.goto 不对 404/500 抛错,而 Next 的 not-found 页只有一个 h1、无层级跳跃
// ⇒ 路由消失时本门禁会安静地判「层级正常」。必须显式断言状态码。
const httpStatus = response?.status() ?? 0;
if (httpStatus >= 400) {
totalIssues++;
results.push({
name,
url,
passed: false,
headings: [],
issues: [
{
type: "http_error",
message: `HTTP ${httpStatus} —— 页面未正常渲染(路由缺失/服务端错误)`,
detail: "标题层级检查未执行",
},
],
});
console.log(` ❌ ${name} (${pagePath}): HTTP ${httpStatus}`);
continue;
}
await page.waitForTimeout(1500);
const headings = await page.evaluate(() => {
const elements = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const elements = Array.from(
document.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
return elements.map((el) => ({
level: parseInt(el.tagName[1]!, 10),
text: el.textContent?.trim() ?? '',
text: el.textContent?.trim() ?? "",
}));
});
@@ -159,10 +239,12 @@ async function runCheck() {
};
results.push(result);
const status = issues.length === 0 ? '✅' : '❌';
const status = issues.length === 0 ? "✅" : "❌";
console.log(`${status} ${name} (${issues.length} 个问题)`);
for (const issue of issues) {
console.log(` • ${issue.message}${issue.detail ? ` (${issue.detail})` : ''}`);
console.log(
` • ${issue.message}${issue.detail ? ` (${issue.detail})` : ""}`,
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -172,31 +254,35 @@ async function runCheck() {
url,
passed: false,
headings: [],
issues: [{ type: 'missing_h1', message: `页面加载失败: ${message}` }],
issues: [{ type: "missing_h1", message: `页面加载失败: ${message}` }],
});
}
}
await browser.close();
console.log('\n📊 检查摘要');
console.log("\n📊 检查摘要");
console.log(` 扫描页面: ${results.length}`);
console.log(` 通过页面: ${results.filter((r) => r.passed).length}`);
console.log(` 失败页面: ${results.filter((r) => !r.passed).length}`);
console.log(` 问题总数: ${totalIssues}`);
const reportPath = path.resolve(__dirname, '../../heading-hierarchy-report.json');
require('fs').writeFileSync(reportPath, JSON.stringify(results, null, 2));
const reportPath = path.resolve(
__dirname,
"../../heading-hierarchy-report.json",
);
require("fs").writeFileSync(reportPath, JSON.stringify(results, null, 2));
console.log(`\n📝 详细报告已保存: ${reportPath}`);
// process.exit() 不会执行 finally,所以两处出口都得自己收服服务再退出。
stopServer(server);
process.exit(totalIssues > 0 ? 1 : 0);
} catch (error) {
console.error('检查执行失败:', error);
console.error("检查执行失败:", error);
stopServer(server);
process.exit(1);
} finally {
if (server) {
server.kill('SIGTERM');
}
stopServer(server);
}
}