From ba65cba933cb4d593e9e9963c259f51ea1f139c8 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Tue, 7 Jul 2026 15:20:57 +0200 Subject: [PATCH 01/12] add playwright a11y tests --- package.json | 7 +- playwright.config.ts | 6 +- playwright/a11y-matrix-generator.js | 240 ---------------- .../accessibility-composition.spec.ts | 221 +++++++++++++++ .../accessibility-cps-ui-kit.spec.ts | 137 +++++++++ .../components}/cps-autocomplete.spec.ts | 0 .../components}/cps-scheduler.spec.ts | 0 .../components}/cps-table.spec.ts | 2 + playwright/fixtures/axe-helpers.ts | 56 ++++ playwright/fixtures/axe-test.ts | 24 -- .../composition-components.ts} | 268 ++++++------------ .../composition/src/app/app.component.html | 2 +- .../composition/src/app/app.component.scss | 2 + .../code-example/code-example.component.html | 4 +- .../code-example/code-example.component.scss | 7 +- .../button-toggle-page.component.html | 3 +- .../sidebar-menu-page.component.html | 5 +- 17 files changed, 525 insertions(+), 459 deletions(-) delete mode 100644 playwright/a11y-matrix-generator.js create mode 100644 playwright/composition/accessibility-composition.spec.ts create mode 100644 playwright/cps-ui-kit/accessibility-cps-ui-kit.spec.ts rename playwright/{ => cps-ui-kit/components}/cps-autocomplete.spec.ts (100%) rename playwright/{ => cps-ui-kit/components}/cps-scheduler.spec.ts (100%) rename playwright/{ => cps-ui-kit/components}/cps-table.spec.ts (97%) create mode 100644 playwright/fixtures/axe-helpers.ts delete mode 100644 playwright/fixtures/axe-test.ts rename playwright/{cps-accessibility.spec.ts => fixtures/composition-components.ts} (57%) diff --git a/package.json b/package.json index 82cae83a3..1ba2b97ef 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,11 @@ "test:playwright:webkit": "playwright test --project=webkit", "test:playwright:webkit:headed": "playwright test --project=webkit --headed", "test:playwright:accessibility": "playwright test --project=accessibility", - "test:playwright:accessibility:matrix": "node playwright/a11y-matrix-generator.js", - "test:playwright:accessibility:matrix:from-cache": "node playwright/a11y-matrix-generator.js --from-file playwright-report/a11y-report.json", + "test:playwright:composition:all": "playwright test playwright/composition --project=chromium --project=webkit --project=accessibility", + "test:playwright:composition:accessibility": "playwright test playwright/composition/accessibility-composition.spec.ts --project=accessibility", + "test:playwright:cps-ui-kit:all": "playwright test playwright/cps-ui-kit --project=chromium --project=webkit --project=accessibility", + "test:playwright:cps-ui-kit:accessibility": "playwright test playwright/cps-ui-kit/accessibility-cps-ui-kit.spec.ts --project=accessibility", + "test:playwright:cps-ui-kit:components": "playwright test playwright/cps-ui-kit/components --project=chromium --project=webkit", "format": "prettier --write \"**/*.{ts,html}\"" }, "private": true, diff --git a/playwright.config.ts b/playwright.config.ts index dbb999369..6a787a1ea 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -45,17 +45,17 @@ export default defineConfig({ projects: [ { name: 'chromium', - testIgnore: /cps-accessibility/, + testIgnore: /accessibility/, use: { ...devices['Desktop Chrome'] } }, { name: 'webkit', - testIgnore: /cps-accessibility/, + testIgnore: /accessibility/, use: { ...devices['Desktop Safari'] } }, { name: 'accessibility', - testMatch: /cps-accessibility/, + testMatch: /accessibility/, use: { ...devices['Desktop Chrome'] } } ], diff --git a/playwright/a11y-matrix-generator.js b/playwright/a11y-matrix-generator.js deleted file mode 100644 index 95d0f2728..000000000 --- a/playwright/a11y-matrix-generator.js +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable @typescript-eslint/no-var-requires */ - -/** - * Accessibility Test Matrix Generator - * - * Runs Playwright accessibility tests and produces a summary matrix table. - * - * Usage: - * node playwright/a11y-matrix-generator.js # run tests + print table - * node playwright/a11y-matrix-generator.js --from-file # use existing JSON report - * node playwright/a11y-matrix-generator.js --help - */ - -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -const args = process.argv.slice(2); - -if (args.includes('--help') || args.includes('-h')) { - console.log(` -Usage: - node playwright/a11y-matrix-generator.js Run tests and print matrix - node playwright/a11y-matrix-generator.js --from-file Build matrix from existing JSON report - -Options: - --from-file Path to a Playwright JSON report (skips running tests) - --help, -h Show this help message -`); - process.exit(0); -} - -let jsonPath; - -const fromFileIdx = args.indexOf('--from-file'); -if (fromFileIdx !== -1) { - jsonPath = args[fromFileIdx + 1]; - if (!jsonPath || !fs.existsSync(jsonPath)) { - console.error(`Error: file not found – ${jsonPath}`); - process.exit(1); - } -} else { - // Run the tests and capture the JSON report - const reportDir = path.resolve(__dirname, '..', 'playwright-report'); - if (!fs.existsSync(reportDir)) { - fs.mkdirSync(reportDir, { recursive: true }); - } - const tmpReport = path.join(reportDir, 'a11y-report.json'); - console.log('Running accessibility tests …\n'); - try { - execSync(`npx playwright test --project=accessibility --reporter=json`, { - cwd: path.resolve(__dirname, '..'), - stdio: ['inherit', fs.openSync(tmpReport, 'w'), 'pipe'] - }); - } catch { - // Tests may exit non-zero when there are failures — that's expected - } - console.log(`Report saved to ${tmpReport}\n`); - jsonPath = tmpReport; -} - -// --------------------------------------------------------------------------- -// Parse -// --------------------------------------------------------------------------- - -const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); - -function flattenSuites(suites) { - const out = []; - for (const suite of suites) { - for (const spec of suite.specs || []) { - const result = spec.tests?.[0]?.results?.[0]; - out.push({ - suite: suite.title, - test: spec.title, - status: result?.status ?? 'unknown', - error: result?.error?.message ?? '', - axeResults: parseAxeAttachments(result) - }); - } - for (const sub of suite.suites || []) { - for (const spec of sub.specs || []) { - const result = spec.tests?.[0]?.results?.[0]; - out.push({ - suite: suite.title, - test: `${sub.title} - ${spec.title}`, - status: result?.status ?? 'unknown', - error: result?.error?.message ?? '', - axeResults: parseAxeAttachments(result) - }); - } - } - } - return out; -} - -/** Extract all axe-core results objects from the test attachments (one per state) */ -function parseAxeAttachments(result) { - if (!result?.attachments) return []; - return result.attachments - .filter((a) => a.name.endsWith('-accessibility-scan') && a.body) - .map((a) => { - try { - return JSON.parse(Buffer.from(a.body, 'base64').toString()); - } catch { - return null; - } - }) - .filter(Boolean); -} - -const tests = flattenSuites(data.suites?.[0]?.suites ?? []); - -// --------------------------------------------------------------------------- -// Extract violations and applicability per component -// --------------------------------------------------------------------------- - -const failedRules = {}; // { "Component|||rule-id": true } -const applicableRules = {}; // { "Component|||rule-id": true } -const allComponents = new Set(); -const allRules = new Set(); // only rules that have at least one violation - -function extractViolationIds(errorMsg) { - return [ - ...new Set( - (errorMsg.match(/] ([a-z][a-z0-9-]+):/g) || []).map((m) => m.slice(2, -1)) - ) - ]; -} - -function addResults(comp, status, errorMsg, axeResultsArr) { - allComponents.add(comp); - - // Use axe attachments for accurate applicability data (one per state scan) - if (axeResultsArr.length > 0) { - for (const axeResults of axeResultsArr) { - for (const v of axeResults.violations || []) { - failedRules[`${comp}|||${v.id}`] = true; - applicableRules[`${comp}|||${v.id}`] = true; - allRules.add(v.id); - } - for (const p of axeResults.passes || []) { - applicableRules[`${comp}|||${p.id}`] = true; - } - for (const inc of axeResults.incomplete || []) { - applicableRules[`${comp}|||${inc.id}`] = true; - } - } - return; - } - - // Fallback: parse from error message (no attachment available) - if (status !== 'passed') { - const ids = extractViolationIds(errorMsg); - for (const id of ids) { - allRules.add(id); - failedRules[`${comp}|||${id}`] = true; - applicableRules[`${comp}|||${id}`] = true; - } - } -} - -for (const t of tests) { - const s = t.suite; - const n = t.test; - - if ( - s === 'Accessibility - axe scan' || - s === 'Accessibility - responsive axe scan' - ) { - addResults(n.replace(/ should.*/, ''), t.status, t.error, t.axeResults); - continue; - } -} - -// --------------------------------------------------------------------------- -// Render -// --------------------------------------------------------------------------- - -const cols = [...allRules].sort(); -const components = [...allComponents].sort(); - -function cell(comp, rule) { - const key = `${comp}|||${rule}`; - if (failedRules[key]) return '❌'; - if (applicableRules[key]) return '✅'; - return '-'; -} - -const COL_WIDTHS = [22, ...cols.map((c) => Math.max(c.length, 6))]; -const padEnd = (s, w) => s + ' '.repeat(Math.max(0, w - s.length)); - -function row(cells) { - return cells.map((c, i) => padEnd(c, COL_WIDTHS[i])).join(' | '); -} - -console.log(); -console.log(row(['Component', ...cols])); -console.log(row(COL_WIDTHS.map((w) => '-'.repeat(w)))); - -let totalPass = 0; -let totalFail = 0; -let totalNA = 0; - -for (const comp of components) { - const cells = cols.map((rule) => { - const v = cell(comp, rule); - if (v === '✅') totalPass++; - else if (v === '❌') totalFail++; - else totalNA++; - return v; - }); - console.log(row([comp, ...cells])); -} - -// Summary row -const summaries = cols.map((rule) => { - let fail = 0; - let applicable = 0; - for (const comp of components) { - const key = `${comp}|||${rule}`; - if (applicableRules[key]) { - applicable++; - if (failedRules[key]) fail++; - } - } - return `${applicable - fail}/${applicable}`; -}); - -console.log(row(COL_WIDTHS.map((w) => '-'.repeat(w)))); -console.log(row(['PASS / TESTED', ...summaries])); - -console.log(`\nCells: ${totalPass} PASS, ${totalFail} FAIL, ${totalNA} N/A`); -console.log(`Components: ${components.length}`); -console.log(`Rules with violations: ${cols.length}`); diff --git a/playwright/composition/accessibility-composition.spec.ts b/playwright/composition/accessibility-composition.spec.ts new file mode 100644 index 000000000..02acb3917 --- /dev/null +++ b/playwright/composition/accessibility-composition.spec.ts @@ -0,0 +1,221 @@ +import { type Page, type TestInfo, expect } from '@playwright/test'; +import type AxeBuilder from '@axe-core/playwright'; +import { pageRoutes } from '../fixtures/composition-components'; +import { + test, + expectNoViolations, + waitForAnimationsToFinish +} from '../fixtures/axe-helpers'; + +const toggle = (page: Page) => + page.getByRole('button', { name: 'Toggle navigation sidebar' }); +const nav = (page: Page) => + page.getByRole('navigation', { name: 'Main navigation' }); +const mainContent = (page: Page) => page.locator('#main-content'); +const backdrop = (page: Page) => page.locator('.sidebar-backdrop'); + +async function runFullPageScan( + page: Page, + makeAxeBuilder: () => AxeBuilder, + label: string, + testInfo: TestInfo +) { + await page.waitForSelector('#main-content'); + await waitForAnimationsToFinish(page); + // No .include() — scans the whole rendered document (header + sidebar + main), + // unlike accessibility-cps-ui-kit.spec.ts which scopes scans to individual components. + const results = await makeAxeBuilder().analyze(); + await testInfo.attach(`${label}-full-page-accessibility-scan`, { + body: JSON.stringify(results, null, 2), + contentType: 'application/json' + }); + expectNoViolations(results.violations); +} + +// ============================================================================ +// axe-core WCAG AA — full page scans, one per composition app route +// ============================================================================ + +test.describe('Accessibility - full page axe scan', () => { + for (const route of pageRoutes) { + test(`${route} should have no violations (full page)`, async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.goto(route); + await runFullPageScan(page, makeAxeBuilder, route, testInfo); + }); + } +}); + +// ============================================================================ +// axe-core at mobile viewport +// ============================================================================ + +test.describe('Accessibility - responsive full page axe scan', () => { + for (const route of pageRoutes) { + test(`${route} should have no violations at mobile width (full page)`, async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(route); + await runFullPageScan(page, makeAxeBuilder, route, testInfo); + }); + } +}); + +// ============================================================================ +// app shell interactive states not covered by the resting-state scans above +// ============================================================================ + +test.describe('Accessibility - app shell interactive states', () => { + test('sidebar collapsed (desktop) has no violations', async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.goto('/colors'); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await runFullPageScan( + page, + makeAxeBuilder, + 'sidebar-collapsed-desktop', + testInfo + ); + }); + + test('sidebar open on mobile (with backdrop) has no violations', async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/colors'); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await page.waitForSelector('.sidebar-backdrop'); + await runFullPageScan( + page, + makeAxeBuilder, + 'sidebar-open-mobile', + testInfo + ); + }); + + test('sidebar collapsed/inert on mobile has no violations', async ({ + page, + makeAxeBuilder + }, testInfo) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/colors'); + await runFullPageScan( + page, + makeAxeBuilder, + 'sidebar-collapsed-mobile', + testInfo + ); + }); +}); + +// ============================================================================ +// app shell focus flows not covered by the resting-state scans above +// ============================================================================ + +test.describe('Composition app shell - sidebar & focus flows', () => { + test.describe('desktop viewport', () => { + test('sidebar toggle has correct aria-expanded and the nav landmark exists', async ({ + page + }) => { + await page.goto('/colors'); + await expect(nav(page)).toBeVisible(); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'true'); + await toggle(page).click(); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); + await expect(nav(page)).toHaveAttribute('inert', ''); + await toggle(page).click(); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'true'); + }); + + test('focus moves to #main-content after direct navigation', async ({ + page + }) => { + await page.goto('/button'); + await expect(mainContent(page)).toBeFocused(); + }); + + test('focus moves to #main-content after clicking a sidebar nav link', async ({ + page + }) => { + await page.goto('/colors'); + await nav(page) + .getByRole('link', { name: 'Button', exact: true }) + .click(); + await expect(page).toHaveURL(/\/button(\/examples)?$/); + await expect(mainContent(page)).toBeFocused(); + }); + + test('active nav link has aria-current="page"', async ({ page }) => { + await page.goto('/button'); + await expect( + nav(page).getByRole('link', { name: 'Button', exact: true }) + ).toHaveAttribute('aria-current', 'page'); + }); + + test('Shift+Tab from main content redirects focus to the active nav link', async ({ + page + }) => { + await page.goto('/button'); + await expect(mainContent(page)).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect( + nav(page).getByRole('link', { name: 'Button', exact: true }) + ).toBeFocused(); + }); + }); + + test.describe('mobile viewport (375x812)', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + }); + + test('sidebar starts closed on mobile', async ({ page }) => { + await page.goto('/colors'); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); + await expect(backdrop(page)).toHaveCount(0); + }); + + test('opening the sidebar shows the backdrop', async ({ page }) => { + await page.goto('/colors'); + await toggle(page).click(); + await expect(backdrop(page)).toBeVisible(); + }); + + test('clicking a nav link closes the sidebar and focuses main content', async ({ + page + }) => { + await page.goto('/colors'); + await toggle(page).click(); + await nav(page) + .getByRole('link', { name: 'Button', exact: true }) + .click(); + await expect(page).toHaveURL(/\/button(\/examples)?$/); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); + await expect(backdrop(page)).toHaveCount(0); + await expect(mainContent(page)).toBeFocused(); + }); + + test('Escape closes the sidebar and returns focus to the toggle button', async ({ + page + }) => { + await page.goto('/colors'); + await toggle(page).click(); + await expect(backdrop(page)).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(toggle(page)).toHaveAttribute('aria-expanded', 'false'); + await expect(backdrop(page)).toHaveCount(0); + await expect(toggle(page)).toBeFocused(); + }); + }); +}); diff --git a/playwright/cps-ui-kit/accessibility-cps-ui-kit.spec.ts b/playwright/cps-ui-kit/accessibility-cps-ui-kit.spec.ts new file mode 100644 index 000000000..1a3ff1a0d --- /dev/null +++ b/playwright/cps-ui-kit/accessibility-cps-ui-kit.spec.ts @@ -0,0 +1,137 @@ +import type { Page, TestInfo } from '@playwright/test'; +import type AxeBuilder from '@axe-core/playwright'; +import { + components, + type ComponentEntry +} from '../fixtures/composition-components'; +import { + test, + expectNoViolations, + waitForAnimationsToFinish +} from '../fixtures/axe-helpers'; + +function buildAxeWithSelectors( + makeAxeBuilder: () => AxeBuilder, + selector: string | string[] +) { + const selectors = Array.isArray(selector) ? selector : [selector]; + let builder = makeAxeBuilder(); + for (const s of selectors) { + builder = builder.include(s); + } + return builder; +} + +async function waitForSelectors(page: Page, selector: string | string[]) { + const selectors = Array.isArray(selector) ? selector : [selector]; + await Promise.all(selectors.map((s) => page.waitForSelector(s))); +} + +/** + * Groups consecutive entries sharing the same `group` so their tests can be + * nested under a shared test.describe block. + */ +function groupComponents(entries: ComponentEntry[]) { + const groups = new Map(); + const items: { group?: string; entries: ComponentEntry[] }[] = []; + + for (const entry of entries) { + if (entry.group) { + let bucket = groups.get(entry.group); + if (!bucket) { + bucket = []; + groups.set(entry.group, bucket); + items.push({ group: entry.group, entries: bucket }); + } + bucket.push(entry); + } else { + items.push({ entries: [entry] }); + } + } + + return items; +} + +function registerScanTests( + buildTitle: (name: string) => string, + registerTest: (entry: ComponentEntry, title: string) => void +) { + for (const item of groupComponents(components)) { + if (item.group) { + test.describe(item.group, () => { + for (const entry of item.entries) { + registerTest(entry, buildTitle(entry.name)); + } + }); + } else { + registerTest(item.entries[0], buildTitle(item.entries[0].name)); + } + } +} + +// ============================================================================ +// axe-core WCAG AA — all component pages +// ============================================================================ + +async function runScan( + page: Page, + makeAxeBuilder: () => AxeBuilder, + selector: string | string[], + label: string, + testInfo: TestInfo +) { + await waitForSelectors(page, selector); + await waitForAnimationsToFinish(page); + const results = await buildAxeWithSelectors( + makeAxeBuilder, + selector + ).analyze(); + await testInfo.attach(`${label}-accessibility-scan`, { + body: JSON.stringify(results, null, 2), + contentType: 'application/json' + }); + expectNoViolations(results.violations); +} + +test.describe('Accessibility - axe scan', () => { + registerScanTests( + (name) => `${name} should have no violations`, + (entry, title) => { + test(title, async ({ page, makeAxeBuilder }, testInfo) => { + await page.goto(entry.route); + if (entry.setup) await entry.setup(page); + await runScan( + page, + makeAxeBuilder, + entry.selector, + 'default', + testInfo + ); + }); + } + ); +}); + +// ============================================================================ +// axe-core at mobile viewport +// ============================================================================ + +test.describe('Accessibility - responsive axe scan', () => { + registerScanTests( + (name) => `${name} should have no violations at mobile width`, + (entry, title) => { + test(title, async ({ page, makeAxeBuilder }, testInfo) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(entry.route); + if (entry.setup) await entry.setup(page); + await runScan( + page, + makeAxeBuilder, + entry.selector, + 'default', + testInfo + ); + }); + } + ); +}); diff --git a/playwright/cps-autocomplete.spec.ts b/playwright/cps-ui-kit/components/cps-autocomplete.spec.ts similarity index 100% rename from playwright/cps-autocomplete.spec.ts rename to playwright/cps-ui-kit/components/cps-autocomplete.spec.ts diff --git a/playwright/cps-scheduler.spec.ts b/playwright/cps-ui-kit/components/cps-scheduler.spec.ts similarity index 100% rename from playwright/cps-scheduler.spec.ts rename to playwright/cps-ui-kit/components/cps-scheduler.spec.ts diff --git a/playwright/cps-table.spec.ts b/playwright/cps-ui-kit/components/cps-table.spec.ts similarity index 97% rename from playwright/cps-table.spec.ts rename to playwright/cps-ui-kit/components/cps-table.spec.ts index ca27fcd12..a15c88b4a 100644 --- a/playwright/cps-table.spec.ts +++ b/playwright/cps-ui-kit/components/cps-table.spec.ts @@ -25,6 +25,8 @@ test.describe('cps-table page', () => { const downloadedContent = fs.readFileSync(downloadedPath!); const fixturePath = path.join( __dirname, + '..', + '..', 'fixtures', 'table_6_fixture.xlsx' ); diff --git a/playwright/fixtures/axe-helpers.ts b/playwright/fixtures/axe-helpers.ts new file mode 100644 index 000000000..d72913fcd --- /dev/null +++ b/playwright/fixtures/axe-helpers.ts @@ -0,0 +1,56 @@ +import { test as base, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +type Violations = Awaited>['violations']; + +type AxeFixture = { + makeAxeBuilder: () => AxeBuilder; +}; + +export const test = base.extend({ + makeAxeBuilder: async ({ page }, use) => { + const makeAxeBuilder = () => + new AxeBuilder({ page }).withTags([ + 'wcag2a', + 'wcag2aa', + 'wcag21a', + 'wcag21aa', + 'wcag22a', + 'wcag22aa', + 'best-practice' + ]); + await use(makeAxeBuilder); + } +}); + +export function formatViolations(violations: Violations): string { + if (violations.length === 0) return ''; + return violations + .map((v) => { + const nodes = v.nodes + .map((n) => ` - ${n.html}\n ${n.failureSummary}`) + .join('\n'); + return `\n[${v.impact}] ${v.id}: ${v.description}\n Help: ${v.helpUrl}\n${nodes}`; + }) + .join('\n'); +} + +export function expectNoViolations(violations: Violations) { + expect(violations, formatViolations(violations)).toHaveLength(0); +} + +/** + * Wait for all animations/transitions to complete before scanning. + * Without this, axe may capture intermediate states (e.g. mid-transition + * background color) and report false-positive color contrast violations. + */ +export async function waitForAnimationsToFinish(page: Page) { + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter((a) => a.effect?.getTiming().iterations !== Infinity) + .map((a) => a.finished.catch(() => {})) + ) + ); +} diff --git a/playwright/fixtures/axe-test.ts b/playwright/fixtures/axe-test.ts deleted file mode 100644 index 0bba0273a..000000000 --- a/playwright/fixtures/axe-test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test as base } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -type AxeFixture = { - makeAxeBuilder: () => AxeBuilder; -}; - -export const test = base.extend({ - makeAxeBuilder: async ({ page }, use) => { - const makeAxeBuilder = () => - new AxeBuilder({ page }).withTags([ - 'wcag2a', - 'wcag2aa', - 'wcag21a', - 'wcag21aa', - 'wcag22a', - 'wcag22aa', - 'best-practice' - ]); - await use(makeAxeBuilder); - } -}); - -export { expect } from '@playwright/test'; diff --git a/playwright/cps-accessibility.spec.ts b/playwright/fixtures/composition-components.ts similarity index 57% rename from playwright/cps-accessibility.spec.ts rename to playwright/fixtures/composition-components.ts index dc39b72cc..15501f27b 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/fixtures/composition-components.ts @@ -1,23 +1,18 @@ -import { test, expect } from './fixtures/axe-test'; -import type { Page, TestInfo } from '@playwright/test'; -import type AxeBuilder from '@axe-core/playwright'; +import type { Page } from '@playwright/test'; -interface ComponentState { - label: string; - setup: (page: Page) => Promise; -} - -interface ComponentEntry { +export interface ComponentEntry { route: string; name: string; selector: string | string[]; /** For overlay components: trigger them before scanning */ setup?: (page: Page) => Promise; - /** For components that need multiple scans (e.g., different states/tabs) */ - states?: ComponentState[]; + /** Nests this entry's test under a shared test.describe(group) with other + * entries that share the same group. + */ + group?: string; } -const components: ComponentEntry[] = [ +export const components: ComponentEntry[] = [ { route: '/autocomplete', name: 'Autocomplete', @@ -51,6 +46,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Confirmation dialog', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -63,6 +59,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Regular dialog', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -75,6 +72,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Draggable dialog', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -87,6 +85,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Resizable dialog', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -99,6 +98,7 @@ const components: ComponentEntry[] = [ route: '/dialog', name: 'Dialog with blurred background and container autofocus', selector: '[role="dialog"]', + group: 'Dialog', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -121,11 +121,17 @@ const components: ComponentEntry[] = [ { route: '/icon', name: 'Icon', selector: '.example-content cps-icon' }, { route: '/info-circle', name: 'Info circle', selector: 'cps-info-circle' }, { route: '/input', name: 'Input', selector: '.example-content cps-input' }, - { route: '/loader', name: 'Loader', selector: '.example-content cps-loader' }, + { + route: '/loader', + name: 'Loader', + selector: '.example-content cps-loader', + group: 'Loader' + }, { route: '/loader', name: 'Loader fullscreen', selector: '.example-content cps-loader', + group: 'Loader', setup: async (page) => { await page.waitForSelector('.example-content'); await page @@ -141,6 +147,7 @@ const components: ComponentEntry[] = [ route: '/menu', name: 'Menu standard', selector: '.cps-menu-container', + group: 'Menu', setup: async (page) => { await page.waitForSelector('.example-content'); await page.locator('.example-content cps-button').first().click(); @@ -150,6 +157,7 @@ const components: ComponentEntry[] = [ route: '/menu', name: 'Menu compressed', selector: '.cps-menu-container', + group: 'Menu', setup: async (page) => { await page.waitForSelector('.example-content'); await page.locator('.example-content cps-button').nth(2).click(); @@ -182,26 +190,26 @@ const components: ComponentEntry[] = [ selector: 'cps-progress-linear' }, { route: '/radio-group', name: 'Radio', selector: 'cps-radio-group' }, - { - route: '/scheduler', - name: 'Scheduler', - selector: 'cps-scheduler', - states: [ - 'Minutes', - 'Hourly', - 'Daily', - 'Weekly', - 'Monthly', - 'Yearly', - 'Advanced' - ].map((tab) => ({ - label: tab, - setup: async (page: Page) => { + ...[ + 'Minutes', + 'Hourly', + 'Daily', + 'Weekly', + 'Monthly', + 'Yearly', + 'Advanced' + ].map( + (tab): ComponentEntry => ({ + route: '/scheduler', + name: tab, + selector: 'cps-scheduler', + group: 'Scheduler', + setup: async (page) => { await page.getByTestId('schedule-type-toggle').getByText(tab).click(); await page.waitForSelector('cps-scheduler'); } - })) - }, + }) + ), { route: '/select', name: 'Select', @@ -222,29 +230,29 @@ const components: ComponentEntry[] = [ name: 'Tabs', selector: '.example-content cps-tab-group' }, - { - route: '/table', - name: 'Table', - selector: 'cps-table', - states: [ - 'Table 1', - 'Table 2', - 'Table 3', - 'Table 4', - 'Table 5', - 'Table 6', - 'Table 7', - 'Table 8', - 'Table 9', - 'Table 10' - ].map((tab) => ({ - label: tab, - setup: async (page: Page) => { + ...[ + 'Table 1', + 'Table 2', + 'Table 3', + 'Table 4', + 'Table 5', + 'Table 6', + 'Table 7', + 'Table 8', + 'Table 9', + 'Table 10' + ].map( + (tab): ComponentEntry => ({ + route: '/table', + name: tab, + selector: 'cps-table', + group: 'Table', + setup: async (page) => { await page.getByRole('tab', { name: tab, exact: true }).click(); await page.waitForSelector('cps-table'); } - })) - }, + }) + ), { route: '/tag', name: 'Tag', selector: 'cps-tag' }, { route: '/textarea', name: 'Textarea', selector: 'cps-textarea' }, { @@ -283,143 +291,35 @@ const components: ComponentEntry[] = [ await page.locator('cps-tree-select').first().click(); } }, - { - route: '/tree-table', - name: 'Tree table', - selector: 'cps-tree-table', - states: [ - 'Tree table 1', - 'Tree table 2', - 'Tree table 3', - 'Tree table 4', - 'Tree table 5', - 'Tree table 6', - 'Tree table 7', - 'Tree table 8', - 'Tree table 9', - 'Tree table 10' - ].map((tab) => ({ - label: tab, - setup: async (page: Page) => { + ...[ + 'Tree table 1', + 'Tree table 2', + 'Tree table 3', + 'Tree table 4', + 'Tree table 5', + 'Tree table 6', + 'Tree table 7', + 'Tree table 8', + 'Tree table 9', + 'Tree table 10' + ].map( + (tab): ComponentEntry => ({ + route: '/tree-table', + name: tab, + selector: 'cps-tree-table', + group: 'Tree table', + setup: async (page) => { await page.getByRole('tab', { name: tab, exact: true }).click(); await page.waitForSelector('cps-tree-table'); } - })) - } -]; - -type Violations = Awaited>['violations']; - -function formatViolations(violations: Violations): string { - if (violations.length === 0) return ''; - return violations - .map((v) => { - const nodes = v.nodes - .map((n) => ` - ${n.html}\n ${n.failureSummary}`) - .join('\n'); - return `\n[${v.impact}] ${v.id}: ${v.description}\n Help: ${v.helpUrl}\n${nodes}`; }) - .join('\n'); -} - -function expectNoViolations(violations: Violations) { - expect(violations, formatViolations(violations)).toHaveLength(0); -} - -function buildAxeWithSelectors( - makeAxeBuilder: () => AxeBuilder, - selector: string | string[] -) { - const selectors = Array.isArray(selector) ? selector : [selector]; - let builder = makeAxeBuilder(); - for (const s of selectors) { - builder = builder.include(s); - } - return builder; -} - -async function waitForSelectors(page: Page, selector: string | string[]) { - const selectors = Array.isArray(selector) ? selector : [selector]; - await Promise.all(selectors.map((s) => page.waitForSelector(s))); -} - -// ============================================================================ -// axe-core WCAG AA — all component pages -// ============================================================================ - -async function runScan( - page: Page, - makeAxeBuilder: () => AxeBuilder, - selector: string | string[], - label: string, - testInfo: TestInfo -) { - await waitForSelectors(page, selector); - // Wait for all animations/transitions to complete before scanning. - // Without this, axe may capture intermediate states (e.g. mid-transition - // background color) and report false-positive color contrast violations. - await page.evaluate(() => - Promise.all( - document - .getAnimations() - .filter((a) => a.effect?.getTiming().iterations !== Infinity) - .map((a) => a.finished.catch(() => {})) - ) - ); - const results = await buildAxeWithSelectors( - makeAxeBuilder, - selector - ).analyze(); - await testInfo.attach(`${label}-accessibility-scan`, { - body: JSON.stringify(results, null, 2), - contentType: 'application/json' - }); - expectNoViolations(results.violations); -} - -test.describe('Accessibility - axe scan', () => { - for (const { route, name, selector, setup, states } of components) { - test(`${name} should have no violations`, async ({ - page, - makeAxeBuilder - }, testInfo) => { - await page.goto(route); - - if (states) { - for (const state of states) { - if (state.setup) await state.setup(page); - await runScan(page, makeAxeBuilder, selector, state.label, testInfo); - } - } else { - if (setup) await setup(page); - await runScan(page, makeAxeBuilder, selector, 'default', testInfo); - } - }); - } -}); - -// ============================================================================ -// axe-core at mobile viewport -// ============================================================================ + ) +]; -test.describe('Accessibility - responsive axe scan', () => { - for (const { route, name, selector, setup, states } of components) { - test(`${name} should have no violations at mobile width`, async ({ - page, - makeAxeBuilder - }, testInfo) => { - await page.setViewportSize({ width: 375, height: 812 }); - await page.goto(route); +const EXTRA_PAGE_ROUTES = [ + '/colors' // default `**` redirect target; has no entry in `components` +]; - if (states) { - for (const state of states) { - if (state.setup) await state.setup(page); - await runScan(page, makeAxeBuilder, selector, state.label, testInfo); - } - } else { - if (setup) await setup(page); - await runScan(page, makeAxeBuilder, selector, 'default', testInfo); - } - }); - } -}); +export const pageRoutes: string[] = [ + ...new Set([...components.map((c) => c.route), ...EXTRA_PAGE_ROUTES]) +].sort(); diff --git a/projects/composition/src/app/app.component.html b/projects/composition/src/app/app.component.html index 25a5a264d..5391b4cb1 100644 --- a/projects/composition/src/app/app.component.html +++ b/projects/composition/src/app/app.component.html @@ -42,7 +42,7 @@ (keydown.shift.tab)="focusActiveNavItem($event)">
- {{ componentTitle }} +

{{ componentTitle }}

diff --git a/projects/composition/src/app/app.component.scss b/projects/composition/src/app/app.component.scss index bef6f6a8b..f1c12003e 100644 --- a/projects/composition/src/app/app.component.scss +++ b/projects/composition/src/app/app.component.scss @@ -106,6 +106,8 @@ font-size: 1.25rem; .composition-body-toolbar-title { margin: 0 auto; + font-size: inherit; + font-weight: inherit; } .sidebar-toggle-placeholder { display: flex; diff --git a/projects/composition/src/app/components/code-example/code-example.component.html b/projects/composition/src/app/components/code-example/code-example.component.html index eec75a97e..e2417ce31 100644 --- a/projects/composition/src/app/components/code-example/code-example.component.html +++ b/projects/composition/src/app/components/code-example/code-example.component.html @@ -1,8 +1,8 @@
@if (label()) { -

+

{{ label() }} -

+ }
+ [tsCode]="examples.disabled.ts" + [isPreviewNonInteractive]="true"> - +