diff --git a/web/packages/studio/e2e-tests/a11y/a11y.test.ts b/web/packages/studio/e2e-tests/a11y/a11y.test.ts new file mode 100644 index 0000000000..6279aef7ce --- /dev/null +++ b/web/packages/studio/e2e-tests/a11y/a11y.test.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runAxeScan } from '@e2e-tests/a11y/axe'; +import { disableAuthForTest } from '@e2e-tests/utils/pageUtils'; +import { expect, test, type Page, type Route } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Fixed mock data — no live backend required. +// All API calls are intercepted by page.route() before any navigation. +// --------------------------------------------------------------------------- + +const MOCK_WORKSPACE = { + id: 'mock-workspace-id', + name: 'default', + description: 'Mock workspace for accessibility tests', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', +}; + +const fulfillJson = (route: Route, body: unknown) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + +/** + * Intercept NMP API calls and return fixed mock responses. + * + * Playwright uses LIFO for route handlers, so the workspace-specific route is + * registered last and therefore runs before the catch-all. + */ +const setupApiMocks = async (page: Page): Promise => { + // Catch-all: empty success for any unmatched API route + await page.route('**/apis/**', (route) => fulfillJson(route, {})); + + // WorkspaceProvider calls this on every authenticated page load + await page.route('**/apis/entities/v2/workspaces/default', (route) => + fulfillJson(route, MOCK_WORKSPACE) + ); +}; + +test.describe('Accessibility — Studio routes (axe / WCAG 2.x A+AA)', () => { + test.beforeEach(async ({ page }) => { + await setupApiMocks(page); + await disableAuthForTest(page); + }); + + test('workspace dashboard has no axe violations', async ({ page }) => { + await page.goto('/workspaces/default/dashboard'); + await page.waitForLoadState('networkidle'); + + const results = await runAxeScan(page); + + expect( + results.violations, + `axe violations on /workspaces/default/dashboard:\n${formatViolations(results.violations)}` + ).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface AxeViolation { + id: string; + description: string; + nodes: Array<{ html: string }>; +} + +const formatViolations = (violations: AxeViolation[]): string => + violations + .map( + (v) => + ` [${v.id}] ${v.description}\n` + + v.nodes.map((n) => ` → ${n.html.slice(0, 120)}`).join('\n') + ) + .join('\n'); diff --git a/web/packages/studio/e2e-tests/a11y/axe.ts b/web/packages/studio/e2e-tests/a11y/axe.ts new file mode 100644 index 0000000000..513d47ef31 --- /dev/null +++ b/web/packages/studio/e2e-tests/a11y/axe.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AxeBuilder } from '@axe-core/playwright'; +import type { Page } from '@playwright/test'; + +/** + * WCAG 2.x conformance tags targeted by our accessibility harness. + * Covers levels A and AA for WCAG 2.0, 2.1, and 2.2. + */ +const A11Y_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'] as const; + +/** Return type of a single axe scan — re-exported from @axe-core/playwright's analyze(). */ +export type AxeScanResult = Awaited>; + +/** + * Runs an axe accessibility scan on the current page state. + * + * Returns the raw AxeResults so callers can assert on violations, + * incomplete checks, or passes as appropriate for their test. + */ +export const runAxeScan = async (page: Page): Promise => + new AxeBuilder({ page }).withTags([...A11Y_TAGS]).analyze(); diff --git a/web/packages/studio/package.json b/web/packages/studio/package.json index ffe5f097cf..d006adf6f4 100644 --- a/web/packages/studio/package.json +++ b/web/packages/studio/package.json @@ -93,6 +93,7 @@ "@nemo/testing": "workspace:*", "@huggingface/hub": "^1.0.0", "@nvidia/foundations-tailwind-plugin": "catalog:", + "@axe-core/playwright": "^4.12.1", "@playwright/test": "^1.57.0", "@storybook/react": "catalog:", "@tailwindcss/postcss": "catalog:", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f02c5a114c..f84e2f78b2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -729,6 +729,9 @@ importers: specifier: ^5.0.3 version: 5.0.12(@types/react@19.2.14)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: + '@axe-core/playwright': + specifier: ^4.12.1 + version: 4.12.1(playwright-core@1.58.2) '@huggingface/hub': specifier: ^1.0.0 version: 1.1.2 @@ -951,6 +954,11 @@ packages: react: optional: true + '@axe-core/playwright@4.12.1': + resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -7669,6 +7677,11 @@ snapshots: '@types/react': 19.2.14 react: 19.2.7 + '@axe-core/playwright@4.12.1(playwright-core@1.58.2)': + dependencies: + axe-core: 4.12.1 + playwright-core: 1.58.2 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5