axe-core 可访问性扫描:从页面状态到违规证据
axe-core 能判断的是调用 analyze() 那一刻,当前 DOM 在所选规则下暴露出的确定问题。弹层尚未打开、登录角色不对、组件仍在加载或扫描范围选错,都会让一个绿色结果失去意义。工程上应先固定页面身份和业务状态,再讨论规则与阈值。
axe-core 是规则引擎,不是完整合规证明
axe-core 在页面中执行规则,返回通过、违规、不适用与无法确定的结果。它擅长发现缺少可访问名称、无效 ARIA、部分颜色对比与结构错误,但无法仅靠 DOM 判断文案是否容易理解、焦点顺序是否符合真实任务、屏幕阅读器输出是否自然。Playwright 官方可访问性指南也明确要求把自动检查、人工评估和包容性用户测试组合使用。
@axe-core/playwright 是 axe-core 与 Playwright 的适配层,负责把引擎注入当前页面,并通过 AxeBuilder 表达 include、exclude、tag 和 rule。Playwright 负责浏览器、Context、Page、登录态、交互与附件。三者的责任不同:不要把 Playwright 的成功导航当成扫描成功,也不要把 axe 结果当成真实辅助技术输出。
npm install --save-dev @playwright/test @axe-core/playwright
npx playwright install chromium
npm ls @playwright/test @axe-core/playwright axe-core依赖必须由 lockfile 固定。若依赖树中同时出现多份 axe-core,应先用 npm explain axe-core 找到来源;同一仓库从不同入口调用不同规则版本,会让结果无法比较。直接在 JSDOM 中扫描适合提早发现静态语义问题,但真实 CSS、布局、frame、Shadow DOM 和焦点仍应在浏览器中验证。
页面状态与扫描时刻共同决定结论
扫描登录页与扫描登录后的设置弹层不是同一件事。测试先用角色定位器完成交互,等待目标区域进入稳定状态,再调用 analyze()。选择器应尽量指向业务区域,不要用一个过大的祖先把导航、第三方挂件和当前任务混成一份无法归责的报告。
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
test('notification dialog exposes no selected violations', async ({ page }, testInfo) => {
await page.goto('/settings');
await page.getByRole('button', { name: '通知设置' }).click();
const dialog = page.getByRole('dialog', { name: '通知设置' });
await expect(dialog).toBeVisible();
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
await testInfo.attach('axe-summary', {
body: JSON.stringify({
url: results.url,
engine: results.testEngine,
violations: results.violations.map(({ id, impact, nodes }) => ({
id, impact, targets: nodes.map(node => node.target),
})),
incomplete: results.incomplete.map(({ id, nodes }) => ({ id, count: nodes.length })),
}, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});这个用例保留规则、影响级别和稳定 target,没有默认复制每个节点的完整 HTML。若报告需要完整 DOM 片段,应把附件放进访问受控、有保留期的制品区。动态页面还要记录角色、主题、语言、viewport 和功能开关,否则同一个 URL 可能代表完全不同的扫描对象。
violations 与 incomplete 要分开处理
violations 表示规则已经得到失败结论,定位时先看 rule id、impact、help、target 和 failureSummary。一个规则可能命中多个节点,缺陷归属应落到组件或页面 owner,而不是把整份 JSON 丢给某个测试人员。incomplete 表示引擎无法自动确定,需要人工判断;静默丢弃 incomplete 等于主动删除未知风险。
正向用例证明符合预期的页面可以通过,反向金丝雀则证明门禁确实会拒绝已知错误。二者必须使用同一依赖、浏览器、规则配置和页面入口,只改变一个预期缺陷。
<main>
<h1>登录</h1>
<button type="button"><span aria-hidden="true">→</span></button>
</main>test('negative canary is rejected by button-name', async ({ page }) => {
await page.goto('/fixtures/a11y-bad.html');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations.map(item => item.id)).toContain('button-name');
});如果坏页面也通过,先核对实际 URL、fixture 是否进入构建、include 是否选中节点、tag/rule 是否把目标规则筛掉。反向实验的价值在于验证整条注入和断言链,而不是制造一次预期失败后用 || true 吞掉退出码。
扫描范围和规则策略必须可复核
include() 可以把扫描约束在当前任务区域,exclude() 会排除目标及全部后代,并让所有规则都不再检查这些节点。对大型容器使用 exclude 会制造盲区。disableRules() 的影响更大,它关闭整条规则,往往让后来新增的页面也失去保护。两者只能作为有 owner、有到期条件的迁移措施。
共享策略适合放进 Playwright fixture,让每个测试从同一规则基线出发,再追加页面范围。配置入口只保留一套;如果同时调用 withTags()、withRules() 和底层 options(),要验证后者是否覆盖前面的 runOnly 设置。
import { test as base } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
export const test = base.extend<{ makeAxe: () => AxeBuilder }>({
makeAxe: async ({ page }, use) => {
await use(() => new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']));
},
});规则策略要能回答为何选择这些 tag、哪些页面状态被覆盖、哪些规则暂时例外、何时重新评估。只保存“零违规”无法证明规则真的执行过,因此诊断任务还应抽样检查 passes、inapplicable 和 incomplete 的数量变化。
例外应保存稳定指纹,而非整份 HTML
遗留问题确实存在时,允许的最小单位应是 rule 与 target 的组合。完整 violations 快照包含渲染 HTML 等易变细节,组件做一次无关改版就会产生噪声;过粗的规则关闭又会隐藏新问题。稳定指纹只保留能识别债务的字段,并在新增、减少或影响级别变化时要求审查。
function fingerprints(results) {
return results.violations
.flatMap(({ id, impact, nodes }) =>
nodes.map(({ target }) => ({ rule: id, impact, target })))
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
}
expect(JSON.stringify(fingerprints(results), null, 2))
.toMatchSnapshot('known-a11y-debt.json');修复债务时先让真实违规消失,再删除指纹,随后同时运行正例和反例。例外审查不能只有脚本作者;组件 owner、可访问性责任人和关键流程的产品责任人应共同确认风险。过期例外应让门禁失败或至少进入明确告警队列。
键盘与辅助技术验证不能委托给 axe
机器扫描之后仍要走完整任务。键盘验证不是数 Tab 次数,而是确认焦点可见、顺序合理、控件可激活、弹层能退出、错误后能恢复并把焦点送回正确位置。屏幕阅读器任务还要检查名称、角色、状态与动态消息是否被目标组合正确表达。
await page.getByLabel('密码').focus();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: '登录' })).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByRole('status')).toContainText('登录成功');这段自动化只证明浏览器可计算的焦点和结构状态,不证明真实朗读质量。人工记录应包含操作系统、浏览器、辅助技术、设置、任务、结果和缺陷链接。对登录、支付、搜索、表单错误与复杂弹层,真实用户或熟悉辅助技术的测试者比增加更多规则更有价值。
报告数据与账号权限属于安全边界
axe 结果可能包含 URL、selector、HTML 片段和错误摘要;与 Playwright trace 结合后还可能暴露页面快照、网络请求与认证状态。自动化账号只拥有被测角色的最小权限,secret 由 CI 短期注入,不向 fork 或不受信分支开放。报告按项目控制访问、加密、保留与删除,日志默认不打印完整节点 HTML。
扫描容量来自页面、角色、状态、浏览器、规则集与重试的乘法。常规 PR 优先覆盖高风险代表状态,夜间任务再扩大矩阵;成功运行保存结构化摘要,完整结果只在诊断需要时保留。大型 DOM 的扫描时间和 selector 计算会显著增长,先治理异常 DOM 规模,再考虑减少 resultTypes。
升级、清理与退出要保持判断能力
升级分支先用旧 lockfile 跑固定正反样例,再更新 @axe-core/playwright 与依赖树,比较 rule id、节点、incomplete、运行时间和报告体积。新增违规要判断是产品缺陷还是规则变化,反向金丝雀消失则直接阻断升级。回滚必须恢复 lockfile、测试镜像和共享 fixture,不能只降低一个包却继续复用另一份新版 axe-core。
Remove-Item -LiteralPath test-results -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath playwright-report -Recurse -Force -ErrorAction SilentlyContinue
git status --short本地清理只删除仓库明确生成的制品,规则策略、反向 fixture、批准指纹与人工任务继续版本化。停用 axe-core 前应导出页面状态矩阵、规则策略、例外、未清债务和 owner,并确认新工具仍能让好页面通过、坏页面失败。能持续回答“扫描了哪个状态、命中了哪个节点、未知项由谁复核、报告存在哪里、例外何时清除”,才算拥有可审查的可访问性门禁。
