
1. 为什么要在 CI 里跑 axe-core一次真实的回归事故先说一个我踩过的坑。去年我们团队重构了一个后台管理系统的表单组件功能测试全绿代码评审也过了结果上线三天后收到一封投诉邮件一位使用屏幕阅读器的用户完全无法提交工单因为重构时把label for换成了纯placeholder视觉上没差别但辅助技术读不出这个输入框是干什么的。问题出在哪功能测试只验证「能不能点、能不能提交」它不关心「读屏软件能不能理解」。这类问题就是 Web 可访问性Accessibility简称 a11y缺陷而 axe-core 正是用来在自动化流程里抓这类问题的引擎。axe-core 是什么一句话它是 Deque Labs 维护的开源可访问性测试引擎能扫描 HTML 界面按 WCAG 2.0/2.1/2.2 的 A、AA、AAA 级别规则找出违规项。它能做什么把原本需要无障碍专家手动审计的工作变成开发团队日常 CI 里自动跑的检查。适合谁前端工程师、测试工程师、以及任何想让「无障碍开发」从口号变成标准实践的团队。为什么一定要放进 CI因为可访问性问题最大的特点是「静默回归」。它不像 JS 报错那样会崩页面照样渲染、按钮照样能点只有依赖辅助技术的用户才会受影响。如果只在发版前手动扫一次中间几十个 PR 引入的问题会全部堆积到最后一刻。把 axe-core 接进 GitHub Actions每次 PR 都跑一遍问题在合并前就被拦下修复成本从「上线后返工」降到「改一行属性」。这篇会给你三样能直接抄的东西可复制的 axe-core 配置片段、完整的 GitHub Actions 工作流 YAML、本地验证命令。同时覆盖组件级和页面级两类扫描——组件级用 Jest jest-axe 在单测里跑页面级用 Playwright 在真实浏览器里跑。最后演示怎么用 TaoToken 统一管理测试脚本里模型调用的凭证避免 Key 散落在各个 workflow 里。2. 前置准备装好 axe-core 与 TaoToken 凭证通道在动手写 CI 之前先把本地环境和凭证通道理清楚。这一节解决两个问题依赖怎么装以及测试脚本里如果调用了大模型比如自动生成修复建议Key 从哪来。2.1 安装 axe-core 相关依赖组件级扫描推荐jest-axe它把 axe-core 封装成 Jest 的匹配器写起来最省事。页面级扫描推荐axe-core/playwright官方维护和 Playwright 的 fixture 机制配合得很好。# 组件级Jest 环境 npm install --save-dev axe-core jest-axe jest-environment-jsdom # 页面级Playwright 环境 npm install --save-dev axe-core/playwright playwright/test # 如果项目用 React还需要测试渲染库 npm install --save-dev testing-library/react testing-library/jest-dom装完确认版本axe-core 每 3 到 5 个月发一次次要版本新规则会陆续加进来建议锁一个较新的版本npm ls axe-core # 期望输出类似 axe-core4.10.x2.2 用 TaoToken 统一管理测试脚本的模型调用凭证很多团队的 a11y 流程会加一步「自动生成修复建议」扫描出违规后把违规节点丢给模型让它输出一段修复代码或解释。这一步会引入 API Key。如果每个 workflow、每个脚本各写一份 Key轮换时就是灾难。我的做法是用 TaoToken 做统一的 Key/API 通道管理。它提供一个兼容 OpenAI 风格的接口把模型调用收敛到一个 Base URL 和一个 Key 上测试脚本、CI、本地开发共用同一套凭证。先在 TaoToken 控制台创建一个 API Key然后配置到本地环境变量里。注意不要把 Key 写进代码或提交到仓库# 本地开发写入 shell 配置或 .env.env 记得加进 .gitignore export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api在 GitHub Actions 里则把 Key 存进仓库 SecretsSettings → Secrets and variables → Actions → New repository secret命名为TAOTOKEN_API_KEY。workflow 里通过${{ secrets.TAOTOKEN_API_KEY }}引用永远不会出现在日志里。这里要强调一个原则Base URL 用https://taotoken.net/api不要带任何查询参数Key 只存 Secrets不存明文。这样无论你有多少个测试脚本要调模型改 Key 只需要改一处。2.3 目录结构约定为了让后面的配置片段路径一致约定如下结构project/ ├── .github/workflows/a11y.yml ├── a11y/ │ ├── axe-config.json # 共享的 axe 规则配置 │ └── report-to-llm.mjs # 调模型生成修复建议的脚本 ├── tests/ │ ├── unit/Button.a11y.test.tsx │ └── e2e/a11y.spec.ts └── package.json把 axe 的规则配置抽成独立 JSON组件级和页面级共用避免两处规则不一致导致「单测过了、E2E 挂了」这种诡异现象。3. 可复制配置axe 规则 JSON 与 CI 工作流 YAML这一节是全文的核心给你能直接落地的配置。先讲规则配置再讲两类测试的写法最后给完整的 GitHub Actions YAML。3.1 共享的 axe 规则配置新建a11y/axe-config.json。这个文件控制哪些规则跑、哪些暂时关掉。新手最容易犯的错是一上来全开 AAA结果几百条违规直接劝退。建议先跑 AA把 critical 和 serious 清零再逐步收紧。{ runOnly: { type: tag, values: [wcag2a, wcag2aa, wcag21a, wcag21aa, best-practice] }, rules: { color-contrast: { enabled: true }, region: { enabled: true }, landmark-one-main: { enabled: true }, page-has-heading-one: { enabled: true }, duplicate-id: { enabled: true }, aria-allowed-attr: { enabled: true } }, resultTypes: [violations, incomplete], reporter: v2 }几个参数说明。runOnly用 tag 过滤只跑 WCAG 2.0/2.1 的 A 和 AA 加上最佳实践这是大多数团队的现实起点。rules里显式打开几条高频规则比如page-has-heading-one检查页面有没有 h1landmark-one-main检查有没有 main 地标。resultTypes同时收集 violations 和 incompleteincomplete 是需要人工复核的项别直接忽略。reporter: v2是 axe 的新版结果格式字段更清晰。注意color-contrast规则在 JSDOM 环境下无法工作因为 JSDOM 不做真实布局计算。组件级测试里要么关掉它要么把对比度检查留给页面级 E2E。后面排障章节会细说。3.2 组件级扫描Jest jest-axe组件级扫描的价值是「快」和「定位准」。它跑在 JSDOM 里毫秒级出结果能精确告诉你哪个组件、哪一行有问题。新建tests/unit/Button.a11y.test.tsximport { render } from testing-library/react; import { axe, toHaveNoViolations } from jest-axe; import axeConfig from ../../a11y/axe-config.json; import { Button } from ../../src/components/Button; expect.extend(toHaveNoViolations); describe(Button 组件可访问性, () { it(默认状态下无违规, async () { const { container } render(Button提交/Button); const results await axe(container, { ...axeConfig, rules: { ...axeConfig.rules, color-contrast: { enabled: false } } }); expect(results).toHaveNoViolations(); }); it(禁用状态下仍可被辅助技术识别, async () { const { container } render(Button disabled提交/Button); const results await axe(container, axeConfig); expect(results).toHaveNoViolations(); }); });关键点axe(container, config)的第一个参数是测试上下文传container表示只扫这个组件渲染出的 DOM不扫整个 document。这样组件级测试不会因为页面其他部分的问题而误报。toHaveNoViolations()是 jest-axe 提供的匹配器断言 violations 数组为空。在package.json里加一条脚本{ scripts: { test:a11y:unit: jest --testMatch**/*.a11y.test.{ts,tsx} } }3.3 页面级扫描Playwright axe-core/playwright组件级测不出真实布局、真实对比度、真实 iframe 嵌套。页面级扫描跑在真实浏览器里覆盖这些场景。新建tests/e2e/a11y.spec.tsimport { test, expect } from playwright/test; import AxeBuilder from axe-core/playwright; import axeConfig from ../../a11y/axe-config.json; const pages [/, /login, /dashboard, /settings]; for (const path of pages) { test(页面 ${path} 无严重可访问性违规, async ({ page }) { await page.goto(path); await page.waitForLoadState(networkidle); const results await new AxeBuilder({ page }) .withTags([wcag2a, wcag2aa, wcag21a, wcag21aa]) .disableRules([region]) .analyze(); const serious results.violations.filter( (v) v.impact critical || v.impact serious ); if (serious.length 0) { console.log(JSON.stringify(serious, null, 2)); } expect(serious).toEqual([]); }); }这里用withTags指定规则集disableRules临时关掉某条规则比如region在 SPA 里经常误报。断言只拦 critical 和 seriousminor 和 moderate 先记录不阻断给团队一个缓冲期。打印违规详情是为了在 CI 日志里能直接看到问题节点。3.4 完整的 GitHub Actions 工作流新建.github/workflows/a11y.yml。这个 workflow 做四件事装依赖、跑组件级扫描、跑页面级扫描、把结果汇总成报告。name: Accessibility CI on: pull_request: branches: [main, develop] push: branches: [main] jobs: a11y-unit: name: 组件级可访问性扫描 runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 cache: npm - run: npm ci - run: npm run test:a11y:unit env: TAOTOKEN_API_KEY: ${{ secrets.TAOTOKEN_API_KEY }} TAOTOKEN_BASE_URL: https://taotoken.net/api a11y-e2e: name: 页面级可访问性扫描 runs-on: ubuntu-latest needs: a11y-unit steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 cache: npm - run: npm ci - run: npx playwright install --with-deps chromium - run: npm run build - run: npx playwright test tests/e2e/a11y.spec.ts env: TAOTOKEN_API_KEY: ${{ secrets.TAOTOKEN_API_KEY }} TAOTOKEN_BASE_URL: https://taotoken.net/api - name: 上传扫描报告 if: always() uses: actions/upload-artifactv4 with: name: a11y-report path: playwright-report/ retention-days: 14几个设计决策。a11y-e2e用needs: a11y-unit依赖组件级任务组件级挂了就不浪费资源跑 E2E。if: always()保证即使测试失败也上传报告方便排查。环境变量里注入 TaoToken 的 Key 和 Base URL测试脚本里如果调模型生成修复建议直接读这两个变量即可。3.5 用模型生成修复建议可选增强如果想让 CI 输出更友好可以加一个脚本把违规节点丢给模型生成中文修复说明。新建a11y/report-to-llm.mjsimport fs from node:fs; const BASE_URL process.env.TAOTOKEN_BASE_URL || https://taotoken.net/api; const API_KEY process.env.TAOTOKEN_API_KEY; export async function explainViolations(violations) { if (!API_KEY) { console.warn(未配置 TAOTOKEN_API_KEY跳过模型解释); return []; } const prompt 你是无障碍专家。以下是 axe-core 扫描出的违规项请为每一项给出简短的中文修复建议格式为「规则ID建议」\n${JSON.stringify( violations.map((v) ({ id: v.id, help: v.help, nodes: v.nodes.length })), null, 2 )}; const res await fetch(${BASE_URL}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${API_KEY} }, body: JSON.stringify({ model: gpt-4o-mini, messages: [{ role: user, content: prompt }], temperature: 0.2 }) }); if (!res.ok) { throw new Error(模型调用失败: ${res.status} ${await res.text()}); } const data await res.json(); return data.choices[0].message.content; }注意这里 Base URL 用的是https://taotoken.net/api路径拼/v1/chat/completions。Key 从环境变量读本地和 CI 共用一套逻辑。这样测试脚本里的模型调用凭证就完全收敛到 TaoToken 一处管理了。4. 验证请求本地跑通与 CI 成功结果配置写完先别急着推 CI本地跑一遍确认没问题能省掉大量「推上去等五分钟看日志」的时间。4.1 本地验证命令先跑组件级npm run test:a11y:unit期望输出类似PASS tests/unit/Button.a11y.test.tsx Button 组件可访问性 ✓ 默认状态下无违规 (45 ms) ✓ 禁用状态下仍可被辅助技术识别 (12 ms) Test Suites: 1 passed, 1 total Tests: 2 passed, 2 total再跑页面级。先启动本地服务再跑 Playwrightnpm run build npx playwright test tests/e2e/a11y.spec.ts --reporterlist如果页面有问题你会看到类似这样的输出直接指出违规规则和节点页面 /login 无严重可访问性违规 [ { id: label, impact: critical, help: Form elements must have labels, nodes: [ { html: input type\text\ placeholder\用户名\ } ] } ]这条就是典型的「用 placeholder 代替 label」修复方式是把input包进label或加aria-label。4.2 验证 TaoToken 通道单独测一下模型调用通道是否通export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api curl -s -X POST $TAOTOKEN_BASE_URL/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -d { model: gpt-4o-mini, messages: [{role: user, content: 用一句话解释 WCAG 2.1 AA}] } | head -c 500返回里能看到choices数组和内容说明通道正常。如果返回 401说明 Key 不对返回 404多半是 Base URL 拼错了路径。4.3 CI 成功的样子推上去之后在 PR 页面能看到两个 check组件级可访问性扫描和页面级可访问性扫描。全绿时是这样的✓ a11y-unit 组件级可访问性扫描 1m 12s ✓ a11y-e2e 页面级可访问性扫描 3m 45s点进a11y-e2e的 Artifacts能下载a11y-report里面有 Playwright 的 HTML 报告每个页面的扫描结果、违规节点、修复链接都在里面。这份报告就是你的「修复清单」——按 impact 从 critical 到 minor 排序一条条清。如果想让报告更结构化可以在 Playwright 配置里加 JSON reporter把 violations 导出成 JSON再喂给前面那个report-to-llm.mjs让模型生成中文修复建议附在 PR 评论里。5. 常见报错排查401、local proxy failed 与 reading choices这一节按真实报错来。CI 里出问题八成是下面这几类。5.1 401 UnauthorizedKey 没传对报错长这样Error: 模型调用失败: 401 {error:{message:Invalid API key}}排查顺序。第一确认 GitHub Secrets 里TAOTOKEN_API_KEY的值没有多余空格或换行复制时容易带上。第二确认 workflow 里env块真的注入了这个变量且变量名和脚本里读的名字一致。第三本地测试时确认export生效了用echo $TAOTOKEN_API_KEY检查。一个高频坑在 workflow 里把 Key 写成了${{ secrets.TAOTOKEN_API_KEY }}但 Secret 名字拼错比如写成了TAOTOKEN_KEY。GitHub 不会报错只会注入空字符串然后你收到 401。建议在脚本开头加一行校验if (!process.env.TAOTOKEN_API_KEY) { throw new Error(TAOTOKEN_API_KEY 未设置请检查 Secrets 配置); }5.2 local proxy failed网络层问题报错类似Error: connect ECONNREFUSED 127.0.0.1:7890 local proxy failed这是本地或 CI 环境里配了 HTTP_PROXY/HTTPS_PROXY 环境变量但代理服务没起来。CI runner 上通常不需要代理检查 workflow 里有没有继承到奇怪的代理变量。本地的话确认你的网络环境配置或者临时 unsetunset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy如果 CI 里确实需要走特定网络出口确保 runner 的网络策略允许访问taotoken.net否则请求会在网络层就被拦掉。5.3 reading choices响应结构不对报错TypeError: Cannot read properties of undefined (reading choices)这通常意味着res.json()返回的对象里没有choices字段。原因有几个。第一请求根本没成功返回的是错误对象但代码没检查res.ok就直接读choices。第二Base URL 拼错了比如写成了https://taotoken.net/api/v1又拼了一次/v1/chat/completions变成/api/v1/v1/chat/completions返回 404 的 HTML。第三模型名写错了某些模型 ID 不存在时返回结构不同。修复方式永远先检查res.ok再解析。前面report-to-llm.mjs里已经这么写了照抄即可。5.4 OAuth 相关报错如果你用的是 Claude Code 或某些需要 OAuth 的工具链可能遇到OAuth token expired or invalid这类报错和 axe-core 本身无关是工具链的认证问题。处理方式是重新走一遍授权流程或者改用 API Key 方式。如果你在 CI 里跑 Claude Code 做代码审查建议用 API Key 而不是 OAuth因为 OAuth 的 token 刷新在无头环境里很麻烦。5.5 color-contrast 在 JSDOM 里报错报错Error: color-contrast rule requires a real browser environment前面提过JSDOM 不做布局计算color-contrast跑不了。组件级测试里显式关掉它rules: { color-contrast: { enabled: false } }把对比度检查交给页面级 Playwright 测试那里是真实浏览器能正确计算。5.6 违规太多导致 CI 一直红新手常见困境接上 axe-core 后第一次跑出几百条违规CI 永远红团队干脆把 check 关掉。这是最糟的结果。正确做法是分阶段。第一阶段只拦 critical把expect(serious).toEqual([])改成expect(critical).toEqual([])。第二阶段清零 critical 后把 serious 加进来。第三阶段再考虑 moderate。同时用disableRules临时豁免那些修复成本高、影响低的规则在代码里留 TODO 注释排期处理。可访问性改进是持续过程不是一次性任务。6. 把凭证与扫描收敛到一处TaoToken 与后续动作到这里你的 CI 已经能自动跑组件级和页面级可访问性扫描了。最后说说怎么把这件事做得更可持续。第一凭证管理。测试脚本里所有模型调用都走 TaoToken 的 Base URL 和统一 Key本地用环境变量CI 用 Secrets。这样无论你有多少个脚本、多少个 workflow轮换 Key 只改一处。想创建和管理 Key去控制台操作即可。第二扫描范围。组件级覆盖 UI 组件库页面级覆盖关键路由。建议维护一个页面清单文件新增页面时同步加进去避免遗漏。清单可以放在a11y/pages.jsonPlaywright 测试读它来遍历。第三修复闭环。CI 报告里的 violations 就是修复清单。按 impact 排序critical 和 serious 优先。每条违规都带helpUrl指向 Deque 的规则文档里面有详细的修复示例。如果想让流程更顺用前面那个脚本把违规喂给模型生成中文修复建议附在 PR 评论里开发同学看到就能直接改。第四长期编码场景。如果你在团队里推动无障碍开发成为标准实践需要一套稳定的模型调用通道来支撑代码审查、修复建议生成这些 Agent 类任务可以考虑 Coding Plan把长期编码和 Agent 场景的额度固定下来避免临时 Key 到处散落。第五验证模型。如果你想先确认某个模型对无障碍规则的理解是否靠谱可以到模型对话里手动测几条比如把一段违规 HTML 丢进去问怎么修确认输出质量后再接进自动化流程。接入文档里有完整的 API 说明和示例遇到参数问题先查文档。最后给一个实用技巧把 axe-core 的扫描结果和代码覆盖率结合看。如果某个组件的可访问性测试覆盖率低说明它没被扫到可能是测试文件没匹配上*.a11y.test.tsx的 glob。定期检查测试文件命名规范比事后补测试省事得多。无障碍这件事工具能帮你发现 57% 左右的问题剩下的需要人工判断但把能自动化的部分自动化就已经让团队往前迈了一大步。