Sanity Studio 无障碍测试实战:Playwright 结合 Axe-Core 的键盘导航、ARIA 校验与焦点管理
2026/9/17 15:50:42 网站建设 项目流程

Sanity Studio 无障碍测试实战:Playwright 结合 Axe-Core 的键盘导航、ARIA 校验与焦点管理

【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity

Sanity Studio 是 Sanity 官方开源的内容工作区(Content Workspace)应用,其 E2E 测试套件基于 Playwright 构建(见 e2e 目录),覆盖表单输入、弹窗、富文本编辑器、文档列表等核心交互。本指南以仓库内 Playwright 最佳实践之无障碍测试文档 为主体,系统讲解如何用 Playwright 为 Sanity Studio 这类复杂富交互应用编写无障碍(a11y)测试:从 Axe-Core 自动化扫描、键盘导航验证、ARIA 角色/状态校验,到焦点管理与色彩/动效偏好模拟,最后落地为 CI 质量门禁。读完你可以在自己的 Studio 工作区或任意 Web 应用上复现整套无障碍测试方案。

Axe-Core 集成

Axe-Core 是 Deque 出品的开源无障碍引擎,@axe-core/playwright将其封装为 Playwright 测试中可直接调用的分析器,能够扫描页面 DOM 并按 WCAG 规则输出违规(violation)清单。Sanity 仓库的 Playwright 最佳实践技能 明确将 "testing accessibility (axe-core)" 列为该技能的适用场景,其决策树也将编写无障碍测试指向 accessibility.md。

安装与基本用例

npm install -D @axe-core/playwright

安装后即可编写最基础的全页扫描测试:

import {test, expect} from '@playwright/test' import AxeBuilder from '@axe-core/playwright' test('homepage should have no a11y violations', async ({page}) => { await page.goto('/') const results = await new AxeBuilder({page}).analyze() expect(results.violations).toEqual([]) })

new AxeBuilder({page})绑定当前页面上下文,analyze()返回results.violations数组,其中每项包含id(规则标识)、impact(严重级别)、descriptionnodes(违规 DOM 节点)等字段。对 Sanity Studio 这类单页应用,建议先等关键的data-testid容器可见再执行扫描,避免在骨架屏阶段误报——仓库的 studio-test.ts 中createDraftDocumentfixture 正是通过等待[data-testid="form-view"]可见来保证页面渲染完成。

限定扫描范围

整页扫描会带来噪声:主题色对比、第三方小部件等问题经常与业务无关。AxeBuilder 提供链式方法精确定位扫描范围:

