mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-16 13:22:41 +00:00
Partial fix for #1668 (M5 of 6). After M1 (audit), M2 (color tokens, #1676), M3 (typography floor, #1679), and M4 (per-route polish, #1681) cleared ~95% of contrast/typography violations, M5 **locks in the wins** by adding an axe-core CI gate that fails the build on any new WCAG AA color-contrast regression. ## What's in the box - `test-a11y-axe-1668.js` — Playwright + `@axe-core/playwright`. Runs every major CoreScope route × `{dark, light}` at 1200×900 desktop, injects axe, runs only the `color-contrast` rule, asserts net violations === 0. - `test-a11y-axe-1668-selftest.js` — fast, deterministic, browser-free unit test that exercises the YAML allowlist parser, the `violationAllowed` matcher, and the route/theme metadata. Runs in the JS unit block (no browser needed). - `tests/a11y-allowlist.yaml` — operator-flagged false-positive allowlist. **0 entries at M5 baseline.** ## Allowlist format Each entry MUST cite a GH issue # and an `expires_at` date. Missing fields = refused. Expired `expires_at` = refused (warning logged). This **forces a periodic revisit** — no permanent suppressions. ```yaml - route: /analytics?tab=channels selector: ".some-known-stale-element" rule: color-contrast issue: 1234 expires_at: 2026-09-01 ``` ## Routes covered (19 × 2 themes = 38 cells) `/`, `/packets`, `/nodes`, `/channels`, `/live`, `/map`, `/observers`, `/compare`, `/analytics?tab={overview,rf,topology,channels,hashsizes,collisions,roles,airtime}`, `/audio-lab`, `/customize`, `/replay`. ## TDD red→green - **RED** (`08adafdb`) — adds the gate + deliberately regresses `--text-muted` from `palette-gray-700` (~10:1) to `#9ca3af` (~2.4:1). axe-core fails on every light-theme cell. - **GREEN** (`f62fb1e0`) — restores the M2 token. Net violations = 0 across all 38 cells. ## Scope discipline - Only `color-contrast` (matches M2/M3/M4 scope). M6 owns `image-alt`, `aria-required-attr`, `label`, mobile viewports, and letsmesh A/B. - No new design tokens. - M2-M4 tokens untouched. ## CI wiring - `.github/workflows/deploy.yml:155` — selftest in JS unit block. - `.github/workflows/deploy.yml:367` — real axe browser run in the Playwright E2E block after the fixture server is up. ## Deps `@axe-core/playwright@4.11.3` + `axe-core@4.12.1` added to `devDependencies`. Pinned versions. --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: clawbot <clawbot@users.noreply.github.com>
165 lines
7.0 KiB
JavaScript
165 lines
7.0 KiB
JavaScript
/**
|
|
* #1281 — Packet detail Location row + 📍map link contrast.
|
|
*
|
|
* Bug:
|
|
* A) <dt>Location</dt><dd>—</dd> renders unconditionally on every packet,
|
|
* wasting a row on ~90% of packet types (only ADVERT carries unencrypted
|
|
* transmitter GPS).
|
|
* B) The trailing `📍map` link has no class/color → inherits UA-default <a>
|
|
* blue → unreadable in dark mode.
|
|
*
|
|
* Asserts:
|
|
* 1. Some non-ADVERT packet detail does NOT contain <dt>Location</dt>.
|
|
* 2. Some ADVERT packet detail DOES contain <dt>Location</dt> with coords.
|
|
* 3. The 📍map link uses class="loc-map-link" with a themed link color
|
|
* (was --accent pre-M5; now --link-color after #1668/PR #1696 AA-contrast
|
|
* fix). The hard guarantee is: NOT the default UA blue rgb(0,0,238).
|
|
*
|
|
* Usage: BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js
|
|
*/
|
|
'use strict';
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
|
|
|
let passed = 0, failed = 0;
|
|
async function step(name, fn) {
|
|
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
|
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
|
}
|
|
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
|
|
|
function normRgb(s) {
|
|
const m = s && s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
if (!m) return null;
|
|
return `rgb(${m[1]}, ${m[2]}, ${m[3]})`;
|
|
}
|
|
|
|
async function gotoPackets(page) {
|
|
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => {
|
|
localStorage.removeItem('meshcore-groupbyhash');
|
|
localStorage.setItem('meshcore-time-window', '525600');
|
|
});
|
|
await page.reload({ waitUntil: 'load' });
|
|
await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 });
|
|
}
|
|
|
|
// Click rows until detail pane's Payload Type matches `wantType` (e.g. "Advert"
|
|
// or any non-"Advert"). Returns true on hit, false if exhausted.
|
|
async function findPacketDetailByType(page, predicate, maxRows = 40) {
|
|
await page.waitForTimeout(400);
|
|
const rows = await page.$$('table tbody tr[data-hash][data-action]');
|
|
for (let i = 0; i < Math.min(rows.length, maxRows); i++) {
|
|
await rows[i].click({ timeout: 3000 }).catch(() => null);
|
|
await page.waitForTimeout(350);
|
|
const meta = await page.evaluate(() => {
|
|
const dts = document.querySelectorAll('dl.detail-meta dt');
|
|
let typeName = null;
|
|
let hasLocation = false;
|
|
let locationText = '';
|
|
for (const dt of dts) {
|
|
const label = dt.textContent.trim();
|
|
const dd = dt.nextElementSibling;
|
|
if (label === 'Payload Type') typeName = dd ? dd.textContent.trim() : null;
|
|
if (label === 'Location') { hasLocation = true; locationText = dd ? dd.textContent.trim() : ''; }
|
|
}
|
|
return { typeName, hasLocation, locationText };
|
|
});
|
|
if (predicate(meta)) return meta;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
|
|
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
|
});
|
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(15000);
|
|
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
|
|
|
console.log(`\n=== #1281 Location row + map link contrast E2E against ${BASE} ===`);
|
|
|
|
await step('Non-ADVERT packet detail does NOT render <dt>Location</dt>', async () => {
|
|
await gotoPackets(page);
|
|
// Filter to a non-ADVERT type to make the search efficient.
|
|
const meta = await findPacketDetailByType(
|
|
page,
|
|
(m) => m.typeName && m.typeName !== 'Advert',
|
|
40
|
|
);
|
|
assert(meta, 'No non-ADVERT packet found in first 40 rows');
|
|
assert(!meta.hasLocation,
|
|
`Expected NO <dt>Location</dt> for type "${meta.typeName}", but found one with text "${meta.locationText}"`);
|
|
});
|
|
|
|
await step('ADVERT packet detail STILL renders <dt>Location</dt> with GPS coords', async () => {
|
|
await gotoPackets(page);
|
|
// Filter UI to ADVERTs to guarantee we find one.
|
|
const fInput = await page.$('#packetFilterInput');
|
|
if (fInput) {
|
|
await fInput.fill('type == ADVERT');
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForTimeout(600);
|
|
}
|
|
const meta = await findPacketDetailByType(
|
|
page,
|
|
(m) => m.typeName === 'Advert' && m.hasLocation,
|
|
40
|
|
);
|
|
assert(meta, 'No ADVERT packet with Location row found in first 40 ADVERT rows');
|
|
assert(/-?\d+\.\d+\s*,\s*-?\d+\.\d+/.test(meta.locationText),
|
|
`ADVERT Location should contain GPS coords, got: "${meta.locationText}"`);
|
|
});
|
|
|
|
await step('📍map link uses class="loc-map-link" with themed link color (not UA default blue)', async () => {
|
|
// Reuse the ADVERT detail pane left open from the previous step.
|
|
//
|
|
// History: original #1281 asserted color === var(--accent) (rgb(74,158,255)).
|
|
// M5 axe/WCAG-AA fixes (#1668, PR #1696) rewired link text away from --accent
|
|
// because --accent (#4a9eff) fails AA contrast for body-text links on most
|
|
// surfaces. .loc-map-link now resolves via --link-color (= --palette-blue-600
|
|
// = #2563eb), which passes AA. Test author intent ("links use a token, not
|
|
// raw UA blue") is satisfied by either token; we now track --link-color to
|
|
// match the CSS rule and preserve the "not UA default blue" guard.
|
|
const result = await page.evaluate(() => {
|
|
const link = document.querySelector('dl.detail-meta a.loc-map-link');
|
|
if (!link) return { missing: true };
|
|
const cs = getComputedStyle(link);
|
|
const linkColorRaw = getComputedStyle(document.documentElement).getPropertyValue('--link-color').trim();
|
|
// Resolve --link-color value to its computed rgb() via a probe element.
|
|
const probe = document.createElement('span');
|
|
probe.style.color = `var(--link-color)`;
|
|
document.body.appendChild(probe);
|
|
const linkColorRgb = getComputedStyle(probe).color;
|
|
probe.remove();
|
|
return {
|
|
linkColor: cs.color,
|
|
tokenRgb: linkColorRgb,
|
|
tokenRaw: linkColorRaw,
|
|
href: link.getAttribute('href'),
|
|
text: link.textContent.trim(),
|
|
};
|
|
});
|
|
assert(!result.missing,
|
|
'<a class="loc-map-link"> not found in detail pane — implementation must apply the class');
|
|
const link = normRgb(result.linkColor);
|
|
const token = normRgb(result.tokenRgb);
|
|
console.log(` link.color=${result.linkColor} --link-color→${result.tokenRgb} (raw "${result.tokenRaw}")`);
|
|
assert(link === token,
|
|
`📍map link color ${result.linkColor} must equal var(--link-color) (${result.tokenRgb}); ` +
|
|
`default UA blue (rgb(0, 0, 238)) is not acceptable`);
|
|
assert(link !== 'rgb(0, 0, 238)',
|
|
'Link color is UA-default blue — class is missing or CSS rule does not match');
|
|
});
|
|
|
|
await browser.close();
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed === 0 ? 0 : 1);
|
|
})().catch((e) => { console.error(e); process.exit(1); });
|