// 全站 axe 节点计数门禁(验收 §8-⑤ 的永久化版本)。 // // 来源:本文件由 `docs/acceptance/2026-09-21-axe/axe-contrast-evidence.mjs` 平移而来(2026-09-23), // 行为口径不变:同一份路由清单在 **chromium + firefox** × **light + dark** 四个组合下逐页跑两遍 axe-core // 1) tag 通道 `runOnly:{type:'tag',values:['wcag2a','wcag2aa','wcag21a','wcag21aa']}` // 2) 规则级通道 `runOnly:{type:'rules',values:EXTRA_RULES}` // 第 2 遍是 `presentation-role-conflict` 的**唯一真实覆盖**(该规则属 `best-practice` tag,不在 tag 通道内), // 也是 Lighthouse 那三条死断言删除后 `autocomplete-valid` / `svg-img-alt` 的兜底。 // 证据链:docs/acceptance/2026-09-21-gates/final-tree-results.md §⑤ 与「Lighthouse 的 3 条死断言」。 // // 与一次性版本的关键差异:**门禁判定从「打印 PASSED」升级为「exit code」**,并新增分母自证断言, // 使「悄悄不再覆盖」无法以绿灯形式通过(CI 见 Jenkinsfile 的 ♿♿ 阶段)。 // // 用法: // npm run check:axe:routes # 生成路由清单(默认 /tmp/axe-routes.xml) // npm run check:axe # 用该清单扫描并判定 // 环境变量:BASE(默认 http://localhost:3100,须是 `node dist/standalone/server.js` 起的生产服务, // 非 dev server)、SITEMAP(路由清单 XML)、OUT(证据 JSON 输出目录,默认 docs/acceptance/2026-09-21-axe, // 历史证据仍留在这里)。 // // 退出码:0 通过 / 1 门禁判红(violation 节点 > 0、主题或底色不匹配、规则级通道分母不闭合)/ // 2 输入不可用(路由清单缺失或解析出 0 条)。 import { chromium, firefox } from 'playwright'; import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, isAbsolute, join, resolve } from 'path'; // 本脚本从证据目录 docs/acceptance/2026-09-21-axe/ 搬到 scripts/accessibility/,cwd 相对路径一律改为 // 仓库根相对,这样无论从哪个目录调用都指向同一份 axe-core 与同一处证据目录(行为等价,只是不再依赖 cwd)。 const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const repoPath = (p) => (isAbsolute(p) ? p : resolve(REPO_ROOT, p)); const BASE = process.env.BASE || 'http://localhost:3100'; const OUT = repoPath(process.env.OUT || 'docs/acceptance/2026-09-21-axe'); const SITEMAP = repoPath(process.env.SITEMAP || '/tmp/axe-routes.xml'); const AXE_SCRIPT = repoPath('node_modules/axe-core/axe.min.js'); const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; // Lighthouse 12.6.1 不包装这三条 axe 规则,且 presentation-role-conflict 属 best-practice tag(不在 TAGS 内) // ⇒ 只有这里的规则级 runOnly 才真正测到它们;证据链见 docs/acceptance/2026-09-21-gates/final-tree-results.md // // 两条数组刻意分开: // CANONICAL_EXTRA_RULES = 门禁**要求被判定**的三条规则(绝对下限,写死在此) // EXTRA_RULES = 实际传给 axe 的 runOnly 列表(默认等于下限) // 只删 EXTRA_RULES 会立即判红(分母不再等于 路由 × 组合 × 3 条下限),删 CANONICAL 则需要改动带注释的门禁契约本身。 const CANONICAL_EXTRA_RULES = ['autocomplete-valid', 'presentation-role-conflict', 'svg-img-alt']; const EXTRA_RULES = [...CANONICAL_EXTRA_RULES]; const ENGINES = [['chromium', chromium], ['firefox', firefox]]; const THEMES = ['light', 'dark']; const COMBOS = ENGINES.length * THEMES.length; // 4 个「引擎 × 主题」组合 // 底色必须等于本站令牌值:未加载样式时所有元素都落在默认黑白底上,对比度会「意外通过」。 const EXPECTED_BG = { light: 'rgb(255, 255, 255)', dark: 'rgb(10, 14, 20)' }; // 逐页状态码期望:清单里绝大多数路由必须 2xx/3xx,`/_not-found` 是本站 404 页、**期望** 404。 // 为什么必须逐页判:404/500 页几乎没有可比对节点,`violationNodes` 仍为 0,而 `s.pages` 计数照加; // 聚合保险丝 `okResponses > 0` 只在**全部**响应都非 2xx 时才炸 ⇒ 单个路由退化成 404 会被当「干净」计过。 // 证据:docs/acceptance/2026-09-21-axe/axe-evidence.json 实测 136 行 = 132×200 + 4×404(全部来自 /_not-found)。 const EXPECTED_404 = ['/_not-found']; const statusOffenders = []; function fatal(message, code = 2) { console.error(`FATAL: ${message}`); process.exit(code); } // 扫描中途抛错(浏览器启动失败、服务不可达、axe 注入失败)会以非 0 退出,这里只是把原因说清楚。 for (const ev of ['unhandledRejection', 'uncaughtException']) { process.on(ev, (err) => fatal(`扫描中断(${ev}):${err instanceof Error ? err.message : String(err)}`, 1)); } if (!existsSync(AXE_SCRIPT)) { fatal(`缺少 ${AXE_SCRIPT}(axe-core 未安装?先 npm ci)`, 2); } if (!existsSync(SITEMAP)) { fatal(`路由清单 ${SITEMAP} 不存在。先跑 npm run check:axe:routes 生成清单`, 2); } const urls = [...new Set( readFileSync(SITEMAP, 'utf8') .split('') .slice(1) .map((b) => (b.match(/([^<]+)<\/loc>/) || [])[1]) .map((loc) => { try { return new URL(loc).pathname; } catch { return null; } }) .filter(Boolean) )]; // 空清单时下面的循环零次执行、计数器全为 0,「PASSED = true」是假绿,必须拒绝出证据。 if (urls.length === 0) { fatal(`从 ${SITEMAP} 解析出 0 条路由,扫描等于没测,退出 2`, 2); } async function settle(page) { await page.waitForTimeout(1200); await page.evaluate(async () => { let last = -1; for (let i = 0; i < 12; i += 1) { window.scrollTo(0, document.body.scrollHeight); await new Promise((r) => setTimeout(r, 180)); if (Math.round(window.scrollY) === last) break; last = Math.round(window.scrollY); } window.scrollTo(0, 0); }); await page.waitForTimeout(400); } const rows = []; for (const [engineName, launcher] of ENGINES) { const browser = await launcher.launch(); for (const theme of THEMES) { const ctx = await browser.newContext({ colorScheme: theme }); await ctx.addInitScript((t) => { localStorage.setItem('novalon-theme', t); localStorage.setItem('novalon-cookie-preferences', JSON.stringify({ necessary: true, analytics: false, functional: false, timestamp: '2026-09-21T00:00:00.000Z' })); }, theme); const page = await ctx.newPage(); for (const url of urls) { const res = await page.goto(`${BASE}${url}`, { waitUntil: 'domcontentloaded', timeout: 45000 }); const statusCode = res ? res.status() : 0; await settle(page); const observedTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); await page.addScriptTag({ path: AXE_SCRIPT }); const tagRun = await page.evaluate((tags) => window.axe.run(document, { runOnly: { type: 'tag', values: tags }, resultTypes: ['violations'], }), TAGS); const violations = tagRun.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length, sample: v.nodes.slice(0, 3).map((n) => (n.target || []).join(' ')), })); // 规则级复跑:axe 遇到未知 rule id 会直接抛错;extraRulesChecked 断言三条规则确实参与了本页运行。 const ruleRes = await page.evaluate((rules) => window.axe.run(document, { runOnly: { type: 'rules', values: rules }, }), EXTRA_RULES); const ruleViolations = ruleRes.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length, sample: v.nodes.slice(0, 3).map((n) => (n.target || []).join(' ')), })); const ruleIds = new Set([ ...ruleRes.violations, ...ruleRes.passes, ...ruleRes.incomplete, ...ruleRes.inapplicable, ].map((r) => r.id)); const covered = CANONICAL_EXTRA_RULES.filter((id) => ruleIds.has(id)); const pageBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); rows.push({ engine: engineName, theme, url, observedTheme, statusCode, pageBg, bgMatchesToken: pageBg === EXPECTED_BG[theme], violationRules: violations.length, violationNodes: violations.reduce((a, v) => a + v.nodes, 0), contrastNodes: violations.filter((v) => v.id === 'color-contrast').reduce((a, v) => a + v.nodes, 0), extraRuleNodes: ruleViolations.reduce((a, v) => a + v.nodes, 0), extraRulesChecked: ruleIds.size, extraRulesCovered: covered, extraRulesMissing: CANONICAL_EXTRA_RULES.filter((id) => !ruleIds.has(id)), violations, extraRuleViolations: ruleViolations, }); console.log(`${engineName}/${theme} ${url} status=${statusCode} theme=${observedTheme} bg=${pageBg} rules=${violations.length} nodes=${rows.at(-1).violationNodes} contrast=${rows.at(-1).contrastNodes} extra=${rows.at(-1).extraRuleNodes}/${covered.length}/${rows.at(-1).extraRulesChecked}`); } await ctx.close(); } await browser.close(); } const summary = {}; for (const r of rows) { const k = `${r.engine}/${r.theme}`; summary[k] = summary[k] || { pages: 0, themeMismatch: 0, bgMismatch: 0, contrastNodes: 0, violationNodes: 0, extraRuleNodes: 0, extraRulePagesUnderCovered: 0, extraRuleCoveragePairs: 0, non2xxResponses: 0, okResponses: 0, unexpectedStatus: 0, }; summary[k].pages += 1; summary[k].contrastNodes += r.contrastNodes; summary[k].violationNodes += r.violationNodes; summary[k].extraRuleNodes += r.extraRuleNodes; summary[k].extraRuleCoveragePairs += r.extraRulesCovered.length; // 两条都要满足:三条下限规则逐条判定过(缺一条即覆盖腐化),且通道本身没跑出预期之外的形状。 if (r.extraRulesMissing.length > 0 || r.extraRulesChecked !== EXTRA_RULES.length) { summary[k].extraRulePagesUnderCovered += 1; } if (r.observedTheme !== r.theme) summary[k].themeMismatch += 1; if (!r.bgMatchesToken) summary[k].bgMismatch += 1; if (r.statusCode >= 200 && r.statusCode < 400) summary[k].okResponses += 1; else summary[k].non2xxResponses += 1; // 逐页状态码判据(独立于上面两条聚合计数) const expects404 = EXPECTED_404.includes(r.url); const statusMatches = expects404 ? r.statusCode === 404 : r.statusCode >= 200 && r.statusCode < 400; if (!statusMatches) { summary[k].unexpectedStatus += 1; statusOffenders.push( `${k} ${r.url} status=${r.statusCode} expected=${expects404 ? '404' : '2xx/3xx'}`, ); } } const expectedCombos = ENGINES.flatMap(([e]) => THEMES.map((t) => `${e}/${t}`)); const totalRuleChecks = rows.reduce((a, r) => a + r.extraRulesChecked, 0); const totalCanonicalCoverage = rows.reduce((a, r) => a + r.extraRulesCovered.length, 0); const expectedCanonicalCoverage = urls.length * COMBOS * CANONICAL_EXTRA_RULES.length; const missingRuleUnion = [...new Set(rows.flatMap((r) => r.extraRulesMissing))]; // ---- 门禁断言(exit code 由此决定,不再只是打印) ---- const failures = []; const push = (cond, msg) => { if (!cond) failures.push(msg); }; // 分母自证:缺了它,「少扫了几个组合」与「扫了且全绿」在证据上无法区分。 push(rows.length === urls.length * COMBOS, `分母不闭合:rows=${rows.length},应为 ${urls.length} 路由 × ${COMBOS} 组合 = ${urls.length * COMBOS}`); for (const combo of expectedCombos) { const s = summary[combo]; if (!s) { push(false, `${combo} 组合完全缺失(浏览器未启动或中途中断)`); continue; } push(s.pages === urls.length, `${combo} 只扫了 ${s.pages} 页,清单有 ${urls.length} 页`); push(s.violationNodes === 0, `${combo} violationNodes=${s.violationNodes}(阈值 0)`); push(s.contrastNodes === 0, `${combo} contrastNodes=${s.contrastNodes}(阈值 0)`); push(s.extraRuleNodes === 0, `${combo} extraRuleNodes=${s.extraRuleNodes}(三条规则级判据,阈值 0)`); push(s.extraRulePagesUnderCovered === 0, `${combo} 有 ${s.extraRulePagesUnderCovered} 页的规则级通道未覆盖满 ${CANONICAL_EXTRA_RULES.length} 条下限规则(缺:${missingRuleUnion.join(', ') || '形状异常'})`); push(s.extraRuleCoveragePairs === urls.length * CANONICAL_EXTRA_RULES.length, `${combo} 规则级覆盖 ${s.extraRuleCoveragePairs} ≠ ${urls.length} 路由 × ${CANONICAL_EXTRA_RULES.length} 规则`); push(s.themeMismatch === 0, `${combo} themeMismatch=${s.themeMismatch}(以为在量深色其实还是浅色)`); push(s.bgMismatch === 0, `${combo} bgMismatch=${s.bgMismatch}(底色不等于令牌值 ⇒ 样式未加载 ⇒ 对比度「意外达标」)`); // 保险丝:整组全非 2xx ⇒ 服务或清单不对,扫的是 404 页而不是本站页面。 push(s.okResponses > 0, `${combo} 所有响应都非 2xx(BASE=${BASE} 不是可用的生产服务,或清单已过期)`); push(s.unexpectedStatus === 0, `${combo} 有 ${s.unexpectedStatus} 页状态码不符期望(404/500 页会被 axe 判为「无违规」且仍计入 pages 分母;期望见 EXPECTED_404)`); } // offenders 明细直接进判据文本(而非只打 stdout):本仓曾因 line reporter 不带 stdout // 而在并发失败时「查不到规则名」,同理这里若不落进 failures 就查不到是哪一条路由 404。 if (statusOffenders.length > 0) { push(false, `逐页状态码断言失败 ${statusOffenders.length} 处:\n ${statusOffenders.join('\n ')}`); } push(totalRuleChecks === urls.length * COMBOS * EXTRA_RULES.length, `规则级通道总量 ${totalRuleChecks} ≠ ${urls.length}×${COMBOS}×${EXTRA_RULES.length} = ${urls.length * COMBOS * EXTRA_RULES.length}`); // 绝对下限的分母闭合:三条规则 × 每一页 × 每一个组合,一个都不能少(0 违规 ≠ 没测到)。 push(totalCanonicalCoverage === expectedCanonicalCoverage, `规则级覆盖 ${totalCanonicalCoverage} ≠ ${expectedCanonicalCoverage}` + `(= ${urls.length} 路由 × ${COMBOS} 组合 × ${CANONICAL_EXTRA_RULES.length} 条下限规则 ${CANONICAL_EXTRA_RULES.join(', ')})`); const artifact = { generatedAt: new Date().toISOString(), base: BASE, axeCore: JSON.parse(readFileSync(repoPath('node_modules/axe-core/package.json'), 'utf8')).version, routeSource: `${SITEMAP} → ${urls.length} 条路由(sitemap ∪ 预渲染产物 ∪ 站内链接 BFS)`, routeCount: urls.length, routes: urls, combinations: expectedCombos, tags: TAGS, extraRules: EXTRA_RULES, requiredRuleCoverage: CANONICAL_EXTRA_RULES, thresholds: { colorContrastNodes: 0, violationNodes: 0, themeMismatch: 0, bgMismatch: 0, extraRuleNodes: 0, extraRulePagesUnderCovered: 0, rows: urls.length * COMBOS, totalExtraRuleChecks: urls.length * COMBOS * EXTRA_RULES.length, requiredRuleCoverageTotal: expectedCanonicalCoverage, }, totalExtraRuleChecks: totalRuleChecks, expectedExtraRuleChecks: urls.length * COMBOS * EXTRA_RULES.length, totalRequiredRuleCoverage: totalCanonicalCoverage, expectedRequiredRuleCoverage: expectedCanonicalCoverage, passed: failures.length === 0, failures, summary, rows, }; mkdirSync(OUT, { recursive: true }); writeFileSync(`${OUT}/axe-evidence.json`, JSON.stringify(artifact, null, 2)); console.log('\nSUMMARY', JSON.stringify(summary, null, 2)); console.log('PASSED =', artifact.passed); if (!artifact.passed) { console.error(`\nFAIL: axe 节点计数门禁 ${failures.length} 条判据未通过(证据已写入 ${OUT}/axe-evidence.json)`); for (const f of failures) console.error(` ✗ ${f}`); process.exit(1); }