Files
novalon-website/scripts/accessibility/axe-node-count.mjs
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

284 lines
16 KiB
JavaScript
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.
// 全站 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('<url>')
.slice(1)
.map((b) => (b.match(/<loc>([^<]+)<\/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);
}