ktg-plugin-marketplace/plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs
Kjell Tore Guttormsen 067d9ab245 test(voyage): add e2e Playwright + axe-core specs (a11y + network) [skip-docs]
Step 30 of v4.3 plan — Wave 7 Group D:
- voyage-playground-a11y.spec.mjs (3 tests): light + dark theme axe-core
  scans (compared against baseline JSON, fails only on NEW or GROWN
  violations) + pixel-diff smoke for SC1 (light + dark, 1280x900,
  maxDiffPixelRatio=0.02).
- voyage-playground-network.spec.mjs (1 test): SC7 authoritative gate via
  page.on('request') instrument — verifies zero external (http/https/ws)
  requests during page load.
- playwright.config.mjs: snapshotPathTemplate routes to tests/e2e/snapshots/
  (matches Step 31 expected_paths).

Baseline policy: HTML is FROZEN in Sesjon 6 (Wave 7 = verification, not
fix). Existing critical/serious WCAG violations (aria-hidden-focus +
color-contrast) recorded in tests/e2e/snapshots/a11y-baseline.json as
delta-baseline. Actual a11y fix deferred to v4.4.

Verify: npm run test:e2e -> 4 passed (3 a11y + 1 network).
2026-05-10 18:22:52 +02:00

152 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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