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).
This commit is contained in:
parent
5820478f71
commit
067d9ab245
3 changed files with 186 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
152
plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs
Normal file
152
plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs
Normal file
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
33
plugins/voyage/tests/e2e/voyage-playground-network.spec.mjs
Normal file
33
plugins/voyage/tests/e2e/voyage-playground-network.spec.mjs
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue