89 lines
7.5 KiB
JavaScript
89 lines
7.5 KiB
JavaScript
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;
|