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
  配置收敛。
This commit is contained in:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+283
View File
@@ -0,0 +1,283 @@
// 全站 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);
}