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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user