Playwright + axe-core + CI:把无障碍回归做成一条能拦住上线的流水线

简介: 本文详解如何将Web无障碍(WCAG 2.1 AA)从“上线前人工抽查”升级为CI流水线门禁:通过Playwright+axe-core自动扫描关键页,按impact分级熔断,结合带过期日与责任人的baseline豁免清单,实现全覆盖、可追溯、防退化的工程化保障。

一个面向政企的 B 端后台,验收前两周,甲方甩来一份信息无障碍整改清单:表单控件没有关联 label、数据表格缺少标题说明、按钮颜色对比度不够、弹窗打开后焦点乱跑——三十多条,条条要求上线前改完。团队这才想起来,招标要求里明明写着「符合 WCAG 2.1 AA」,而过去半年,无障碍这件事一直靠上线前找个人开着读屏软件随手点两下。

随手点两下的结果,就是漏。读屏抽查覆盖不了所有页面,抽查的人也未必记得住 WCAG 的每一条规则,今天查过的页面明天改一版又悄悄退化。更要命的是它没有留痕:查没查、查了哪些、发现的问题改没改,全靠一句「我记得看过」。等到甲方拿着专业工具跑一遍全量扫描,所有侥幸都原形毕露,返工成本集中爆发在离上线最近、最改不动的那个节点上。

本篇讲一件事:怎么把无障碍回归从「上线前人工抽查」变成一条接进 CI、能真正拦住上线的流水线。核心不是把 axe-core 跑起来那么简单——跑起来谁都会,难的是怎么分级、怎么处理动态页面的扫描时机、怎么给已知误报建一份不会无限膨胀的豁免清单。这三件事没做好,门禁要么天天误报被团队想办法绕过,要么形同虚设。

一、为什么无障碍不能只靠上线前人工读屏抽查
先给结论:人工读屏抽查在工程上是不成立的,因为它同时缺三样东西——覆盖率、可重复性、可追溯性。

覆盖率上,人一天能认真抽查的页面有限,一个几十页的后台,抽查往往只碰高频那几屏,冷门页面常年不查,而恰恰是冷门页面的对比度、label 缺失最容易翻车。可重复性上,同一套规则换个人查,结论能差出一大截,读屏软件的熟练度、对 ARIA 语义的理解,都是个人经验,沉淀不下来。可追溯性上,抽查这件事本身不产生结构化记录,出了问题只能事后回忆扯皮,谁也没法证明「上次到底查没查这一屏」。

把 axe-core 接进 Playwright 的价值,正是把这三样一次性补齐:规则集固定,每次提交都跑同一套标准;扫描结果是一份结构化 JSON,谁看都一样;报告可以归档,违规、豁免、责任人全都留痕。无障碍从「老手的直觉」变成「流水线的门禁」,这一步是绕不过去的。

二、分级门禁:只让 critical/serious 的 AA 违规熔断
门禁最忌讳一刀切。axe 扫出来的 violation 每条都带两个关键属性:一个是它命中的 WCAG 级别(tags 里能看出是 A、AA 还是 AAA),一个是 impact(critical、serious、moderate、minor)。这两维交叉,才决定一条违规该不该拦上线。

政企招投标基本只要求 AA,所以我们把扫描范围用 withTags 限定在 WCAG 2.x 的 A 与 AA 级,不扫 AAA——把 AAA 也纳入只会制造一堆改不动的噪音。分级门禁的规则很简单:只有 AA 级、且 impact 是 critical 或 serious 的违规才熔断流水线;moderate 和 minor 记进技术债台账,写报告但不阻断。这样门禁既有牙齿,又不会因为一条无关痛痒的小问题天天误报,被团队反感、绕过。

三、Playwright + axe-core:三个关键页面的注入扫描
下面是完整的可运行 spec,覆盖登录、列表、表单三个关键业务页。核心思路是:跑关键业务流的同时,在每个页面注入 axe 扫描,把结果原样落盘,交给后面的分级脚本消费。

// a11y.spec.js —— 关键业务流的无障碍回归
// 依赖安装:npm i -D @playwright/test @axe-core/playwright axe-core
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
const fs = require('fs');

// 每个关键页面指定一个 ready 选择器:它出现即代表本页核心语义已渲染完成
const KEY_PAGES = [
{ name: 'login', path: '/login', ready: '#username' },
{ name: 'list', path: '/orders', ready: 'table[data-testid="order-list"] tbody tr' },
{ name: 'form', path: '/orders/new', ready: 'form[data-testid="order-form"] button[type="submit"]' },
];

