mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 05:18:18 +00:00
## Summary **Regression guard for an already-fixed bug.** The WCAG AA contrast BLOCKER on `.subpath-selected .hop-prefix` called out in #1705 was already resolved by PR #1708 (commit `293efdb6`), which is an ancestor of this branch's base. This PR adds the missing automated test that locks the fix in — it does **not** ship a CSS change. ### Why this is not a TDD red→green sequence Test commit `0d58d1d5` is **green-on-arrival**: the fix it asserts against had already landed days earlier on master. There is no red commit on this branch — running the test at any point on this branch passes. This PR therefore claims the **net-new-test exemption** per `~/.openclaw/workspace-meshcore/AGENTS.md`: bug fixes on EXISTING UI normally require red→green, but where the fix shipped first and the test is a *post-hoc regression guard*, the test lands in the same PR series but does not need to be the first commit. No production behavior is changed by anything in this branch. (The earlier revisions of this body presented a misleading red→green table; that framing was wrong and has been removed.) ## What this PR actually ships Two test files plus CI wiring — all test-only / config-only: 1. `test-issue-1705-subpath-contrast.js` — parses `public/style.css`, extracts `--accent-strong` / `--text-on-accent` per theme, sRGB-composites any `rgba()` foreground against the resolved background, asserts WCAG AA ≥4.5:1 on `.subpath-selected .hop-prefix` in both themes. 2. `test-issue-1705-subpath-contrast-e2e.js` — Playwright/headless-Chromium variant that loads the real `public/style.css` into a DOM mirroring `analytics.js` `renderSubpathsTable`, then asserts `getComputedStyle` contrast on a live `tr.subpath-selected > .hop-prefix`. Catches specificity/cascade regressions the static parser cannot. 3. `.github/workflows/deploy.yml` PR test stage wiring — runs both tests on every PR. ## The bug (historical, already fixed) For context: `.subpath-selected .hop-prefix` measured ~1.87:1 in dark theme (`rgba(255,255,255,0.6)` composited against `var(--accent)` = `#4a9eff`). #1708 (`293efdb6`) swapped `.subpath-selected` background to `var(--accent-strong)` (`#2563eb`) and color to `var(--text-on-accent)` (`#f9fafb`), yielding **4.95:1** in both themes. The child `.hop-prefix` color was set to `inherit` so the prefix cannot be decoratively muted below AA again. ## Parser test — hardening (review r1) Round-1 review pass surfaced 7 must-fix items on the parser test; all addressed: | # | Concern | Fix | |---|---------|-----| | MF3 | Parser asserted declared cascade, not computed style | Added the Playwright E2E variant; `getComputedStyle` resolves specificity natively | | MF4 | CSS comment cited "4.83:1+" while measured value is 4.95:1 | Comment updated to `#f9fafb on #2563eb = 4.95:1` | | MF5 | `extractBlockTokens` silently returned `{}` when the regex didn't match | Throws with selector label; defensive assertion verifies `:root` declares the three tokens | | MF6 | `'inherit'` on the child color fell back to `#ffffff` silently | Now throws; callers either declare the parent color or use the Playwright variant where `inherit` resolves natively | | MF7 | `extractDecl` picked the last regex match across rules with no specificity model | Now throws on N>1 distinct values; warns on N>1 identical values | ## E2E test — hardening (polish v2) - M2: removed dead `<link rel="stylesheet" href="file://...">` element from the test HTML template — `setContent` runs against `about:blank` origin and cannot fetch `file://`, so the link was dead. CSS is injected inline via `page.evaluate` (unchanged). Removed the now-unused `cssHref` declaration. - M3: fixed misleading class comment — the production table uses `.analytics-table` (see `analytics.js` `renderSubpathsTable`), so the fixture's `class="analytics-table subpaths-table"` was misleading. Stripped `.subpaths-table` from the fixture and corrected the comment to cite the real production class. ## Acceptance criteria - [x] `.subpath-selected .hop-prefix` ≥4.5:1 in both themes — verified by both tests (parser + E2E) - [x] Regression test added covering the selected state, wired into PR CI Partial fix for #1705 — leaves the issue open for the audit-probe alpha-compositing work (private tooling, out of scope here). ## Local test results - `node test-issue-1705-subpath-contrast.js` (parser): **PASS** — `light 4.95:1 / dark 4.95:1` - `node test-issue-1705-subpath-contrast-e2e.js` (Playwright): **SKIP** in dev sandbox (chromium relocation error — musl/arm sandbox-in-sandbox); CI installs the Playwright-bundled binary via `npx playwright install chromium` and runs with `CHROMIUM_REQUIRE=1` --------- Co-authored-by: CoreScope Bot <bot@corescope.local> Co-authored-by: Kpa-clawbot <bot@openclaw.local> Co-authored-by: clawbot <clawbot@users.noreply.github.com>
211 lines
8.2 KiB
JavaScript
211 lines
8.2 KiB
JavaScript
#!/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 `<tr class="subpath-selected">` with a
|
|
* `<span class="hop-prefix">` 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 <tr data-hops>).
|
|
// The production table uses `.analytics-table` (see analytics.js
|
|
// renderSubpathsTable: `<table class="analytics-table">`), so the
|
|
// fixture below mirrors that exact class to exercise any rule scoped
|
|
// to `.analytics-table tr.subpath-selected .hop-prefix`.
|
|
const html = `<!doctype html>
|
|
<html><head>
|
|
<meta charset="utf-8">
|
|
<title>1705 e2e</title>
|
|
</head>
|
|
<body>
|
|
<table class="analytics-table">
|
|
<thead><tr><th>#</th><th>Route</th><th>Count</th></tr></thead>
|
|
<tbody>
|
|
<tr data-hops="aa,bb" class="subpath-selected">
|
|
<td>1</td>
|
|
<td>node-a → node-b<br><span class="hop-prefix mono" id="probe-prefix">aa → bb</span></td>
|
|
<td>42</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</body></html>`;
|
|
|
|
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);
|
|
});
|