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