for (const pg of KEY_PAGES) {
test(a11y: ${pg.name}, async ({ page }) => {
await page.goto(pg.path, { waitUntil: 'domcontentloaded' });

// 关键:等语义就绪再扫,而不是 domcontentloaded 就扫
await page.waitForSelector(pg.ready, { state: 'visible', timeout: 15000 });
await page.waitForLoadState('networkidle');

const results = await new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) // 只扫 A / AA,不扫 AAA
  .analyze();

// 结果落盘,分级判断交给 gate 脚本,spec 内不直接熔断
fs.mkdirSync('a11y-results', { recursive: true });
fs.writeFileSync(
  `a11y-results/${pg.name}.json`,
  JSON.stringify(results, null, 2)
);

expect(results).toBeTruthy();

});
}
这段 spec 有三个地方是刻意为之。第一,扫描范围用 withTags 限定在 WCAG 2.x 的 A 与 AA 级,不碰 AAA,理由上一节说过。第二,spec 里不直接断言 violations 为空、不直接熔断,而是把结果原样落盘——因为「有没有违规」和「该不该拦上线」是两个问题,混在断言里就失去了按 impact 分级的空间。第三,也是最容易踩的坑:goto 之后没有立刻 analyze,而是先 waitForSelector 等那个代表「页面语义就绪」的元素出现,再等 networkidle,最后才扫。这个时机问题,下一节单独展开。

四、语义就绪后再扫描:动态渲染页面的时机问题
现代前端是异步渲染的:domcontentloaded 触发那一刻,DOM 骨架有了,但数据还没回来,表格是空的,表单控件还没挂上 label。这时候 axe 扫下去,要么扫不到真实内容导致漏报,要么把一个「加载中」的中间态当成违规误报。

正确做法是为每个关键页面指定一个 ready 选择器——一个「它出现了就说明这页的核心语义已经渲染完成」的元素。列表页等 tbody 里出现第一行真实数据,表单页等提交按钮可见。等它出现,再等 networkidle,最后才 analyze。

这里踩过最典型的坑是用固定的 sleep 去等。网络快的时候白等浪费时间,网络慢的时候没等够,扫描时机照样是错的。要等的是「语义信号」,不是「时间」——waitForSelector 等到的是那个元素真的出现,这才是可靠的就绪判定。

五、baseline 豁免清单:带过期时间与责任人
门禁跑起来之后,一定会遇到「已知但暂时改不了」的违规:老组件的对比度不达标、要等下一次设计改版才能修。如果每条都熔断,团队第三天就开始想办法绕过门禁;如果直接忽略这一整类规则,又可能漏掉新冒出来的真问题。

解法是 baseline 豁免清单:把「已知、已评估、暂时放行」的违规显式记录下来,扫描命中就旁路。但豁免清单必须带两个硬约束——过期时间和责任人。下面是一份 baseline.json 示例:

[
{
"page": "list",
"ruleId": "color-contrast",
"impact": "serious",
"reason": "旧版设计 token 灰阶对比不足,改版排期在 Q4",
"owner": "zhangsan",
"expire": "2026-12-31"
}
]
配套的分级脚本,读取 axe 结果、比对 baseline、按 impact 决定是否 exit(1):

// a11y-gate.js —— 分级、比对 baseline、决定是否熔断
// 用法:node a11y-gate.js a11y-results/ baseline.json
const fs = require('fs');
const path = require('path');

const BLOCK_IMPACTS = new Set(['critical', 'serious']); // 只让这两级熔断

function loadBaseline(file) {
if (!fs.existsSync(file)) return [];
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
const today = new Date().toISOString().slice(0, 10);
// 豁免带过期时间:过期条目自动失效,不再旁路
return raw.filter(item => item.expire && item.expire >= today);
}

function isWaived(violation, page, baseline) {
return baseline.some(b =>
b.page === page &&
b.ruleId === violation.id &&
!!b.owner // 必须有责任人,否则不算有效豁免
);
}

function main() {
const dir = process.argv[2] || 'a11y-results';
const baseline = loadBaseline(process.argv[3] || 'baseline.json');
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));

const blocking = [];
const techDebt = [];

for (const f of files) {
const page = path.basename(f, '.json');
const results = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
for (const v of results.violations) {
if (isWaived(v, page, baseline)) continue; // 豁免旁路
const impact = v.impact || 'minor';
const tags = v.tags || [];
const isAA = tags.some(t => t.includes('wcag2aa') || t.includes('wcag21aa'));
const record = { page, ruleId: v.id, impact, level: isAA ? 'AA' : 'other', nodes: v.nodes.length };
if (isAA && BLOCK_IMPACTS.has(impact)) {
blocking.push(record); // critical/serious 的 AA 违规 → 熔断
} else {
techDebt.push(record); // moderate/minor → 技术债,不阻断
}
}
}

fs.writeFileSync('a11y-gate-report.json', JSON.stringify({ blocking, techDebt }, null, 2));
console.log([a11y-gate] blocking=${blocking.length} techDebt=${techDebt.length});

if (blocking.length > 0) {
console.error('[a11y-gate] 熔断:存在 critical/serious 级 AA 违规');
blocking.forEach(b => console.error(- ${b.page} / ${b.ruleId} (${b.impact})));
process.exit(1);
}
process.exit(0);
}

