import { expect, type Page } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; interface Waiver { page: string; rule: string; reason: string; } /** * critical/serious 违规的逐条豁免清单(spec S3): * 仅允许「属于将被 ② 阅读器改版重写的旧 chrome」的问题进这里,reason 必须写明归属。 * 例:{ page: "reader-chrome", rule: "color-contrast", reason: "旧 rd-* chrome 配色,② 迁移 shadcn token 后移除本豁免" } */ const WAIVERS: Waiver[] = [ { page: "reader-chrome", rule: "color-contrast", reason: "旧 chrome:Reader.tsx 头栏(书架返回钮/书名/库名/进度)用 stone-* 配色,对比不足;② 阅读器改版迁移 shadcn token 后移除本豁免", }, ]; export async function scanA11y(page: Page, name: string): Promise { // 等 CSS 动画/过渡收尾(如 fx-rise 入场淡入)再扫:动画途中采样会因不透明度混色造成 contrast 误报;2s 上限兜底长动画 await page.evaluate(() => Promise.race([ Promise.allSettled(document.getAnimations().map((a) => a.finished)), new Promise((r) => setTimeout(r, 2000)), ]), ); const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze(); if (results.violations.length > 0) { console.log( `[axe:${name}]`, JSON.stringify( results.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.map((n) => n.target.join(" ")), })), null, 2, ), ); } const blocking = results.violations.filter( (v) => (v.impact === "critical" || v.impact === "serious") && !WAIVERS.some((w) => w.page === name && w.rule === v.id), ); expect( blocking.map((v) => ({ id: v.id, impact: v.impact })), `${name} 存在未豁免的 critical/serious a11y 违规`, ).toEqual([]); }