- 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 配置收敛。
290 lines
9.2 KiB
TypeScript
290 lines
9.2 KiB
TypeScript
#!/usr/bin/env tsx
|
||
|
||
/**
|
||
* 标题层级检查脚本
|
||
*
|
||
* 对关键页面进行标题(h1-h6)层级扫描,确保:
|
||
* 1. 每个页面有且仅有一个 h1
|
||
* 2. 标题层级不跳跃(例如 h1 后直接出现 h3)
|
||
* 3. 标题不为空
|
||
*/
|
||
|
||
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 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: "联系我们" },
|
||
];
|
||
|
||
interface HeadingIssue {
|
||
type:
|
||
| "missing_h1"
|
||
| "multiple_h1"
|
||
| "empty_heading"
|
||
| "skipped_level"
|
||
| "http_error";
|
||
message: string;
|
||
detail?: string;
|
||
}
|
||
|
||
interface PageResult {
|
||
name: string;
|
||
url: string;
|
||
passed: boolean;
|
||
headings: Array<{ level: number; text: string }>;
|
||
issues: HeadingIssue[];
|
||
}
|
||
|
||
function startServer(): Promise<ReturnType<typeof spawn>> {
|
||
return new Promise((resolve, reject) => {
|
||
// 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) => {
|
||
const output = data.toString();
|
||
if (output.includes("Ready") || output.includes("3000")) {
|
||
resolve(server);
|
||
}
|
||
});
|
||
|
||
server.stderr?.on("data", (data: Buffer) => {
|
||
const output = data.toString();
|
||
if (output.includes("EADDRINUSE") || output.includes("Failed")) {
|
||
reject(new Error(output));
|
||
}
|
||
});
|
||
|
||
setTimeout(() => resolve(server), 5000);
|
||
});
|
||
}
|
||
|
||
// 负 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" });
|
||
// N-24:原本 `|| res.status === 404` 把「已响应但站点是错的」当成就绪;
|
||
// 而 :3000 上任何别的服务都会立刻满足它,于是整轮量的是错对象。
|
||
if (res.ok) return;
|
||
} catch {
|
||
// 服务未起:连接被拒
|
||
}
|
||
// 就绪等待必须每轮都退避:启动期返回 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[] {
|
||
const issues: HeadingIssue[] = [];
|
||
|
||
const h1Headings = headings.filter((h) => h.level === 1);
|
||
if (h1Headings.length === 0) {
|
||
issues.push({ type: "missing_h1", message: "页面缺少 h1 标题" });
|
||
} else if (h1Headings.length > 1) {
|
||
issues.push({
|
||
type: "multiple_h1",
|
||
message: `页面存在 ${h1Headings.length} 个 h1 标题`,
|
||
detail: h1Headings.map((h) => h.text).join(" | "),
|
||
});
|
||
}
|
||
|
||
for (let i = 0; i < headings.length; i++) {
|
||
const current = headings[i]!;
|
||
if (!current.text.trim()) {
|
||
issues.push({
|
||
type: "empty_heading",
|
||
message: `h${current.level} 标题内容为空`,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
if (i === 0) continue;
|
||
const previous = headings[i - 1]!;
|
||
if (current.level > previous.level + 1) {
|
||
issues.push({
|
||
type: "skipped_level",
|
||
message: `标题层级跳跃: h${previous.level} → h${current.level}`,
|
||
detail: `"${previous.text}" → "${current.text}"`,
|
||
});
|
||
}
|
||
}
|
||
|
||
return issues;
|
||
}
|
||
|
||
async function runCheck() {
|
||
let server;
|
||
try {
|
||
console.log("🚀 启动预览服务...");
|
||
server = await startServer();
|
||
await waitForServer(BASE_URL);
|
||
|
||
console.log("🔍 检查页面标题层级...\n");
|
||
const browser = await chromium.launch();
|
||
const context = await browser.newContext({
|
||
viewport: { width: 1280, height: 800 },
|
||
});
|
||
const page = await context.newPage();
|
||
|
||
const results: PageResult[] = [];
|
||
let totalIssues = 0;
|
||
|
||
for (const { path: pagePath, name } of PAGES) {
|
||
const url = `${BASE_URL}${pagePath}`;
|
||
try {
|
||
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"),
|
||
);
|
||
return elements.map((el) => ({
|
||
level: parseInt(el.tagName[1]!, 10),
|
||
text: el.textContent?.trim() ?? "",
|
||
}));
|
||
});
|
||
|
||
const issues = checkHeadings(headings);
|
||
totalIssues += issues.length;
|
||
|
||
const result: PageResult = {
|
||
name,
|
||
url,
|
||
passed: issues.length === 0,
|
||
headings,
|
||
issues,
|
||
};
|
||
results.push(result);
|
||
|
||
const status = issues.length === 0 ? "✅" : "❌";
|
||
console.log(`${status} ${name} (${issues.length} 个问题)`);
|
||
for (const issue of issues) {
|
||
console.log(
|
||
` • ${issue.message}${issue.detail ? ` (${issue.detail})` : ""}`,
|
||
);
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
console.error(`❌ ${name} 检查失败:`, message);
|
||
results.push({
|
||
name,
|
||
url,
|
||
passed: false,
|
||
headings: [],
|
||
issues: [{ type: "missing_h1", message: `页面加载失败: ${message}` }],
|
||
});
|
||
}
|
||
}
|
||
|
||
await browser.close();
|
||
|
||
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));
|
||
console.log(`\n📝 详细报告已保存: ${reportPath}`);
|
||
|
||
// process.exit() 不会执行 finally,所以两处出口都得自己收服服务再退出。
|
||
stopServer(server);
|
||
process.exit(totalIssues > 0 ? 1 : 0);
|
||
} catch (error) {
|
||
console.error("检查执行失败:", error);
|
||
stopServer(server);
|
||
process.exit(1);
|
||
} finally {
|
||
stopServer(server);
|
||
}
|
||
}
|
||
|
||
runCheck();
|