Files
novalon-website/scripts/utils/check-heading-hierarchy.ts
T
zhangxiang 6bb7c557ee 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
  配置收敛。
2026-09-28 10:48:08 +08:00

290 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();