- 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 配置收敛。
284 lines
16 KiB
JavaScript
284 lines
16 KiB
JavaScript
// 全站 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);
|
||
}
|