diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20f4539..4508593 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,15 @@ jobs: - name: Test all packages run: npm run test + - name: Install Playwright (chromium) for the a11y gate + run: npx playwright install --with-deps chromium + + - name: Accessibility gate (Storybook + axe, WCAG 2.2 AA) + run: npm run test:a11y + + - name: Keyboard-interaction gate (Storybook + Playwright) + run: npm run test:keyboard + security: name: Security & SBOM runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 15587cf..17de715 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ coverage/ *.log npm-debug.log* .DS_Store + +# Storybook build output +storybook-static/ diff --git a/.storybook/a11y.mjs b/.storybook/a11y.mjs new file mode 100644 index 0000000..ec31902 --- /dev/null +++ b/.storybook/a11y.mjs @@ -0,0 +1,200 @@ +/** + * Headless accessibility gate for Storybook (WCAG 2.2 AA / EN 301 549 — see + * ACCESSIBILITY.md). For every story in the static build it (1) confirms the + * story renders without error and (2) runs axe-core, failing on any violation. + * + * Deliberately does NOT use @storybook/test-runner: that pulls jest 30, which + * conflicts with the monorepo's jest 29 unit tests. This uses Playwright + + * @axe-core/playwright directly (no jest), so the two test stacks never clash. + * + * Per-story exceptions are read from the story's own `parameters.a11y` + * (disable, or config.rules: [{ id, enabled:false }]) — the same annotations + * the interactive addon-a11y panel honours — so there is a single source of + * truth in the story file. + * + * Usage: build Storybook first, then `node .storybook/a11y.mjs` + * (wrapped by `npm run test:a11y`). + */ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { AxeBuilder } from '@axe-core/playwright'; + +const STATIC_DIR = 'storybook-static'; +const PORT = 6006; +const BASE = `http://127.0.0.1:${PORT}`; +const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; + +function startServer() { + const proc = spawn( + 'npx', + ['http-server', STATIC_DIR, '-p', String(PORT), '-s', '-c-1'], + { stdio: 'ignore' } + ); + return proc; +} + +async function waitForServer(timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/index.json`); + if (res.ok) return; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error(`Storybook static server did not start on ${BASE}`); +} + +const server = startServer(); +let exitCode = 0; +try { + await waitForServer(); + const index = JSON.parse(readFileSync(`${STATIC_DIR}/index.json`, 'utf8')); + const stories = Object.values(index.entries).filter((e) => e.type === 'story'); + + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + const failures = []; + + // Capture runtime errors per story — a story that throws still leaves an empty + // #storybook-root that would otherwise "pass" axe, so treat throws as failures. + // Ignore known, harmless noise (the router matching iframe.html; missing assets). + let pageErrors = []; + const IGNORE_ERR = /NG04002|404|Failed to load resource|favicon/i; + page.on('pageerror', (e) => pageErrors.push(e.message.split('\n')[0])); + page.on('console', (m) => { + if (m.type() === 'error') pageErrors.push(m.text().split('\n')[0]); + }); + + const renderErrors = []; + for (const story of stories) { + pageErrors = []; + // A single broken story must NOT abort the whole survey — capture and move on. + try { + // 'networkidle' is unreliable here — icon-font 404s and the router keep + // the network busy — so wait for DOM + actual rendered content instead. + await page.goto(`${BASE}/iframe.html?id=${story.id}&viewMode=story`, { + waitUntil: 'domcontentloaded', + }); + // Rendered content can be inside #storybook-root OR an overlay portalled + // into (modal, context menu, side panel). + await page.waitForFunction( + () => { + const r = document.querySelector('#storybook-root'); + return (r && r.children.length > 0) || + !!document.querySelector('.modal-overlay, [role="dialog"], .ngx-contextmenu, cdk-dialog-container, .cdk-overlay-container *'); + }, + { timeout: 8000 } + ); + // Let change detection settle so any thrown runtime error surfaces. + await page.waitForTimeout(300); + const realErrors = pageErrors.filter((m) => !IGNORE_ERR.test(m)); + if (realErrors.length) { + throw new Error('runtime: ' + realErrors[0].slice(0, 140)); + } + // Detect EMPTY renders: the wrapper element can exist while the component + // rendered nothing (an internal @if is false → only a comment node), which + // still "passes" axe. Require actual laid-out content (or a body overlay). + // A story may legitimately render nothing (documenting a hidden/empty + // state); it opts out of the empty-render check with + // parameters.allowEmptyRender = true. + const allowEmpty = await page.evaluate(async (id) => { + try { + const s = await window.__STORYBOOK_PREVIEW__.storyStore.loadStory({ storyId: id }); + return s?.parameters?.allowEmptyRender === true; + } catch { + return false; + } + }, story.id); + const empty = allowEmpty ? false : await page.evaluate(() => { + const root = document.querySelector('#storybook-root'); + if (!root) return true; + const overlay = document.querySelector( + '.modal-overlay, [role="dialog"], .ngx-contextmenu, .cdk-overlay-container *' + ); + if (overlay) return false; + // The mount root is always full-width; a component that rendered nothing + // (internal @if false → only a comment) leaves ALL descendants with a + // zero box. Consider it rendered only if some descendant is laid out. + for (const el of root.querySelectorAll('*')) { + if (el.offsetHeight > 0 || el.offsetWidth > 0) return false; + } + return true; + }); + if (empty) { + throw new Error('rendered empty (component produced no visible content)'); + } + } catch (e) { + renderErrors.push({ id: story.id, reason: e.message.split('\n')[0] }); + console.error(` ✗ ${story.id} — DID NOT RENDER (${e.message.split('\n')[0]})`); + continue; + } + + // Read the story's own a11y parameters (single source of truth with the + // interactive addon), so documented exceptions are honoured here too. + const a11y = await page.evaluate(async (id) => { + try { + const preview = window.__STORYBOOK_PREVIEW__; + const story = await preview.storyStore.loadStory({ storyId: id }); + return story?.parameters?.a11y ?? null; + } catch { + return null; + } + }, story.id); + + if (a11y?.disable) { + console.log(` ⊘ ${story.id} (a11y disabled)`); + continue; + } + const disabledRules = process.env.A11Y_NO_SUPPRESS + ? [] + : (a11y?.config?.rules ?? []) + .filter((r) => r && r.enabled === false && r.id) + .map((r) => r.id); + + try { + let builder = new AxeBuilder({ page }).withTags(WCAG_TAGS); + if (disabledRules.length) builder = builder.disableRules(disabledRules); + const results = await builder.analyze(); + if (results.violations.length) { + failures.push({ id: story.id, violations: results.violations }); + console.error( + ` ✗ ${story.id} — ${results.violations + .map((v) => `${v.id} (${v.impact}, ${v.nodes.length})`) + .join(', ')}` + ); + } else { + const note = disabledRules.length ? ` [skipped: ${disabledRules.join(', ')}]` : ''; + console.log(` ✓ ${story.id}${note}`); + } + } catch (e) { + renderErrors.push({ id: story.id, reason: 'axe: ' + e.message.split('\n')[0] }); + console.error(` ✗ ${story.id} — axe error (${e.message.split('\n')[0]})`); + } + } + if (renderErrors.length) { + console.error(`\n${renderErrors.length} story/ies failed to render / axe-error:`); + for (const r of renderErrors) console.error(` - ${r.id}: ${r.reason}`); + failures.push(...renderErrors); + } + + await browser.close(); + + if (failures.length) { + console.error(`\n${failures.length} story/ies with WCAG 2.2 AA violations.`); + exitCode = 1; + } else { + console.log(`\nAll ${stories.length} stories pass WCAG 2.2 AA (axe-core).`); + } +} catch (err) { + console.error('a11y run failed:', err); + exitCode = 1; +} finally { + server.kill(); +} + +process.exit(exitCode); diff --git a/.storybook/assets/sidebar.json b/.storybook/assets/sidebar.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.storybook/assets/sidebar.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.storybook/contrast-audit.mjs b/.storybook/contrast-audit.mjs new file mode 100644 index 0000000..1ccc43a --- /dev/null +++ b/.storybook/contrast-audit.mjs @@ -0,0 +1,58 @@ +/** + * One-off contrast audit: runs axe color-contrast over every story (no per-story + * suppressions) and prints the unique failing foreground/background pairs with + * their ratio and the element, so the exact tokens to darken are known. + * Usage: node .storybook/contrast-audit.mjs (Storybook must be built) + */ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { AxeBuilder } from '@axe-core/playwright'; + +const PORT = 6023; +const BASE = `http://127.0.0.1:${PORT}`; +const server = spawn('npx', ['http-server', 'storybook-static', '-p', String(PORT), '-s', '-c-1'], { stdio: 'ignore' }); + +async function waitUp(t = 20000) { + const end = Date.now() + t; + while (Date.now() < end) { try { if ((await fetch(`${BASE}/index.json`)).ok) return; } catch {} await new Promise(r => setTimeout(r, 300)); } + throw new Error('server not up'); +} + +try { + await waitUp(); + const index = JSON.parse(readFileSync('storybook-static/index.json', 'utf8')); + const stories = Object.values(index.entries).filter(e => e.type === 'story'); + const browser = await chromium.launch(); + const page = await (await browser.newContext()).newPage(); + const pairs = new Map(); // key: fg|bg -> {fg,bg,ratio,need,stories:Set,sample} + + for (const s of stories) { + try { + await page.goto(`${BASE}/iframe.html?id=${s.id}&viewMode=story`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(400); + const res = await new AxeBuilder({ page }).withTags(['wcag2aa', 'wcag21aa', 'wcag22aa']).options({ runOnly: ['color-contrast'] }).analyze(); + for (const v of res.violations) { + for (const n of v.nodes) { + const d = n.any?.[0]?.data || {}; + const key = `${d.fgColor}|${d.bgColor}`; + if (!pairs.has(key)) pairs.set(key, { fg: d.fgColor, bg: d.bgColor, ratio: d.contrastRatio, need: d.expectedContrastRatio, stories: new Set(), sample: (n.target || []).join(' ') }); + pairs.get(key).stories.add(s.id.split('--')[0]); + } + } + } catch { /* skip */ } + } + await browser.close(); + + const rows = [...pairs.values()].sort((a, b) => (a.ratio || 0) - (b.ratio || 0)); + console.log(`\n${rows.length} unique failing colour pairs:\n`); + for (const r of rows) { + console.log(`fg ${r.fg} on bg ${r.bg} = ${r.ratio}:1 (need ${r.need})`); + console.log(` components: ${[...r.stories].join(', ')}`); + console.log(` e.g. ${r.sample.slice(0, 80)}`); + } +} catch (e) { + console.error('audit failed:', e.message); +} finally { + server.kill(); +} diff --git a/.storybook/host-main.ts b/.storybook/host-main.ts new file mode 100644 index 0000000..504e4bd --- /dev/null +++ b/.storybook/host-main.ts @@ -0,0 +1,4 @@ +// Storybook provides its own application bootstrap. This file only exists to +// satisfy the Angular browser builder's `main` requirement for the build +// context (tsconfig, styles, scripts) that Storybook reuses. +export {}; diff --git a/.storybook/index.html b/.storybook/index.html new file mode 100644 index 0000000..1e981d3 --- /dev/null +++ b/.storybook/index.html @@ -0,0 +1,10 @@ + + + + + Storybook + + + + + diff --git a/.storybook/keyboard.mjs b/.storybook/keyboard.mjs new file mode 100644 index 0000000..21c3c2d --- /dev/null +++ b/.storybook/keyboard.mjs @@ -0,0 +1,287 @@ +/** + * Headless keyboard-interaction gate for Storybook (WCAG 2.1.1 keyboard, + * 2.1.2 no trap, 2.4.3/2.4.7 focus — see ACCESSIBILITY.md §4/§7). + * + * Unlike `.storybook/a11y.mjs` (structural axe rules run identically over + * every story), keyboard *behaviour* is control-specific: Tab/Enter/Esc/Arrow + * keys are expected to do different things on a switch vs. a combobox vs. a + * dialog. So this is a small, explicit table of {story, check(page)} cases — + * one (or a few) representative story per interaction pattern used across + * ca-ng-components / ca-ng-components-extra — rather than a generic sweep. + * + * Each check drives real keyboard events (`page.keyboard.press`, which + * dispatches trusted keydown/keyup — unlike `dispatchEvent`, this also + * triggers native behaviour on real form controls) and asserts the resulting + * DOM state (aria-*, focus, visibility). A case that throws is a failure. + * + * Same rationale as a11y.mjs for not using @storybook/test-runner: it pulls + * jest 30, which conflicts with the monorepo's jest 29 unit tests. + * + * Usage: build Storybook first, then `node .storybook/keyboard.mjs` + * (wrapped by `npm run test:keyboard`). + */ +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; + +const STATIC_DIR = 'storybook-static'; +const PORT = 6007; +const BASE = `http://127.0.0.1:${PORT}`; + +function startServer() { + return spawn('npx', ['http-server', STATIC_DIR, '-p', String(PORT), '-s', '-c-1'], { stdio: 'ignore' }); +} + +async function waitForServer(timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/index.json`); + if (res.ok) return; + } catch { /* not up yet */ } + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error(`Storybook static server did not start on ${BASE}`); +} + +async function goto(page, id) { + await page.goto(`${BASE}/iframe.html?id=${id}&viewMode=story`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => { + const r = document.querySelector('#storybook-root'); + return (r && r.children.length > 0) || !!document.querySelector('[role="dialog"], .cdk-overlay-container *'); + }, { timeout: 8000 }); + await page.waitForTimeout(300); +} + +// `page.keyboard.press()` resolves once the DOM event is dispatched, but +// Angular's change detection (zone flush) that reacts to it runs on a later +// task — reading DOM state immediately after can observe stale attributes. +// Every case presses through this helper instead of calling +// `page.keyboard.press` directly, so state reads are never racing CD. +async function press(page, key) { + await page.keyboard.press(key); + await page.waitForTimeout(150); +} + +async function focus(locator) { + await locator.focus(); + await locator.page().waitForTimeout(150); +} + +/** + * One check per interaction *pattern*, not per component — e.g. sh-combo + * inherits sh-select's Arrow/Enter/Escape handling, so only sh-select is + * covered explicitly; a divergence would already be caught by axe/unit tests. + */ +const CASES = [ + { + name: 'sh-toggle — Enter and Space both toggle the switch', + id: 'ng-components-toggle--default', + async run(page) { + const el = page.locator('.toggle[role="switch"]').first(); + await focus(el); + const before = await el.getAttribute('aria-checked'); + await press(page, 'Enter'); + const afterEnter = await el.getAttribute('aria-checked'); + if (afterEnter === before) throw new Error(`Enter did not toggle aria-checked (stayed ${before})`); + await press(page, ' '); + const afterSpace = await el.getAttribute('aria-checked'); + if (afterSpace === afterEnter) throw new Error(`Space did not toggle aria-checked back (stayed ${afterEnter})`); + }, + }, + { + name: 'sh-checkbox — Space toggles the native checkbox', + id: 'ng-components-checkbox--default', + async run(page) { + const input = page.locator('input[type="checkbox"]').first(); + await focus(input); + const before = await input.isChecked(); + await press(page, ' '); + const after = await input.isChecked(); + if (after === before) throw new Error(`Space did not toggle the checkbox (stayed ${before})`); + }, + }, + { + name: 'sh-radio — ArrowDown moves focus+selection, Space selects the focused option', + id: 'ng-components-radio--default', + async run(page) { + // The `.radio[role=radio]` divs carry no `id` (only their hidden native + // `` sibling does), so focus is tracked by element identity, not id. + const options = page.locator('.radio[role="radio"]'); + const count = await options.count(); + if (count < 2) throw new Error('story needs >=2 options to test Arrow navigation'); + await focus(options.nth(0)); + await press(page, 'ArrowDown'); + const focusedSecond = await options.nth(1).evaluate((el) => el === document.activeElement); + if (!focusedSecond) throw new Error('ArrowDown did not move focus to the next option'); + await press(page, ' '); + const checkedAfter = await options.nth(1).getAttribute('aria-checked'); + if (checkedAfter !== 'true') throw new Error(`Space did not select the focused option (aria-checked=${checkedAfter})`); + }, + }, + { + name: 'sh-checkgroup — Space toggles a single item without affecting siblings', + id: 'ng-components-checkgroup--default', + async run(page) { + const items = page.locator('.checkbox[role="checkbox"]'); + if (await items.count() < 1) throw new Error('no checkbox items rendered'); + const first = items.nth(0); + await focus(first); + const before = await first.getAttribute('aria-checked'); + await press(page, ' '); + const after = await first.getAttribute('aria-checked'); + if (after === before) throw new Error(`Space did not toggle the item (stayed ${before})`); + }, + }, + { + name: 'sh-tabs — ArrowRight moves to and selects the next tab, Home jumps to the first', + id: 'ng-components-tabs--default', + async run(page) { + const tabs = page.locator('[role="tab"]'); + const count = await tabs.count(); + if (count < 2) throw new Error('story needs >=2 tabs to test Arrow navigation'); + const focusable = page.locator('[role="tab"][tabindex="0"]').first(); + await focus(focusable); + await press(page, 'ArrowRight'); + const focusedId = await page.evaluate(() => document.activeElement?.id); + const selectedNow = await page.locator(`#${focusedId}`).getAttribute('aria-selected'); + if (selectedNow !== 'true') throw new Error(`ArrowRight did not select the newly-focused tab (aria-selected=${selectedNow})`); + await press(page, 'Home'); + const firstTabId = await tabs.nth(0).getAttribute('id'); + const focusedAfterHome = await page.evaluate(() => document.activeElement?.id); + if (focusedAfterHome !== firstTabId) throw new Error(`Home did not move focus to the first tab (focus=${focusedAfterHome})`); + }, + }, + { + name: 'sh-select — closed ArrowDown cycles the value, Alt+ArrowDown opens the listbox, Enter commits, Escape closes', + id: 'ng-components-select--default', + async run(page) { + const combobox = page.locator('[role="combobox"]').first(); + await focus(combobox); + // Closed + plain ArrowDown cycles the committed value directly (native + // OS ` is associated with its `