diff --git a/plugins/voyage/playwright.config.mjs b/plugins/voyage/playwright.config.mjs index 375effe..b4c1fc3 100644 --- a/plugins/voyage/playwright.config.mjs +++ b/plugins/voyage/playwright.config.mjs @@ -3,6 +3,7 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: 'tests/e2e', testMatch: '**/*.spec.mjs', + snapshotPathTemplate: '{testDir}/snapshots/{arg}{ext}', timeout: 30_000, expect: { timeout: 5_000 }, fullyParallel: false, diff --git a/plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs b/plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs new file mode 100644 index 0000000..ab359bb --- /dev/null +++ b/plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs @@ -0,0 +1,152 @@ +// 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, + }); + }); +}); diff --git a/plugins/voyage/tests/e2e/voyage-playground-network.spec.mjs b/plugins/voyage/tests/e2e/voyage-playground-network.spec.mjs new file mode 100644 index 0000000..5d2ad28 --- /dev/null +++ b/plugins/voyage/tests/e2e/voyage-playground-network.spec.mjs @@ -0,0 +1,33 @@ +// tests/e2e/voyage-playground-network.spec.mjs +// v4.3 Step 30 — Group D SC7 authoritative network-intercept gate. +// +// Instruments page.on('request', ...) to capture every outbound request +// during playground load. Allowlist: nothing (zero external requests). +// All assets MUST be bundled locally (./lib/, ./vendor/, file://...). +// +// Why authoritative: voyage-playground.test.mjs already greps the static +// HTML for http/https URLs (Step 28 SC7), but a runtime intercept also +// catches fetch()/XHR/import calls that are constructed dynamically. + +import { test, expect } from '@playwright/test'; + +test.describe('voyage-playground network — SC7 zero external requests', () => { + test('no http/https requests during page load', async ({ page }) => { + const externalRequests = []; + + page.on('request', (request) => { + const url = request.url(); + // file:// URLs are local — playground is loaded via file:// baseURL + if (url.startsWith('file://') || url.startsWith('data:') || url.startsWith('blob:')) { + return; + } + // Anything else is external (http://, https://, ws://, ftp://, etc.) + externalRequests.push({ url, method: request.method(), resourceType: request.resourceType() }); + }); + + await page.goto('voyage-playground.html'); + await page.waitForLoadState('networkidle'); + + expect(externalRequests, JSON.stringify(externalRequests, null, 2)).toEqual([]); + }); +});