mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-04 22:39:37 +00:00
# M6 — expanded axe ruleset (#1668) **Closes #1668.** M1-M5 already merged: M2 palette/contrast, M3 typography, M4 per-route polish, M5 axe gate + 443→0 fixes. ## What this PR adds ### Expanded axe ruleset - New rules: image-alt, label, aria-required-attr, aria-valid-attr (12 total, verified 0 violations on master after one fix) - Mobile viewport (375×812) added alongside existing 1200×900 desktop - TDD: RED commit `d3e4309e` expands the rule/viewport set deliberately to fail; GREEN commit `5599068f` adds the one needed aria-label fix on audio-lab BPM + Volume sliders ## What this PR does NOT include The letsmesh A/B verification artifact (initially scoped for M6) is split out to a follow-up issue. The capture script needs more work to reliably navigate post-onboarding state on both sites. Tracked separately so the gate-expansion work isn't held up by tooling. ## Test plan - `test-a11y-axe-1668.js` runs new ruleset across both viewports — 0 violations baseline (on master pre-merge AND post-merge) - `test-a11y-axe-1668-selftest.js` unchanged (allowlist semantics still apply) - Anti-tautology: reverting `5599068f` produces 8 net violations on `#alabBPM`/`#alabVol` × 2 themes × 2 viewports ## Notes - Allowlist still empty (per M5 policy — issue# + expires_at required) - M5 token work covered all color-contrast surfaces; M6's image/aria additions only required one fix (audio-lab sliders) --------- Co-authored-by: Kpa-clawbot <bot@openclaw.local>
This commit is contained in:
co-authored by
Kpa-clawbot
parent
3c440c0049
commit
eaac816280
@@ -363,9 +363,15 @@ jobs:
|
||||
- name: Run Playwright E2E tests (fail-fast)
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
||||
# M5 of #1668 — axe-core CI gate (color-contrast AA).
|
||||
# Real browser run; fails on any net violation (raw − allowlist).
|
||||
# Allowlist: tests/a11y-allowlist.yaml (0 entries at M5 baseline).
|
||||
# M5+M6 of #1668 — axe-core CI gate.
|
||||
# M5: color-contrast on desktop dark+light.
|
||||
# M6: expanded ruleset (image-alt, label, aria-required-attr,
|
||||
# aria-valid-attr, aria-valid-attr-value, landmark-one-main,
|
||||
# region, button-name, link-name, document-title, html-has-lang,
|
||||
# duplicate-id) AND adds 375x812 mobile viewport (with
|
||||
# color-contrast on mobile too).
|
||||
# Allowlist: tests/a11y-allowlist.yaml (0 entries — hard pass policy).
|
||||
# Per-viewport summary printed at the end; any net>0 fails the build.
|
||||
BASE_URL=http://localhost:13581 AXE_SCREENSHOT_DIR=/tmp/axe-1668 \
|
||||
node test-a11y-axe-1668.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
+2
-2
@@ -445,12 +445,12 @@
|
||||
<button class="alab-speed" data-speed="4">4x</button>
|
||||
<div class="alab-slider-group">
|
||||
<span>BPM</span>
|
||||
<input type="range" id="alabBPM" min="30" max="300" value="${baseBPM}">
|
||||
<input type="range" id="alabBPM" aria-label="Playback BPM" min="30" max="300" value="${baseBPM}">
|
||||
<span id="alabBPMVal">${baseBPM}</span>
|
||||
</div>
|
||||
<div class="alab-slider-group">
|
||||
<span>Vol</span>
|
||||
<input type="range" id="alabVol" min="0" max="100" value="${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}">
|
||||
<input type="range" id="alabVol" aria-label="Playback volume" min="0" max="100" value="${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}">
|
||||
<span id="alabVolVal">${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}%</span>
|
||||
</div>
|
||||
<div class="alab-slider-group">
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@
|
||||
</nav>
|
||||
|
||||
<!-- Search overlay -->
|
||||
<div id="searchOverlay" class="search-overlay hidden" aria-label="Search packets, nodes, channels">
|
||||
<div id="searchOverlay" class="search-overlay hidden" role="search" aria-label="Search packets, nodes, channels">
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="Search packets, nodes, channels…" autofocus>
|
||||
<div id="searchResults" class="search-results" role="listbox"></div>
|
||||
|
||||
@@ -24,6 +24,33 @@ assert.ok(Array.isArray(mod.ROUTES), 'ROUTES must be an array');
|
||||
assert.ok(mod.ROUTES.length >= 14, `ROUTES too small: ${mod.ROUTES.length}`);
|
||||
assert.deepStrictEqual(mod.THEMES, ['dark', 'light'], 'THEMES must be [dark,light]');
|
||||
|
||||
// ---- M6: viewports + per-viewport rulesets ---------------------------------
|
||||
assert.ok(Array.isArray(mod.VIEWPORTS), 'VIEWPORTS must be an array');
|
||||
assert.strictEqual(mod.VIEWPORTS.length, 2, 'M6: VIEWPORTS must have desktop + mobile');
|
||||
const vpDesktop = mod.VIEWPORTS.find(v => v.name === 'desktop');
|
||||
const vpMobile = mod.VIEWPORTS.find(v => v.name === 'mobile');
|
||||
assert.ok(vpDesktop, 'VIEWPORTS missing desktop');
|
||||
assert.ok(vpMobile, 'VIEWPORTS missing mobile');
|
||||
assert.strictEqual(vpDesktop.w, 1200, 'desktop width must be 1200');
|
||||
assert.strictEqual(vpDesktop.h, 900, 'desktop height must be 900');
|
||||
assert.strictEqual(vpMobile.w, 375, 'mobile width must be 375');
|
||||
assert.strictEqual(vpMobile.h, 812, 'mobile height must be 812');
|
||||
assert.ok(Array.isArray(vpDesktop.rules) && vpDesktop.rules.length > 0, 'desktop.rules must be a non-empty array');
|
||||
assert.ok(Array.isArray(vpMobile.rules) && vpMobile.rules.length > 0, 'mobile.rules must be a non-empty array');
|
||||
// M6: every gated viewport MUST include color-contrast (mobile color-contrast is
|
||||
// the M6 promise) and the new rules must be present on both.
|
||||
for (const vp of mod.VIEWPORTS) {
|
||||
for (const required of ['color-contrast', 'image-alt', 'label',
|
||||
'aria-required-attr', 'region']) {
|
||||
assert.ok(vp.rules.includes(required),
|
||||
`viewport ${vp.name} must include rule "${required}"`);
|
||||
}
|
||||
}
|
||||
// And both viewports' rule arrays must match the exported RULES_* constants
|
||||
// (anti-drift: prevents someone hand-editing one but not the other).
|
||||
assert.deepStrictEqual(vpDesktop.rules, mod.RULES_DESKTOP, 'desktop.rules drift vs RULES_DESKTOP');
|
||||
assert.deepStrictEqual(vpMobile.rules, mod.RULES_MOBILE, 'mobile.rules drift vs RULES_MOBILE');
|
||||
|
||||
// Spot-check key routes from the M1 audit baseline
|
||||
for (const r of ['/', '/packets', '/nodes', '/live', '/map', '/analytics?tab=collisions', '/audio-lab']) {
|
||||
assert.ok(mod.ROUTES.includes(r), `ROUTES missing ${r}`);
|
||||
|
||||
+110
-63
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* test-a11y-axe-1668.js — Milestone 5 of #1668
|
||||
* test-a11y-axe-1668.js — Milestones 5 + 6 of #1668
|
||||
*
|
||||
* axe-core CI gate. Loads every major CoreScope route in dark + light theme,
|
||||
* injects axe-core, runs the `color-contrast` rule, and asserts zero
|
||||
* injects axe-core, runs the configured ruleset, and asserts zero
|
||||
* violations (modulo `tests/a11y-allowlist.yaml`).
|
||||
*
|
||||
* Scope per M5 brief:
|
||||
* - Rules: color-contrast ONLY (M6 owns the expanded ruleset)
|
||||
* - Themes: dark + light
|
||||
* - Viewport: 1200x900 desktop (mobile = M6)
|
||||
* Scope:
|
||||
* - M5: color-contrast on desktop dark+light at 1200x900.
|
||||
* - M6: expanded ruleset (image-alt, label, aria-required-attr,
|
||||
* aria-valid-attr, aria-valid-attr-value, landmark-one-main, region,
|
||||
* button-name, link-name, document-title, html-has-lang, duplicate-id)
|
||||
* applied across BOTH viewports, PLUS color-contrast at 375x812 mobile.
|
||||
*
|
||||
* Themes: dark + light
|
||||
* Viewports: desktop 1200x900, mobile 375x812 (M6 adds mobile)
|
||||
*
|
||||
* Allowlist (`tests/a11y-allowlist.yaml`):
|
||||
* Operator-flagged false-positives. Each entry MUST cite an issue # AND
|
||||
@@ -81,6 +86,32 @@ const REGISTERED_ANALYTICS_TABS = [
|
||||
|
||||
const THEMES = ['dark', 'light'];
|
||||
|
||||
// M6: ruleset per viewport. Both viewports share the expanded ruleset;
|
||||
// color-contrast also runs on both (M5 baseline desktop + M6 mobile gate).
|
||||
// All rules in these arrays MUST be 0 violations against the CI fixture
|
||||
// (no allowlist seeding — same hard policy as M5).
|
||||
const RULES_DESKTOP = [
|
||||
'color-contrast',
|
||||
'image-alt',
|
||||
'label',
|
||||
'aria-required-attr',
|
||||
'aria-valid-attr',
|
||||
'aria-valid-attr-value',
|
||||
'landmark-one-main',
|
||||
'region',
|
||||
'button-name',
|
||||
'link-name',
|
||||
'document-title',
|
||||
'html-has-lang',
|
||||
'duplicate-id',
|
||||
];
|
||||
const RULES_MOBILE = RULES_DESKTOP.slice(); // identical at M6; split arrays let
|
||||
// a future PR diverge cleanly.
|
||||
const VIEWPORTS = [
|
||||
{ name: 'desktop', w: 1200, h: 900, rules: RULES_DESKTOP },
|
||||
{ name: 'mobile', w: 375, h: 812, rules: RULES_MOBILE },
|
||||
];
|
||||
|
||||
// ---- tiny YAML loader (flow `[]` or block list of `key: value` maps) -------
|
||||
//
|
||||
// Stays dependency-free — we only need to parse our own narrow schema.
|
||||
@@ -194,7 +225,7 @@ async function setTheme(page, theme) {
|
||||
}, theme);
|
||||
}
|
||||
|
||||
async function runRoute(page, route, theme, AxeBuilder) {
|
||||
async function runRoute(page, route, theme, rules, AxeBuilder) {
|
||||
const url = `${BASE}/#${route}`;
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
// Give the SPA a moment to render. We deliberately do NOT
|
||||
@@ -209,8 +240,7 @@ async function runRoute(page, route, theme, AxeBuilder) {
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
const axe = new AxeBuilder({ page })
|
||||
.withRules(['color-contrast']);
|
||||
const axe = new AxeBuilder({ page }).withRules(rules);
|
||||
const result = await axe.analyze();
|
||||
return result;
|
||||
}
|
||||
@@ -223,7 +253,7 @@ async function main() {
|
||||
console.log(`a11y-axe-1668: BASE=${BASE} allowlist=${allowlist.length} entries`);
|
||||
|
||||
const routesToRun = ROUTES_FILTER.length ? ROUTES.filter(r => ROUTES_FILTER.includes(r)) : ROUTES;
|
||||
console.log(`a11y-axe-1668: routes=${routesToRun.length} themes=${THEMES.length} cells=${routesToRun.length * THEMES.length}`);
|
||||
console.log(`a11y-axe-1668: routes=${routesToRun.length} themes=${THEMES.length} viewports=${VIEWPORTS.length} cells=${routesToRun.length * THEMES.length * VIEWPORTS.length}`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
@@ -231,67 +261,77 @@ async function main() {
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const summary = []; // { route, theme, raw, suppressed, net }
|
||||
const summary = []; // { vp, route, theme, raw, suppressed, net }
|
||||
let totalNet = 0;
|
||||
// Per-viewport tallies for the summary footer.
|
||||
const vpTotals = {};
|
||||
for (const vp of VIEWPORTS) vpTotals[vp.name] = { raw: 0, suppressed: 0, net: 0 };
|
||||
|
||||
try {
|
||||
for (const theme of THEMES) {
|
||||
// One context per theme — keeps the init-script localStorage stable.
|
||||
const context = await browser.newContext({ viewport: { width: 1200, height: 900 } });
|
||||
await context.addInitScript((t) => {
|
||||
try {
|
||||
localStorage.setItem('meshcore-theme', t);
|
||||
localStorage.setItem('live-controls-expanded', 'true');
|
||||
localStorage.setItem('meshcore-time-window', '525600');
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
} catch (_) {}
|
||||
}, theme);
|
||||
for (const vp of VIEWPORTS) {
|
||||
console.log(`\n--- viewport ${vp.name} ${vp.w}x${vp.h} rules=${vp.rules.length} ---`);
|
||||
for (const theme of THEMES) {
|
||||
// One context per (viewport, theme) — keeps init-script localStorage stable.
|
||||
const context = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
|
||||
await context.addInitScript((t) => {
|
||||
try {
|
||||
localStorage.setItem('meshcore-theme', t);
|
||||
localStorage.setItem('live-controls-expanded', 'true');
|
||||
localStorage.setItem('meshcore-time-window', '525600');
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
} catch (_) {}
|
||||
}, theme);
|
||||
|
||||
for (const route of routesToRun) {
|
||||
const page = await context.newPage();
|
||||
let raw = 0, suppressed = 0, net = 0;
|
||||
const violationsDetail = [];
|
||||
try {
|
||||
const result = await runRoute(page, route, theme, AxeBuilder);
|
||||
for (const v of result.violations) {
|
||||
if (v.id !== 'color-contrast') continue; // narrow safeguard
|
||||
for (const node of v.nodes) {
|
||||
raw++;
|
||||
if (violationAllowed(route, v.id, node, allowlist)) {
|
||||
suppressed++;
|
||||
} else {
|
||||
net++;
|
||||
violationsDetail.push({
|
||||
selector: node.target,
|
||||
html: node.html && node.html.slice(0, 200),
|
||||
message: node.failureSummary,
|
||||
});
|
||||
for (const route of routesToRun) {
|
||||
const page = await context.newPage();
|
||||
let raw = 0, suppressed = 0, net = 0;
|
||||
const violationsDetail = [];
|
||||
try {
|
||||
const result = await runRoute(page, route, theme, vp.rules, AxeBuilder);
|
||||
for (const v of result.violations) {
|
||||
if (!vp.rules.includes(v.id)) continue; // narrow safeguard
|
||||
for (const node of v.nodes) {
|
||||
raw++;
|
||||
if (violationAllowed(route, v.id, node, allowlist)) {
|
||||
suppressed++;
|
||||
} else {
|
||||
net++;
|
||||
violationsDetail.push({
|
||||
rule: v.id,
|
||||
selector: node.target,
|
||||
html: node.html && node.html.slice(0, 200),
|
||||
message: node.failureSummary,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Probe errors should NOT silently pass — treat as a hard failure
|
||||
// so route regressions (server 500, hash route 404, JS crash) surface.
|
||||
net = 1;
|
||||
violationsDetail.push({ probeError: err.message });
|
||||
}
|
||||
} catch (err) {
|
||||
// Probe errors should NOT silently pass — treat as a hard failure
|
||||
// so route regressions (server 500, hash route 404, JS crash) surface.
|
||||
net = 1;
|
||||
violationsDetail.push({ probeError: err.message });
|
||||
}
|
||||
|
||||
const cell = { route, theme, raw, suppressed, net };
|
||||
summary.push(cell);
|
||||
totalNet += net;
|
||||
const verdict = net === 0 ? '✅' : '❌';
|
||||
console.log(` ${verdict} ${theme.padEnd(5)} ${route.padEnd(34)} raw=${raw} suppressed=${suppressed} net=${net}`);
|
||||
if (net > 0) {
|
||||
for (const d of violationsDetail) {
|
||||
console.log(` - ${JSON.stringify(d).slice(0, 500)}`);
|
||||
const cell = { vp: vp.name, route, theme, raw, suppressed, net };
|
||||
summary.push(cell);
|
||||
totalNet += net;
|
||||
vpTotals[vp.name].raw += raw;
|
||||
vpTotals[vp.name].suppressed += suppressed;
|
||||
vpTotals[vp.name].net += net;
|
||||
const verdict = net === 0 ? '✅' : '❌';
|
||||
console.log(` ${verdict} ${vp.name.padEnd(7)} ${theme.padEnd(5)} ${route.padEnd(34)} raw=${raw} suppressed=${suppressed} net=${net}`);
|
||||
if (net > 0) {
|
||||
for (const d of violationsDetail) {
|
||||
console.log(` - ${JSON.stringify(d).slice(0, 500)}`);
|
||||
}
|
||||
const safe = `${vp.name}_${theme}_${route.replace(/[^a-z0-9]+/gi, '_')}`;
|
||||
const shot = path.join(SHOT_DIR, `${safe}.png`);
|
||||
try { await page.screenshot({ path: shot, fullPage: false }); } catch (_) {}
|
||||
}
|
||||
const safe = `${theme}_${route.replace(/[^a-z0-9]+/gi, '_')}`;
|
||||
const shot = path.join(SHOT_DIR, `${safe}.png`);
|
||||
try { await page.screenshot({ path: shot, fullPage: false }); } catch (_) {}
|
||||
await page.close();
|
||||
}
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
@@ -299,16 +339,20 @@ async function main() {
|
||||
|
||||
console.log('');
|
||||
console.log(`a11y-axe-1668: SUMMARY net=${totalNet} cells=${summary.length}`);
|
||||
for (const vp of VIEWPORTS) {
|
||||
const t = vpTotals[vp.name];
|
||||
console.log(` viewport ${vp.name}: raw=${t.raw} suppressed=${t.suppressed} net=${t.net} (${vp.rules.length} rules)`);
|
||||
}
|
||||
for (const c of summary) {
|
||||
if (c.net > 0) {
|
||||
console.log(` FAIL ${c.theme} ${c.route} net=${c.net}`);
|
||||
console.log(` FAIL ${c.vp} ${c.theme} ${c.route} net=${c.net}`);
|
||||
}
|
||||
}
|
||||
if (totalNet > 0) {
|
||||
console.error(`\nFAIL: ${totalNet} color-contrast violation(s) above allowlist`);
|
||||
console.error(`\nFAIL: ${totalNet} a11y violation(s) above allowlist`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nPASS: zero color-contrast violations across ${summary.length} cells`);
|
||||
console.log(`\nPASS: zero violations across ${summary.length} cells (${VIEWPORTS.length} viewports × ${THEMES.length} themes × ${routesToRun.length} routes)`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
@@ -327,6 +371,9 @@ module.exports = {
|
||||
violationAllowed,
|
||||
ROUTES,
|
||||
THEMES,
|
||||
VIEWPORTS,
|
||||
RULES_DESKTOP,
|
||||
RULES_MOBILE,
|
||||
REGISTERED_PAGES,
|
||||
REGISTERED_ANALYTICS_TABS,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user