ktg-plugin-marketplace/plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs

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