import fs from 'node:fs'; import path from 'node:path'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; // Optional local QA tooling: no runtime package or model session is added. const require = createRequire(process.env.HM_PLAYWRIGHT_DIR ? path.join(process.env.HM_PLAYWRIGHT_DIR, 'package.json') : import.meta.url); const { chromium } = require('playwright'); const base = process.env.HM_A11Y_URL ?? 'http://127.0.0.1:6024'; const output = path.resolve(process.env.HM_A11Y_OUTPUT ?? '_local/accessibility-runtime'); const profilesOnly = process.argv.includes('--profiles-only'); fs.mkdirSync(output, { recursive: true }); const result = { capturedAt: new Date().toISOString(), url: base, checks: [], errors: [], limitations: ['Screen reader speech and physical touch are NOT VERIFIED', 'Only named stories, states and widths are covered; this is not WCAG certification'] }; const browser = await chromium.launch({ headless: true, ...(process.env.HM_CHROME_PATH ? { executablePath: process.env.HM_CHROME_PATH } : {}) }); const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); page.on('pageerror', e => result.errors.push(e.message)); const save = () => fs.writeFileSync(path.join(output, 'runtime.json'), JSON.stringify(result, null, 2)); const check = async (name, action) => { try { result.checks.push({ name, status: 'PASS', evidence: await action() }); } catch (e) { result.checks.push({ name, status: 'FAIL', error: e.message }); } save(); }; try { const index = await (await page.request.get(`${base}/index.json`)).json(); const stories = Object.values(index.entries).filter(e => e.type === 'story' && e.id.endsWith('--all')); const story = async name => { const entry = stories.find(e => e.importPath.endsWith(`/${name}.stories.tsx`)); assert.ok(entry, `Missing ${name} All story`); await page.goto(`${base}/iframe.html?id=${entry.id}&viewMode=story`); await page.locator('#storybook-root > *').first().waitFor(); await page.evaluate(() => document.fonts.ready); }; for (const width of profilesOnly ? [] : [390, 768, 1280, 1440]) { await check(`Combobox relationship/keyboard/geometry ${width}`, async () => { await page.setViewportSize({ width, height: 900 }); await story('combobox'); const trigger = page.locator('#cb-empty'); await trigger.focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); assert.equal(await trigger.evaluate(e => e === document.activeElement && e.matches(':focus-visible')), true); await page.keyboard.press(width === 768 ? 'Space' : 'Enter'); const popup = page.locator('[data-combobox-popup]'); await popup.waitFor(); const cdp = await page.context().newCDPSession(page); const documentNode = await cdp.send('DOM.getDocument'); const popupNode = await cdp.send('DOM.querySelector', { nodeId: documentNode.root.nodeId, selector: '[data-combobox-popup]' }); const ax = await cdp.send('Accessibility.getPartialAXTree', { nodeId: popupNode.nodeId, fetchRelatives: false }); const popupName = ax.nodes.find(node => node.role?.value === 'dialog')?.name?.value; assert.ok(popupName?.trim(), 'Dialog needs an actual browser accessible name'); await cdp.detach(); assert.equal(await trigger.getAttribute('aria-haspopup'), await popup.getAttribute('role')); assert.equal(await trigger.getAttribute('aria-controls'), await popup.getAttribute('id')); assert.equal(await trigger.getAttribute('aria-expanded'), 'true'); assert.equal(await page.locator('[data-slot="command-input"]').evaluate(e => document.getElementById(e.getAttribute('aria-controls'))?.getAttribute('role')), 'listbox'); const geometry = await popup.evaluate(e => { const r = e.getBoundingClientRect(); return { left: r.left, right: r.right, width: innerWidth }; }); assert.ok(geometry.left >= -1 && geometry.right <= width + 1, JSON.stringify(geometry)); const ids = await page.locator('[id]').evaluateAll(es => es.map(e => e.id).filter((id, i, all) => all.indexOf(id) !== i)); assert.deepEqual(ids, []); const tree = await popup.ariaSnapshot(); assert.match(tree, /dialog/); assert.match(tree, /listbox/); await page.locator('[data-slot="command-input"]').fill('Astro'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await popup.waitFor({ state: 'hidden' }); await page.waitForFunction(() => document.activeElement?.id === 'cb-empty'); assert.match(await trigger.textContent(), /Astro/); await page.keyboard.press('Enter'); await popup.waitFor(); await page.keyboard.press('Escape'); await popup.waitFor({ state: 'hidden' }); await page.waitForFunction(() => document.activeElement?.id === 'cb-empty'); assert.equal(await trigger.getAttribute('aria-expanded'), 'false'); const disabled = page.locator('[data-slot="combobox-trigger"]:disabled'); assert.ok(await disabled.count()); const overview = page.locator('#storybook-root'); assert.equal(await overview.evaluate(e => document.documentElement.scrollWidth <= innerWidth + 1), true); await page.screenshot({ path: path.join(output, `combobox-${width}.png`), fullPage: true }); return { geometry, duplicateIds: ids, tree, popupName, keyboard: ['Tab', 'Shift+Tab', 'Enter/Space', 'ArrowDown', 'Enter select', 'Escape'], disabled: await disabled.count() }; }); } for (const name of ['gnb', 'lnb']) { await check(`${name} visible profile names / explicit name / keyboard return`, async () => { await page.setViewportSize({ width: 1280, height: 900 }); await story(name); const profile = page.locator(`[data-slot="${name === 'gnb' ? 'gnb-user' : 'lnb-profile'}"]`).first(); const texts = await profile.locator(name === 'gnb' ? '[data-slot="gnb-user-name"], [data-slot="gnb-user-description"]' : '[data-slot="lnb-profile-text"] > *').allTextContents(); const label = await profile.getAttribute('aria-label'); for (const text of texts) assert.ok(label.includes(text), `${label} omits ${text}`); await profile.focus(); await page.keyboard.press('Enter'); await page.getByRole('menu').waitFor(); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Escape'); await page.getByRole('menu').waitFor({ state: 'hidden' }); assert.equal(await profile.evaluate(e => e === document.activeElement), true); const axeResults = process.env.HM_AXE_PATH ? await (async () => { await page.addScriptTag({ path: process.env.HM_AXE_PATH }); const r = await page.evaluate(async selector => { const r = await axe.run(document.querySelector(selector), { runOnly: ['label-content-name-mismatch'] }); return { violations: r.violations.map(v => v.id), incomplete: r.incomplete.map(v => v.id) }; }, `[data-slot="${name === 'gnb' ? 'gnb-user' : 'lnb-profile'}"]`); assert.deepEqual(r.violations, []); return r; })() : { status: 'NOT VERIFIED', reason: 'HM_AXE_PATH not supplied' }; return { texts, label, tree: await profile.ariaSnapshot(), axe: axeResults }; }); } } catch (e) { result.errors.push(e.stack); } finally { save(); await browser.close(); } result.status = result.errors.length || result.checks.some(c => c.status === 'FAIL') ? 'FAIL' : result.checks.length === (profilesOnly ? 2 : 6) ? 'PASS' : 'NOT VERIFIED'; save(); console.log(JSON.stringify({ status: result.status, checks: result.checks.map(({name,status,error}) => ({name,status,error})), errors: result.errors }, null, 2)); if (result.status !== 'PASS') process.exitCode = 1;