// 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([]); }); 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, }); }); });