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 选择器的脆弱性治理——那是另外两件事。我们只解决一个很窄、很具体的问题:无障碍这一类断言,怎么工程化成一条真能拦住上线的门禁。

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

相关文章
|
22天前
|
弹性计算 运维 安全
阿里云服务器免费领取最新教程:新用户、学生、个人、企业申领渠道、配置与运维完整实操流程指南(个人新用户3个月、学生1年免费服务器)
整体来看免费ECS权益给学生、个人开发者提供了零成本接触云计算的渠道。个人新用户可以拿到最长3个月免费实例,学生通过高校认证可以拿到代金券实现一年免费使用。整套流程从账号注册、实名认证,再到实例申领、安全配置、命令实操,新手跟着步骤就可以独立完成。但要牢记免费资源存在额度、周期限制,到期及时续费或者释放资源,防止产生不必要账单。拿到实例之后优先做好安全配置,只开放业务必须端口,不要把全部端口对外开放,降低服务器被扫描入侵的风险。利用免费算力多多练习项目部署、运维操作,快速积累云上实操经验。
239 1
|
25天前
|
人工智能 监控 安全
DeepSeek Harness爆火,测试人能拿它做什么?这5个场景最实用
本文揭秘DeepSeek在测试领域的5大实战用法:用例生成、接口自动化、代码审查、单元测试、日志分析,辅以详细步骤与避坑指南。实测可减少60%-70%重复劳动,强调“AI代劳+人工审核”——替手不替脑。
|
11天前
|
缓存 开发工具 git
DSH skill 怎么安装、放在哪个目录?DeepSeek Harness 技能与插件的区别与用法
dsh skill 不是插件:它是给模型的可加载 Markdown 指令集,由本地提供方按 rank 扫描六个根目录发现,用 dsh plugin add 装不进去。本文讲清六个根目录的优先级、SKILL.md 写法与两个调用开关。
374 4
DSH skill 怎么安装、放在哪个目录?DeepSeek Harness 技能与插件的区别与用法
|
1月前
|
人工智能 数据挖掘 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8基座,六大核心能力重构企业自动化工作流与计费选型全指南
千问办公QwenWork依托Qwen3.8基座,通过六大核心能力打通文档处理、数据分析、浏览器自动化、自定义技能编排、多端Agent协同、企业业务系统,把AI能力从简单对话升级为完整任务交付,解决企业大量重复办公工作。但AI办公智能体属于效率辅助工具,无法完全替代业务人员专业判断,财务、法务类关键业务输出,必须经过人工审核校验。
377 2
|
1月前
|
弹性计算 安全 Linux
新手如何选购并配置阿里云服务器保姆级教程,实例选型、参数解读、远程连接、安全加固全流程教学
对于绝大多数刚接触云计算的新手来说,挑选并配置第一台云服务器往往会遇到大量困惑。面对控制台密密麻麻的参数选项,分不清轻量应用服务器和ECS之间的差异,看不懂计费模式,购买之后不知道如何登录、如何做安全设置,经常出现买完服务器无法访问、服务器被恶意扫描攻击、资源规格和业务需求不匹配造成资源浪费等一系列问题。本文面向零基础使用者,完整梳理从前期账号准备、产品选型、参数筛选、下单购买,再到远程登录、系统初始化、安全加固、基础环境调试的全部流程,同时提供可直接复制运行的实操命令,帮助新手顺利完成第一台云服务器的搭建,规避新手高频踩坑点,理清不同业务场景下的选型逻辑,让服务器真正可以投入业务使用。
254 3
|
2月前
|
数据采集 人工智能 搜索推荐
生成式搜索品牌推荐:开发者如何让品牌被 AI 搜索正确引用
本文剖析生成式搜索中品牌推荐的底层机制,提出从知识原子拆解、结构化标记到多源校验的工程落地路径,指导开发者通过可信内容、Schema标注与事实句优化,提升品牌被AI准确引用的概率。
211 4
|
1月前
|
人工智能 JavaScript 测试技术
从需求到用例全自动:三条知识库路径+两种工作流,我搭了一套永不掉链子的测试智能体
别再让AI反复读文档!本文揭秘如何用“知识地图”(向量库+知识图谱+Markdown库)构建AI长期记忆,结合工作流编排,实现精准、关联、可复用的测试用例自动生成——从临时喂文档,升级为智能导航式测试生产力。
|
1月前
|
缓存
TokenPlan的Credits怎么计费?1个Credits不等于固定Token数,Qwen3.6‑Plus计算示例解析
阿里云百炼Token Plan按Credits动态计费,1 Credits不等于固定Token数。消耗由模型类型(如qwen3.6-plus)、Token数量、思考模式、工具调用及缓存命中等共同决定。示例显示:8,349输入Token≈1.67 Credits,573输出Token≈0.69 Credits,合计约4 Credits。百万Tokens成本低至1.12元,较按量计费最高省44%。详情以官网为准。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
320 3
|
2月前
|
人工智能 自然语言处理 搜索推荐
企业级 AI 搜索获客体系建设方案:五层增长模型与全链路落地架构
AI 搜索获客不是营销噱头,而是一个有明确架构、可量化验证、可持续运营的工程体系。它的核心逻辑一句话就能说清:先让 AI 认识你、认可你,再让这份认可变成稳定的客户来源。
|
2月前
|
SQL 人工智能 数据管理
第一份“主数据”国家标准来了:GB/T 47854-2026《大数据 主数据管理要求》
主数据是企业核心业务对象(如客户、供应商、物料等)的统一权威数据,支撑ERP、CRM、AI等系统协同。2026年7月2日,我国首项主数据国标GB/T 47854-2026发布,2027年2月1日实施,标志着主数据管理迈向标准化、体系化新阶段。