diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 07cdfa14..3446d310 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -152,6 +152,7 @@ jobs: node test-issue-1633-hide-1byte-hops.js node test-issue-1668-m4-per-route.js node test-a11y-axe-1668-selftest.js + node test-issue-1705-subpath-contrast.js - name: šŸ›”ļø Preflight XSS gate — actual --diff check (PR only) # The fixture self-test above (test-preflight-xss-gate.js) only @@ -406,6 +407,7 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 node test-issue-1705-subpath-contrast-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1151-orphan-separators-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1486-collapse-reopens-detail-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-issue-1705-subpath-contrast-e2e.js b/test-issue-1705-subpath-contrast-e2e.js new file mode 100644 index 00000000..34cf6844 --- /dev/null +++ b/test-issue-1705-subpath-contrast-e2e.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/* Issue #1705 — WCAG AA contrast E2E guard for `.subpath-selected .hop-prefix`. + * + * Companion to test-issue-1705-subpath-contrast.js (parser-only). The + * parser test is a fast pre-CI smoke; it inspects DECLARED cascade in + * style.css text but cannot resolve real browser specificity, `!important` + * ordering, or `color: inherit` resolution. A higher-specificity + * `.subpath-selected .hop-prefix` rule could win in the real browser + * while the parser test still happily asserts the canonical token pair. + * + * This test loads public/style.css into a real (headless) Chromium and + * uses getComputedStyle() on a `` with a + * `` child — the exact DOM shape rendered by + * public/analytics.js. It then computes the sRGB-composited WCAG ratio + * between the resolved foreground and the resolved background in both + * light (`:root`) and dark (`[data-theme="dark"]`) themes and asserts + * >=4.5:1. + * + * Why this catches what the parser test cannot: + * - `getComputedStyle` resolves `color: inherit` through the real + * cascade (parser fakes it). + * - Any later rule that overrides `.subpath-selected` color/background + * at higher specificity is observed live (parser only sees the + * selector-shaped match). + * - rgba()/transparent foregrounds are composited the same way the + * browser would render them. + * + * Designed to be runnable in CI; degrades to SKIP if Chromium cannot + * launch (mirrors test-logo-theme-e2e.js policy). Set CHROMIUM_REQUIRE=1 + * to convert SKIP -> FAIL. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +const CSS_PATH = path.resolve(__dirname, 'public', 'style.css'); + +function fail(msg) { + console.error(`test-issue-1705-subpath-contrast-e2e.js: FAIL — ${msg}`); + process.exit(1); +} + +// WCAG sRGB relative luminance + contrast. +function relLum([r, g, b]) { + const f = (c) => { + c /= 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); +} +function contrast(fg, bg) { + const L1 = relLum(fg), L2 = relLum(bg); + const [hi, lo] = L1 >= L2 ? [L1, L2] : [L2, L1]; + return (hi + 0.05) / (lo + 0.05); +} +// Parse "rgb(r, g, b)" / "rgba(r, g, b, a)" from getComputedStyle. +function parseRGB(s) { + const m = s && s.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)\s*(?:,\s*([0-9.]+))?\s*\)$/); + if (!m) return null; + return [Math.round(+m[1]), Math.round(+m[2]), Math.round(+m[3]), m[4] != null ? parseFloat(m[4]) : 1]; +} +function composite(fg, bg) { + const a = fg[3]; + return [ + Math.round(fg[0] * a + bg[0] * (1 - a)), + Math.round(fg[1] * a + bg[1] * (1 - a)), + Math.round(fg[2] * a + bg[2] * (1 - a)), + 1, + ]; +} +const hex = (c) => `#${c.slice(0, 3).map(v => v.toString(16).padStart(2, '0')).join('')}`; + +async function main() { + if (!fs.existsSync(CSS_PATH)) { + fail(`style.css not found at ${CSS_PATH}`); + } + + const requireChromium = process.env.CHROMIUM_REQUIRE === '1'; + let browser; + try { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + } catch (err) { + if (requireChromium) { + fail(`Chromium required but unavailable: ${err.message}`); + } + console.log(`test-issue-1705-subpath-contrast-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`); + process.exit(0); + } + + // Reproduce the DOM rendered by public/analytics.js (renderTable + + // click handler that adds `.subpath-selected` to a ). + // The production table uses `.analytics-table` (see analytics.js + // renderSubpathsTable: ``), so the + // fixture below mirrors that exact class to exercise any rule scoped + // to `.analytics-table tr.subpath-selected .hop-prefix`. + const html = ` + + + 1705 e2e + + +
+ + + + + + + + +
#RouteCount
1node-a → node-b
aa → bb
42
+`; + + let failures = 0; + try { + for (const theme of ['light', 'dark']) { + const context = await browser.newContext({ viewport: { width: 1280, height: 720 } }); + const page = await context.newPage(); + // Use file:// origin so the file:// stylesheet link is allowed. + await page.goto('about:blank'); + await page.setContent(html, { waitUntil: 'load' }); + // setContent loads with about:blank origin which cannot fetch file://. + // Inline the stylesheet contents instead — same correctness because + // we read public/style.css directly off disk. + const cssText = fs.readFileSync(CSS_PATH, 'utf8'); + await page.evaluate((css) => { + const styleEl = document.createElement('style'); + styleEl.textContent = css; + document.head.appendChild(styleEl); + }, cssText); + // Apply the theme attribute the same way customize.js does. + await page.evaluate((t) => { + if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark'); + else document.documentElement.removeAttribute('data-theme'); + }, theme); + // Force a layout flush. + await page.evaluate(() => document.body.getBoundingClientRect()); + + const probe = await page.evaluate(() => { + const tr = document.querySelector('tr.subpath-selected'); + const span = document.getElementById('probe-prefix'); + if (!tr || !span) return { error: 'fixture DOM missing' }; + const trStyle = getComputedStyle(tr); + const spanStyle = getComputedStyle(span); + return { + trBg: trStyle.backgroundColor, + trColor: trStyle.color, + spanColor: spanStyle.color, + spanBg: spanStyle.backgroundColor, + }; + }); + if (probe.error) fail(`[${theme}] ${probe.error}`); + + const fgRaw = parseRGB(probe.spanColor); + const bgRaw = parseRGB(probe.trBg); + if (!fgRaw) fail(`[${theme}] could not parse computed span color "${probe.spanColor}"`); + if (!bgRaw) fail(`[${theme}] could not parse computed tr background "${probe.trBg}"`); + + // If parent bg is transparent (no .subpath-selected background + // declared / overridden away), walk up to body to find an opaque + // ancestor. Browsers paint against the next opaque layer. + let bg = bgRaw; + if (bg[3] < 1) { + const opaque = await page.evaluate(() => { + let n = document.querySelector('tr.subpath-selected').parentElement; + while (n) { + const s = getComputedStyle(n).backgroundColor; + const m = s.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)\s*(?:,\s*([0-9.]+))?\s*\)$/); + if (m && (m[4] == null || parseFloat(m[4]) >= 1)) return s; + n = n.parentElement; + } + // Fall back to white if we run off the top. + return 'rgb(255, 255, 255)'; + }); + bg = parseRGB(opaque) || [255, 255, 255, 1]; + } + const fg = fgRaw[3] < 1 ? composite(fgRaw, bg) : fgRaw; + const ratio = contrast(fg, bg); + const ok = ratio >= 4.5; + console.log( + `${ok ? 'āœ“' : 'āœ—'} ${theme.padEnd(5)} computed span color=${probe.spanColor} on tr bg=${probe.trBg} ` + + `composed=${hex(fg)} on ${hex(bg)} ratio=${ratio.toFixed(2)}:1 (need ≄4.5)` + ); + if (!ok) { + failures++; + console.error( + `#1705 regression: real browser getComputedStyle in ${theme} theme gives ${ratio.toFixed(2)}:1, below WCAG AA 4.5:1 ` + + `(composed ${hex(fg)} on ${hex(bg)})` + ); + } + await context.close(); + } + } finally { + await browser.close(); + } + + if (failures > 0) process.exit(1); + console.log(`\nāœ… PASS — .subpath-selected .hop-prefix ≄4.5:1 in both themes (real browser getComputedStyle)`); +} + +main().catch((err) => { + console.error(`test-issue-1705-subpath-contrast-e2e.js: ERROR — ${err && err.stack || err}`); + process.exit(1); +}); diff --git a/test-issue-1705-subpath-contrast.js b/test-issue-1705-subpath-contrast.js new file mode 100644 index 00000000..5b20d083 --- /dev/null +++ b/test-issue-1705-subpath-contrast.js @@ -0,0 +1,260 @@ +/* Issue #1705 — WCAG AA contrast regression for `.subpath-selected .hop-prefix`. + * + * The class historically used `color: rgba(255,255,255,0.6)` over a + * `.subpath-selected { background: var(--accent) }` surface. In dark theme + * the composited pixel was ~1.87:1 — well under WCAG AA 4.5:1. + * + * This test: + * 1. Parses public/style.css and extracts `--accent`, `--accent-strong`, + * `--text-on-accent` per theme (with the same one-level var() resolver + * as test-issue-1668-m2-contrast.js). + * 2. Reads the declared `background` for `.subpath-selected` and the + * declared `color` for `.subpath-selected .hop-prefix`. + * 3. Properly composites rgba() foregrounds against the resolved + * background pixel before computing luminance (the historical + * a11y-probe miss called out in the #1705 issue body). + * 4. Asserts the composited fg-vs-bg contrast ratio is ≄4.5:1 in both + * `:root` (light) and `[data-theme="dark"]`. + * + * Red posture: with the legacy rule + * .subpath-selected .hop-prefix { color: rgba(255,255,255,0.6); } + * over `--accent` (#4a9eff in either theme), this test fails on the + * composited contrast assertion (ā‰ˆ1.87:1). + * + * Green posture: parent uses `--accent-strong` + `--text-on-accent`, and + * the child inherits, giving ≄4.5:1 on the composited pixel in both themes. + */ +'use strict'; +const fs = require('fs'); +const assert = require('assert'); + +const CSS_PATH = 'public/style.css'; +const css = fs.readFileSync(CSS_PATH, 'utf8'); + +// ── Token extraction (mirrors test-issue-1668-m2-contrast.js) ───────────── +// MF5 (review r1): throw loudly when the block regex does not match the +// scoped `selector { ... \n}` shape — silent {} previously let the test +// pass falsely if `style.css` were minified or its closing brace style +// changed. Callers must pass a human-readable `selectorLabel` for the +// error message. +function extractBlockTokens(blockRegex, selectorLabel) { + const tokens = {}; + const flagged = new RegExp(blockRegex.source, blockRegex.flags.includes('g') ? blockRegex.flags : blockRegex.flags + 'g'); + let m; + let matched = false; + while ((m = flagged.exec(css)) !== null) { + matched = true; + const body = m[1]; + const re = /^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gim; + let mm; + while ((mm = re.exec(body)) !== null) { + tokens[mm[1]] = mm[2].trim(); + } + } + if (!matched) { + throw new Error( + `extractBlockTokens: no closing brace found for selector ${selectorLabel || blockRegex.source} ` + + `(parser expects \` { ... \\n}\` shape; if style.css was minified or restructured, fix the parser).` + ); + } + return tokens; +} + +const lightTokens = extractBlockTokens(/:root\s*\{([\s\S]*?)\n\}/, ':root'); +const darkTokens = extractBlockTokens(/\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/, '[data-theme="dark"]'); + +// MF5 defensive assertion — if the CSS structure regresses to a shape +// the parser can't read, fail at the top rather than silently emitting +// "all colors look fine" because every token resolved to undefined. +for (const tok of ['--accent', '--accent-strong', '--text-on-accent']) { + assert.ok( + lightTokens[tok], + `extractBlockTokens regression: expected :root to declare ${tok} but parser found none. ` + + `Either the CSS structure changed or extractBlockTokens needs updating.` + ); +} + +function resolveToken(name, theme) { + const map = theme === 'dark' ? { ...lightTokens, ...darkTokens } : lightTokens; + let val = map[name]; + if (!val) return null; + for (let i = 0; i < 5; i++) { + const m = val.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*([^)]+))?\)\s*$/); + if (!m) break; + const next = map[m[1]]; + val = next || (m[2] ? m[2].trim() : null); + if (!val) return null; + } + return val; +} + +// ── Color parsing (returns [r,g,b,a] with a in [0,1]) ───────────────────── +function parseColor(s) { + if (!s) return null; + s = s.trim(); + let m = s.match(/^#([0-9a-f]{3})$/i); + if (m) { + return [ + parseInt(m[1][0] + m[1][0], 16), + parseInt(m[1][1] + m[1][1], 16), + parseInt(m[1][2] + m[1][2], 16), + 1, + ]; + } + m = s.match(/^#([0-9a-f]{6})$/i); + if (m) { + return [parseInt(m[1].slice(0,2),16), parseInt(m[1].slice(2,4),16), parseInt(m[1].slice(4,6),16), 1]; + } + m = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+))?\s*\)$/i); + if (m) return [+m[1], +m[2], +m[3], m[4] != null ? parseFloat(m[4]) : 1]; + return null; +} + +// ── Alpha compositing (sRGB straight-alpha over an opaque background) ───── +// The historical audit probe miss: rgba(255,255,255,0.6) over #4a9eff is +// NOT "white vs #4a9eff" — it's the COMPOSITED pixel vs #4a9eff for the +// purposes of WCAG. We compose then compare composed-fg to bg. +function composite(fg, bg) { + const a = fg[3]; + return [ + Math.round(fg[0] * a + bg[0] * (1 - a)), + Math.round(fg[1] * a + bg[1] * (1 - a)), + Math.round(fg[2] * a + bg[2] * (1 - a)), + 1, + ]; +} + +function relLum([r,g,b]) { + const f = (c) => { + c /= 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b); +} + +function contrast(fg, bg) { + const L1 = relLum(fg), L2 = relLum(bg); + const [hi, lo] = L1 >= L2 ? [L1, L2] : [L2, L1]; + return (hi + 0.05) / (lo + 0.05); +} + +// `.subpath-selected { background: ; color: ; ... }` +// `.subpath-selected .hop-prefix { color: ; ... }` +// MF7 (review r1): collect ALL matches for the property across every rule +// whose selector matches `selectorRegex` and fail loudly if more than one +// declaration is found, so a higher-specificity / later-in-cascade +// override doesn't silently win over (or under) the one we're auditing. +// The simplified parser here cannot model real CSS specificity; throwing +// is the correct discipline — a human resolves which one is the +// intended ground truth. +function extractDecl(selectorRegex, prop, selectorLabel) { + const re = new RegExp(selectorRegex.source + '\\s*\\{([^}]*)\\}', 'g'); + const matches = []; + let m; + while ((m = re.exec(css)) !== null) { + const body = m[1]; + const propRe = new RegExp('(?:^|[;\\s])' + prop + '\\s*:\\s*([^;]+?)\\s*(?:!important)?\\s*;', 'i'); + const mm = body.match(propRe); + if (mm) matches.push({ rule: m[0].slice(0, 80).replace(/\s+/g, ' '), value: mm[1].trim() }); + } + if (matches.length === 0) return null; + if (matches.length > 1) { + // Different values across rules → ambiguous cascade. Identical values + // are still suspicious but not load-bearing for this audit. + const distinct = new Set(matches.map(x => x.value)); + if (distinct.size > 1) { + throw new Error( + `extractDecl: multiple distinct \`${prop}\` declarations matched selector ${selectorLabel || selectorRegex.source} — ` + + `parser cannot resolve CSS specificity. Matches:\n` + + matches.map(x => ` ${x.value} (in: ${x.rule}...)`).join('\n') + + `\nResolve by collapsing rules in CSS or by adding a Playwright getComputedStyle assertion.` + ); + } + // Same value across N rules — warn but proceed. + console.warn(` ⚠ extractDecl: ${matches.length} rules declare \`${prop}\` = ${matches[0].value} for ${selectorLabel || selectorRegex.source} (identical, OK).`); + } + return matches[matches.length - 1].value; +} + +// The parent selector — `\\.subpath-selected` matches both +// `.subpath-selected { ... }` and the qualified child rule, so anchor by +// boundary on the closing of the class token. +const parentBg = extractDecl(/(?:^|[\s>+~,])\.subpath-selected(?=\s*\{)/, 'background', '.subpath-selected'); +const parentColor = extractDecl(/(?:^|[\s>+~,])\.subpath-selected(?=\s*\{)/, 'color', '.subpath-selected'); +const childColor = extractDecl(/\.subpath-selected\s+\.hop-prefix(?=\s*\{)/, 'color', '.subpath-selected .hop-prefix'); + +assert.ok(parentBg, '`.subpath-selected { background: ... }` not found in CSS'); +assert.ok(childColor, '`.subpath-selected .hop-prefix { color: ... }` not found in CSS'); + +console.log(`\n#1705 .subpath-selected hop-prefix contrast audit\n${'─'.repeat(60)}`); +console.log(`parent background : ${parentBg}`); +console.log(`parent color : ${parentColor || '(unset)'}`); +console.log(`child color : ${childColor}`); + +// `var(--token, fallback)` — resolve via theme map; if missing, use fallback. +function resolveCssValue(raw, theme) { + raw = raw.trim(); + const m = raw.match(/^var\(\s*(--[a-z0-9-]+)\s*(?:,\s*(.+))?\)$/i); + if (m) { + const tokVal = resolveToken(m[1], theme); + if (tokVal) return tokVal; + return m[2] ? resolveCssValue(m[2], theme) : null; + } + // strip trailing `!important` if any + return raw.replace(/\s*!important\s*$/i, ''); +} + +const THEMES = ['light', 'dark']; +let failures = 0; +for (const theme of THEMES) { + const bgRaw = resolveCssValue(parentBg, theme); + const bg = parseColor(bgRaw); + assert.ok(bg, `[${theme}] could not parse parent background "${bgRaw}"`); + + // Child color may be `inherit`, in which case fall back to the parent's + // declared color. MF6 (review r1): do NOT default to white — that + // historically hid a regression where the parent's color drifted from + // #fff to something else and the test still saw white-on-blue and + // passed. Walk the declared cascade: child → parent → throw if neither + // declares a color. If the user wants browser-cascade semantics, + // they can use the Playwright variant (test-issue-1705-subpath-contrast-e2e.js). + let childRaw = resolveCssValue(childColor, theme); + if (/^inherit$/i.test(childRaw)) { + if (!parentColor) { + throw new Error( + `[${theme}] child .hop-prefix color is \`inherit\` but no parent .subpath-selected color is declared in CSS. ` + + `Parser cannot walk past one level — either declare a color on .subpath-selected, ` + + `or replace this parser test with the Playwright variant which uses real getComputedStyle.` + ); + } + childRaw = resolveCssValue(parentColor, theme); + if (!childRaw) { + throw new Error( + `[${theme}] could not resolve parent color "${parentColor}" while expanding child \`inherit\` in ${theme} theme.` + ); + } + } + const fgDeclared = parseColor(childRaw); + assert.ok(fgDeclared, `[${theme}] could not parse child color "${childRaw}"`); + + // Alpha-composite the declared foreground over the resolved background + // pixel before computing contrast. This is the correctness fix the + // probe missed. + const fgComposed = fgDeclared[3] < 1 ? composite(fgDeclared, bg) : fgDeclared; + const ratio = contrast(fgComposed, bg); + + const hex = (c) => `#${c.slice(0,3).map(v=>v.toString(16).padStart(2,'0')).join('')}`; + const ok = ratio >= 4.5; + console.log( + `${ok ? 'āœ“' : 'āœ—'} ${theme.padEnd(5)} hop-prefix composed=${hex(fgComposed)} on bg=${hex(bg)} ratio=${ratio.toFixed(2)}:1 (need ≄4.5)` + ); + if (!ok) failures++; + assert.ok( + ok, + `#1705 regression: .subpath-selected .hop-prefix contrast in ${theme} theme is ${ratio.toFixed(2)}:1, below WCAG AA 4.5:1 (composed ${hex(fgComposed)} on ${hex(bg)})` + ); +} + +if (failures === 0) { + console.log(`\nāœ… PASS — .subpath-selected .hop-prefix ≄4.5:1 in both themes`); +}