test('form accessibility', async ({page}) => { await page.goto('/contact') // Analyze only the form const results = await new AxeBuilder({page}).include('#contact-form').analyze() expect(results.violations).toEqual([]) }) test('ignore known issues', async ({page}) => { await page.goto('/legacy-page') const results = await new AxeBuilder({page}) .exclude('.legacy-widget') // Skip legacy component .disableRules(['color-contrast']) // Disable specific rule .analyze() expect(results.violations).toEqual([]) })
  • include(selector):仅分析匹配的子树;
  • exclude(selector):跳过已知问题区域(如遗留组件);
  • disableRules(ruleIds):按规则 ID 禁用特定规则(如color-contrast);
  • 三者可自由组合,形成"扫新不扫旧、逐模块治理"的渐进式策略。

封装 A11y Fixture

Sanity 仓库本身就是 fixture 模式的重度使用者:studio-test.ts 通过baseTest.extend<SanityFixtures>注入了sanityClientcreateDraftDocument等自定义 fixture,隔离文档创建、数据清理与失败诊断。无障碍测试同样推荐用 fixture 固化"统一规则集 + 惰性构造"的逻辑:

// fixtures/a11y.fixture.ts import {test as base} from '@playwright/test' import AxeBuilder from '@axe-core/playwright' type A11yFixtures = { makeAxeBuilder: () => AxeBuilder } export const test = base.extend<A11yFixtures>({ makeAxeBuilder: async ({page}, use) => { await use(() => new AxeBuilder({page}).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])) }, }) // Usage test('dashboard a11y', async ({page, makeAxeBuilder}) => { await page.goto('/dashboard') const results = await makeAxeBuilder().analyze() expect(results.violations).toEqual([]) })

withTags([...])限定只执行 WCAG 2.0/2.1 的 A/AA 级规则,这是业界事实标准基线。通过 fixture 暴露makeAxeBuilder工厂函数而非单例对象,可以保证每个测试拿到绑定当前page的全新构建器,同时把规则集集中维护。

详细的违规报告

失败信息越具体,排查成本越低。将违规对象映射为可读的结构化字段,并作为断言失败消息输出:

test('report a11y issues', async ({page}) => { await page.goto('/') const results = await new AxeBuilder({page}).analyze() // Custom failure message with details const violations = results.violations.map((v) => ({ id: v.id, impact: v.impact, description: v.description, nodes: v.nodes.map((n) => n.html), })) expect(violations, JSON.stringify(violations, null, 2)).toHaveLength(0) })

toHaveLength(0)配合JSON.stringify(..., null, 2)作为第二个参数,测试失败时 Playwright 会在报告中直接渲染出完整的违规 JSON,包含规则 ID、影响级别、规则描述与违规节点的 HTML 快照,便于直接定位到具体组件。

键盘导航

键盘可达性是无障碍的核心支柱。Playwright 的page.keyboard.press()能模拟真实的 Tab/Enter/Escape 键击,配合toBeFocused()断言可以严格验证焦点流转。

Tab 顺序测试

test('correct tab order in form', async ({page}) => { await page.goto('/signup') // Start from the beginning await page.keyboard.press('Tab') await expect(page.getByLabel('Email')).toBeFocused() await page.keyboard.press('Tab') await expect(page.getByLabel('Password')).toBeFocused() await page.keyboard.press('Tab') await expect(page.getByRole('button', {name: 'Sign up'})).toBeFocused() })

逐次按 Tab 并断言getByLabel/getByRole定位的元素获得焦点,即可锁定表单的可见顺序(visual order)与 DOM 顺序(DOM order)是否一致——这是表单型组件最常见的无障碍回归点。

纯键盘完整流程

无障碍要求整条业务链路只靠键盘即可走通。以一个商品购买流程为例:

test('complete flow with keyboard only', async ({page}) => { await page.goto('/products') // Navigate to product with keyboard await page.keyboard.press('Tab') // Skip to main content await page.keyboard.press('Tab') // First product await page.keyboard.press('Enter') // Open product await expect(page).toHaveURL(/\/products\/\d+/) // Add to cart with keyboard await page.keyboard.press('Tab') await page.keyboard.press('Tab') // Navigate to "Add to Cart" await page.keyboard.press('Enter') await expect(page.getByRole('alert')).toContainText('Added to cart') })

Sanity 仓库的 TreeEditingNavigation.spec.ts 也大量采用"基于 role 定位 + 交互"的写法,例如用page.getByRole('button', {name: 'Albert, the whale'})打开数组项,再通过侧边栏菜单切换数组项,验证树编辑(Tree Editing)弹窗的完整键盘操作路径。

跳转链接(Skip Link)

test('skip link works', async ({page}) => { await page.goto('/') await page.keyboard.press('Tab') const skipLink = page.getByRole('link', {name: /skip to main/i}) await expect(skipLink).toBeFocused() await page.keyboard.press('Enter') // Focus should move to main content await expect(page.getByRole('main')).toBeFocused() })

跳转链接是长页面键盘用户的"快进按钮":第一次 Tab 必须命中跳转链接,回车后焦点应落到main地标。注意getByRole('main')本身就是对role="main"地标的语义化断言——如果页面没有 main 地标,这一步会直接失败。

Escape 键处理

模态弹窗必须支持 Escape 关闭,且焦点要正确回退。Sanity 仓库提供了极具参考价值的真实案例:FullScreenEscape.spec.ts 专门测试富文本(Portable Text)编辑器的全屏模式与 Escape 的交互层级:

test('you should be able to use scape to close full screen mode', async ({page}) => { await page.keyboard.press('Escape') await expect( page.getByTestId('field-text').getByTestId('fullscreen-button-expand'), ).toBeVisible() }) test('if in fullscreen mode, and having a popover open, escape should close the popover not the fullscreen mode', async ({page}) => { // 打开链接注解 popover 后按 Escape await page.keyboard.press('Escape') await expect(page.getByTestId('popover-edit-dialog')).not.toBeVisible() await expect(page.getByTestId('fullscreen-button-collapse')).toBeVisible() })

这个用例验证了关键的无障碍细节:Escape 应当关闭最内层的弹出层(popover),而非直接退出全屏模式,且关闭后全屏态必须保持——这正是模态层级(layer)管理中常见的 Bug 来源。类似地,array.spec.ts 中用page.getByRole('dialog')定位插入对话框,按 Escape 后断言toBeHidden(),并注释说明"Escape 关闭依赖层叠层级(layer)",与上文的 fixture 模式共同印证了弹窗类交互的测试要点。

通用的弹窗 Escape 测试模板:

test('escape closes modal', async ({page}) => { await page.goto('/dashboard') await page.getByRole('button', {name: 'Settings'}).click() const modal = page.getByRole('dialog') await expect(modal).toBeVisible() await page.keyboard.press('Escape') await expect(modal).toBeHidden() // Focus should return to trigger await expect(page.getByRole('button', {name: 'Settings'})).toBeFocused() })

ARIA 校验

角色验证

Playwright 的getByRole既是定位器也是语义化断言工具——元素必须真实暴露对应的 ARIA 角色才能被命中:

test('correct ARIA roles', async ({page}) => { await page.goto('/dashboard') // Verify landmark roles await expect(page.getByRole('navigation')).toBeVisible() await expect(page.getByRole('main')).toBeVisible() await expect(page.getByRole('contentinfo')).toBeVisible() // footer // Verify interactive roles await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible() await expect(page.getByRole('search')).toBeVisible() })

Sanity 的 E2E 套件深谙此道:menuItemSelectedIndicator.spec.ts 使用page.getByRole('menuitem', {name: 'View Mode: Default'})定位菜单项,用 role + accessible name 的组合替代脆弱的 CSS 选择器,同时顺带验证了菜单项暴露了menuitem角色。

ARIA 状态

手风琴、菜单、对话框等组件的展开状态必须通过aria-expanded正确表达:

test('aria-expanded updates correctly', async ({page}) => { await page.goto('/faq') const accordion = page.getByRole('button', {name: 'Shipping'}) // Initially collapsed await expect(accordion).toHaveAttribute('aria-expanded', 'false') await accordion.click() // Now expanded await expect(accordion).toHaveAttribute('aria-expanded', 'true') // Content is visible const panel = page.getByRole('region', {name: 'Shipping'}) await expect(panel).toBeVisible() })

toHaveAttribute自带自动重试,能容忍 UI 更新的异步时序。这里同时校验了三件事:开关前的折叠态、点击后的展开态,以及内容面板以region角色暴露——三者共同保证屏幕阅读器用户能感知状态变化。

活动区域(Live Region)

动态更新的内容(如购物车总价)必须通过 live region 向屏幕阅读器播报:

test('live region announces updates', async ({page}) => { await page.goto('/checkout') // Find live region const liveRegion = page.locator('[aria-live="polite"]') await page.getByLabel('Quantity').fill('3') // Live region should update with new total await expect(liveRegion).toContainText('Total: $29.97') })

aria-live="polite"表示内容更新后阅读器会在当前朗读完成后播报(不打断用户)。断言 live region 的文本内容是否随操作更新,是验证"动态内容可达"的最直接手段。

焦点管理

模态框焦点陷阱(Focus Trap)

模态框必须把 Tab 焦点"困"在自身内部,防止焦点逃逸到背景页面:

test('focus trapped in modal', async ({page}) => { await page.goto('/') await page.getByRole('button', {name: 'Open Modal'}).click() const modal = page.getByRole('dialog') await expect(modal).toBeVisible() // Get all focusable elements in modal const focusableElements = modal.locator( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ) const count = await focusableElements.count() // Tab through all elements, should stay in modal for (let i = 0; i < count + 1; i++) { await page.keyboard.press('Tab') const focused = page.locator(':focus') await expect(modal).toContainText((await focused.textContent()) || '') } })

技巧在于:遍历count + 1次 Tab(多按一次触发循环回绕),每次用page.locator(':focus')读取当前焦点元素,并断言其文本包含在模态框内。若焦点陷阱失效,最后一次 Tab 会让焦点落到模态框外,断言即失败。

焦点恢复

模态框关闭后,焦点必须回到打开它的触发元素,而不是丢回页面顶部或 body:

test('focus returns after modal close', async ({page}) => { await page.goto('/') const trigger = page.getByRole('button', {name: 'Delete Item'}) await trigger.click() await page.getByRole('button', {name: 'Cancel'}).click() // Focus should return to the trigger await expect(trigger).toBeFocused() })

这是键盘用户最常见的"迷路"场景,也是 WCAG 2.4.3(焦点顺序)与 2.4.7(焦点可见)的核心诉求。上文 FullScreenEscape 用例中"Escape 后全屏态保持"的断言,本质上也是焦点/状态恢复逻辑的验证。

色彩与对比度

高对比模式

Windows 高对比模式(forced colors)下,页面会被操作系统强制重绘配色,组件必须保证在系统色板下依然可辨识:

test('works in high contrast mode', async ({page}) => { await page.emulateMedia({forcedColors: 'active'}) await page.goto('/') // Verify key elements are visible await expect(page.getByRole('navigation')).toBeVisible() await expect(page.getByRole('button', {name: 'Sign In'})).toBeVisible() // Take screenshot for visual verification await expect(page).toHaveScreenshot('high-contrast.png') })

emulateMedia({forcedColors: 'active'})不需要真实操作系统支持即可模拟高对比渲染;结合toHaveScreenshot快照对比,可防止后续改动破坏高对比下的可读性(如需截图基线管理,可参考仓库的 test-suite-structure.md)。

减少动效(Reduced Motion)

前庭障碍用户会开启系统级"减少动效"偏好,应用应相应关闭或缩短动画。Sanity 仓库对此有真实的工程实践:在 e2e/playwright.config.ts 中,Chromium 项目的contextOptions设置了reducedMotion: 'reduce',Firefox 项目(L73-L75)以及全局use配置(L135)也统一声明了该偏好——整个 E2E 套件默认运行在"减少动效"环境下,既加速了测试,也持续守护了动效降级逻辑。

单测级别的模拟与断言:

test('respects reduced motion preference', async ({page}) => { await page.emulateMedia({reducedMotion: 'reduce'}) await page.goto('/') // Animations should be disabled const hero = page.getByTestId('hero-animation') const animation = await hero.evaluate((el) => getComputedStyle(el).animationDuration) expect(animation).toBe('0s') })

通过getComputedStyle(...).animationDuration读取计算样式,直接断言动画时长归零——用数据说话,而非肉眼观察。

CI 集成:把无障碍设为质量门禁

自动化扫描只有进入 CI 才有持续价值。将无障碍测试拆分为独立 project,并只运行*.a11y.spec.ts命名的用例:

// playwright.config.ts export default defineConfig({ projects: [ { name: 'a11y', testMatch: /.*\.a11y\.spec\.ts/, use: {...devices['Desktop Chrome']}, }, ], })
# .github/workflows/a11y.yml - name: Run accessibility tests run: npx playwright test --project=a11y

Sanity 的 playwright.config.ts 展示了同类配置的完整形态:testMatch之外的timeout: 60_000expect.timeout: 30_000retries: 2fullyParallel: true以及按浏览器拆分的projectschromium/firefox/ macOS 下的webkit)都可直接借鉴。a11y project 同样可以按需引入浏览器矩阵,并在 PR 与 nightly 两个频率上分别运行。需要更细的按标签筛选能力时,可参考仓库内 test-tags.md。

需要避开的反模式

反模式问题正确做法
只在首页测无障碍漏掉其他页面的问题覆盖所有关键用户流程
忽略所有违规测试失去价值修复问题,或显式 exclude 已知项
只依赖自动化测试漏掉大量人工才可见的无障碍问题与人工测试结合
从不测试屏幕阅读器漏掉交互层面的问题定期用 VoiceOver / NVDA 实测

结合 Sanity 的实践可以看到,"避免只在首页测"这一点尤其重要:仓库的 E2E 套件按功能域拆分(e2e/tests 下的inputsptetree-editingenhanced-object-dialogstructure等目录),每个关键交互组件都有专属用例文件,无障碍扫描也应沿同样的粒度展开,而不是集中在一个入口页面。

相关参考

  • 定位器(role-based selectors):详见 locators.md,掌握getByRolegetByLabel等语义化定位器的全部用法;
  • 视觉回归测试:详见 test-suite-structure.md,了解如何用截图对比守护高对比模式等视觉类无障碍回归;
  • Sanity 仓库实测用例:FullScreenEscape.spec.ts(Escape 层级)、array.spec.ts(对话框键盘关闭)、menuItemSelectedIndicator.spec.ts(menuitem 角色)、studio-test.ts(fixture 封装范式)、playwright.config.ts(项目与 reducedMotion 配置)。

以上方案既可以直接迁移到你的 Sanity Studio 工作区,也可以作为任何 React/复杂富交互应用无障碍测试的通用模板:先用 Axe-Core 建立自动扫描基线,再以键盘导航、ARIA 状态与焦点管理用例覆盖关键交互路径,最后接入 CI 作为持续质量门禁。

【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询