// tests/e2e/voyage-playground-a11y.spec.mjs // v4.3 Step 30 — Group D e2e a11y + pixel-diff specs. // // Three Playwright tests: // 1. Light-theme axe-core scan — no NEW critical/serious violations vs baseline // 2. Dark-theme axe-core scan — no NEW critical/serious violations vs baseline // 3. Pixel-diff smoke (1280×900) against baseline-PNGs in // tests/e2e/snapshots/. Threshold maxDiffPixelRatio: 0.02. // // SC1 backup verification (autoritativ er manuell SC1-checklist Step 31). // SC2 authoritative verification (axe-core). // // Baseline policy (v4.3 Sesjon 6 NEXT-SESSION-PROMPT recovery): the voyage // playground HTML is FROZEN in Sesjon 6. Wave 7 = VERIFICATION ONLY, not // FIX. Existing critical/serious violations are recorded as baseline // (rule-id + node-count fingerprint) in tests/e2e/snapshots/a11y-baseline.json. // The test fails only when: // (a) A new violation rule-id appears, OR // (b) A known rule's node-count increases. // Actual a11y fix is deferred to v4.4 (HTML unfreeze). import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const BASELINE_PATH = join(__dirname, 'snapshots', 'a11y-baseline.json'); function fingerprint(violations) { // Reduce violations to (id, node-count) tuples. Order-independent. const map = {}; for (const v of violations) { map[v.id] = (map[v.id] || 0) + (Array.isArray(v.nodes) ? v.nodes.length : 0); } return map; } function loadBaseline(theme) { if (!existsSync(BASELINE_PATH)) return null; const data = JSON.parse(readFileSync(BASELINE_PATH, 'utf-8')); return data[theme] || null; } function compareWithBaseline(currentFp, baselineFp) { const newRules = []; const grownRules = []; for (const [id, count] of Object.entries(currentFp)) { if (!(id in baselineFp)) { newRules.push({ id, currentCount: count }); } else if (count > baselineFp[id]) { grownRules.push({ id, baselineCount: baselineFp[id], currentCount: count }); } } return { newRules, grownRules }; } test.describe('voyage-playground a11y (axe-core)', () => { test('light theme — no NEW critical/serious violations vs baseline', async ({ page }) => { await page.goto('voyage-playground.html'); await page.evaluate(() => { window.localStorage.setItem('voyage-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.style.colorScheme = 'light'; }); await page.reload(); await page.waitForLoadState('domcontentloaded'); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) .analyze(); const criticalOrSerious = results.violations.filter( (v) => v.impact === 'critical' || v.impact === 'serious', ); const currentFp = fingerprint(criticalOrSerious); const baseline = loadBaseline('light'); if (baseline === null) { // No baseline yet — write current as baseline (run once, then commit). const baselineDoc = existsSync(BASELINE_PATH) ? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8')) : {}; baselineDoc.light = currentFp; writeFileSync(BASELINE_PATH, JSON.stringify(baselineDoc, null, 2) + '\n'); console.log('[a11y baseline] wrote light fingerprint:', currentFp); return; // first run establishes baseline } const { newRules, grownRules } = compareWithBaseline(currentFp, baseline); expect(newRules, `NEW rules vs baseline: ${JSON.stringify(newRules, null, 2)}`).toEqual([]); expect(grownRules, `GROWN rule-counts vs baseline: ${JSON.stringify(grownRules, null, 2)}`).toEqual([]); }); test('dark theme — no NEW critical/serious violations vs baseline', async ({ page }) => { await page.goto('voyage-playground.html'); await page.evaluate(() => { window.localStorage.setItem('voyage-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.style.colorScheme = 'dark'; }); await page.reload(); await page.waitForLoadState('domcontentloaded'); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) .analyze(); const criticalOrSerious = results.violations.filter( (v) => v.impact === 'critical' || v.impact === 'serious', ); const currentFp = fingerprint(criticalOrSerious); const baseline = loadBaseline('dark'); if (baseline === null) { const baselineDoc = existsSync(BASELINE_PATH) ? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8')) : {}; baselineDoc.dark = currentFp; writeFileSync(BASELINE_PATH, JSON.stringify(baselineDoc, null, 2) + '\n'); console.log('[a11y baseline] wrote dark fingerprint:', currentFp); return; } const { newRules, grownRules } = compareWithBaseline(currentFp, baseline); expect(newRules, `NEW rules vs baseline: ${JSON.stringify(newRules, null, 2)}`).toEqual([]); expect(grownRules, `GROWN rule-counts vs baseline: ${JSON.stringify(grownRules, null, 2)}`).toEqual([]); }); // v4.3 Step 4 — DIAGNOSTIC test (removed in Step 5/Wave 3). Prints the // node selectors flagged by color-contrast so we can target scoped CSS // overrides at exactly those nodes (finding 09132940). // Asserts ZERO color-contrast violations after the inline-style override // is applied — passes only when remediation is complete. test('DIAGNOSTIC — print color-contrast node selectors (09132940)', async ({ page }) => { await page.goto('voyage-playground.html'); await page.evaluate(() => { window.localStorage.setItem('voyage-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.style.colorScheme = 'light'; }); await page.reload(); await page.waitForLoadState('domcontentloaded'); const results = await new AxeBuilder({ page }) .options({ runOnly: ['color-contrast'] }) .analyze(); const nodes = results.violations.flatMap((v) => v.nodes.map((n) => ({ rule: v.id, target: n.target, html: (n.html || '').slice(0, 80) })), ); // Diagnostic emission — visible via --reporter=line console.log('[DIAGNOSTIC color-contrast nodes]', JSON.stringify(nodes)); expect(nodes, `color-contrast violations remain: ${JSON.stringify(nodes, null, 2)}`).toEqual([]); }); // v4.3 Step 8 — inline screenshot gallery (finding 31d28f65). // Injects a pre-built artifacts object with screenshots[] via the // window.__voyage.scheduleRender hook (avoids webkitdirectory which // is not programmatically triggerable). Asserts the dashboard renders // at least one data:image PNG tag. test('SC1.6 inline gallery — data:image PNGs rendered (31d28f65)', async ({ page }) => { await page.goto('voyage-playground.html'); await page.waitForLoadState('domcontentloaded'); // 1×1 transparent PNG (same base64 as the fixture file) const SAMPLE_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; await page.evaluate((dataUrl) => { window.__voyage.scheduleRender({ artifacts: { basePath: 'fixture-project', storageKey: 'voyage_proj_fixture', brief: { path: 'brief.md', content: '# Fixture', frontmatter: {} }, plan: null, review: null, progress: null, research: [], architecture: { overview: null, gaps: null, looseFiles: [] }, screenshots: [{ path: 'docs/screenshots/dashboard/sample.png', dataUrl: dataUrl }], looseFiles: [], }, }); }, SAMPLE_DATA_URL); // The gallery is rendered inside #voyage-dashboard const imgCount = await page.locator('#voyage-dashboard img[src^="data:image/png"]').count(); expect(imgCount, 'expected at least one data:image/png in the gallery').toBeGreaterThan(0); }); test('pixel-diff smoke 1280×900 — light + dark within 2% threshold (SC1 backup)', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); // Light theme baseline await page.goto('voyage-playground.html'); await page.evaluate(() => { window.localStorage.setItem('voyage-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.style.colorScheme = 'light'; }); await page.reload(); await page.waitForLoadState('domcontentloaded'); await expect(page).toHaveScreenshot('voyage-playground-light.png', { maxDiffPixelRatio: 0.02, fullPage: false, }); // Dark theme baseline await page.evaluate(() => { window.localStorage.setItem('voyage-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.style.colorScheme = 'dark'; }); await page.reload(); await page.waitForLoadState('domcontentloaded'); await expect(page).toHaveScreenshot('voyage-playground-dark.png', { maxDiffPixelRatio: 0.02, fullPage: false, }); }); });