main();
这份 baseline 的关键在 expire 和 owner 两个字段。没有过期时间的豁免清单会无限膨胀:今天放行一条,明天再放行一条,半年后没人记得哪些还有效,门禁被彻底架空。给每条豁免一个到期日,到期自动失效、重新暴露出来,逼着团队要么修掉、要么显式续期并写清理由。owner 字段则保证每条豁免都有人认领——isWaived 里特意检查了 b.owner 存在,没有责任人的豁免不算数。gate 脚本的分级逻辑也很直白:命中 baseline 的 continue 旁路;剩下的违规里只有 AA 级且 impact 是 critical 或 serious 的进 blocking 触发 exit(1),moderate 和 minor 进 techDebt 写报告但不阻断。

六、接进 CI:让门禁真的能拦住上线
有了 spec 和 gate 脚本,最后一步是把它接进流水线,让 exit code 成为真正的门禁。下面是一段 GitHub Actions 片段:

.github/workflows/a11y-gate.yml

name: a11y-gate
on:
pull_request:
branches: [main]
jobs:
a11y:
runs-on: ubuntu-latest
steps:

  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 20
  - run: npm ci
  - run: npx playwright install --with-deps chromium
  - name: Run key-flow a11y specs
    run: npx playwright test a11y.spec.js
  - name: Grade and gate
    run: node a11y-gate.js a11y-results/ baseline.json
  - name: Upload report
    if: always()
    uses: actions/upload-artifact@v4
    with:
      name: a11y-gate-report
      path: a11y-gate-report.json

这段流水线里,真正让它成为「门禁」的是 Grade and gate 这一步的退出码。gate 脚本发现 blocking 违规时 process.exit(1),GitHub Actions 会把这个 job 标红;再配上分支保护规则「a11y-gate 必须通过才能合并」,一条 critical 级 AA 违规就真的能拦住上线,而不是只发一封没人看的告警邮件。最后一步 upload-artifact 加了 if: always(),保证即使门禁失败报告也能归档,评审时能看到到底是哪几条违规拦的。这一步经常被漏掉,结果门禁红了却没人说得清为什么红。

七、人工抽查 vs CI 门禁:一张表看清差别
把两种模式放到五个维度上对照,差别一目了然:

维度
人工读屏抽查
CI 内 axe 自动门禁
覆盖率
抽查几屏,覆盖不全,改一版就退化
关键业务流全量注入扫描,每次提交都跑
单次耗时
人工点一遍数小时,且依赖具体某个人
随 Playwright 一起跑,几分钟内出结论
漏检风险
高,靠人的经验和当天状态,规则记不全
低,规则集固定,critical/serious 直接熔断
可追溯性
几乎没有,查没查全靠一句「我记得看过」
报告归档,违规、豁免、责任人、过期时间都有记录
返工成本
集中在验收前爆发,离上线最近最难改
前移到每次 PR,问题小步快修
差别不在于 axe 的逻辑有多复杂,而在于它把「事后归因」变成了「事前拦截」。

最后一个落地建议:别一上来就把门禁设成「零违规熔断」。存量系统第一次扫往往几十上百条,全熔断等于没法上线。正确姿势是先跑一轮摸底,把存量问题批量录进 baseline(带上合理的过期时间和责任人),让门禁只对「新增的 critical/serious」熔断,再用一段时间逐步清空 baseline。门禁要能落地,先得能过。

本篇不讨论通用视觉回归的像素比对,也不涉及 UI 选择器的脆弱性治理——那是另外两件事。我们只解决一个很窄、很具体的问题:无障碍这一类断言,怎么工程化成一条真能拦住上线的门禁。

无障碍不是验收前补的作业,而是每一次提交都该守住的一条线。

相关文章
|
10天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
|
10天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。
|
16天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)
|
9天前
|
IDE 开发工具
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
Qoder国际版上线全新内置大模型Sonus(/ˈsoʊnəs/),全球领先,专精超长任务执行与电脑操作(Computer Use)。配合Qoder桌面端0.2.3版本,可自主完成编程、金融建模、科研及表格制作等复杂工作。现全面支持Qoder全系产品,效率提升3.2倍。
1062 1
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
|
11天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1931 15
|
11天前
|
缓存 人工智能 自然语言处理
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
本文是阿里云百炼平台Qwen3.8-Flash大模型的选型接入指南,作为兼顾性能与响应速度的高性价比多模态模型,它支持百万级上下文窗口、全场景多模态输入与完整智能体能力矩阵,适配编程辅助、智能体协作等核心场景。文中同步梳理了最新下调的阶梯定价、夜间4折等优惠活动,搭配OpenAI兼容流式调用示例,帮助开发者低成本快速落地高并发AI应用。
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
|
15天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1672 4
|
17天前
|
缓存 数据可视化 开发工具
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
DeepSeek Harness 的更新分两层:本体更新(npx 自动最新、npm update -g、源码 git pull)与插件更新(插件市场点更新、命令行覆盖安装)。本文按「准备 → 更新本体 → 更新插件 → 更新后检查」四步走,覆盖新手常见疑问。
1886 1
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
|
12天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
843 2
|
10天前
|
缓存 测试技术 API
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
DeepSeek V4.1 Flash 内测不用申请,base_url 不变、改个模型名就能调,9/10 到期。本文讲清接入、计费限流与多模态注意点。
842 0
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)