Files
meshcore-analyzer/public/traces.js
T
d954ea7444 feat(#1668): axe-core CI gate for WCAG AA color-contrast (M5) (#1696)
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>
2026-06-12 20:00:35 -07:00

366 lines
15 KiB
JavaScript

/* === CoreScope — traces.js === */
'use strict';
(function () {
let currentHash = null;
let traceData = [];
let packetMeta = null;
function init(app, routeParam) {
// Check URL for pre-filled hash — support both route param and query param
const params = new URLSearchParams(location.hash.split('?')[1] || '');
const urlHash = routeParam || params.get('hash') || '';
// Use the global 5-char OWASP escapeHtml from app.js. Hard-fail if it's
// missing — never fall back to identity (see #1537: map.js safeEsc bug).
if (typeof escapeHtml !== 'function') {
throw new Error('traces.js: global escapeHtml is missing — refusing to render unescaped input');
}
app.innerHTML = `
<div class="traces-page">
<div class="page-header">
<h2><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Packet Trace</h2>
</div>
<div class="trace-search">
<input type="text" id="traceHashInput" placeholder="Enter packet hash…" value="${escapeHtml(urlHash)}" aria-label="Packet hash to trace">
<button class="btn-primary" id="traceBtn">Trace</button>
</div>
<div id="traceResults"></div>
</div>`;
document.getElementById('traceBtn').addEventListener('click', doTrace);
document.getElementById('traceHashInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') doTrace();
});
if (urlHash) doTrace();
}
function destroy() {
currentHash = null;
traceData = [];
packetMeta = null;
}
function obsLabel(t) {
return t.observer_name || (t.observer && t.observer.length > 16 ? t.observer.slice(0, 12) + '…' : t.observer) || '—';
}
function obsLink(t) {
const label = escapeHtml(obsLabel(t));
if (!t.observer) return label;
return `<a href="#/observers/${encodeURIComponent(t.observer)}" style="color:var(--link-color);text-decoration:none;" title="${escapeHtml(t.observer)}">${label}</a>`;
}
async function doTrace() {
const input = document.getElementById('traceHashInput');
const hash = input.value.trim();
if (!hash) return;
currentHash = hash;
history.replaceState(null, '', `#/tools/trace/${encodeURIComponent(hash)}`);
const results = document.getElementById('traceResults');
results.innerHTML = '<div class="text-center text-muted" style="padding:40px">Tracing…</div>';
try {
const [traceResp, pktResp] = await Promise.all([
api(`/traces/${encodeURIComponent(hash)}`),
api(`/packets?hash=${encodeURIComponent(hash)}&limit=50`)
]);
traceData = traceResp.traces || [];
const packets = pktResp.packets || [];
if (traceData.length === 0 && packets.length === 0) {
results.innerHTML = '<div class="trace-empty">No observations found for this packet hash.</div>';
return;
}
// Extract unique paths from observations.
// Drop partial paths that are a prefix of a longer observed path — these are
// the same packet seen at intermediate relay nodes before it reached the final
// hop, not genuinely different routes. Keeping them creates confusing long
// "shortcut" edges in the path graph that visually obscure the actual route.
const allPathsRaw = [];
for (const t of traceData) {
try {
const hops = JSON.parse(t.path_json || '[]');
if (hops.length > 0) allPathsRaw.push({ hops, observer: obsLabel(t) });
} catch {}
}
const allPaths = dedupePrefixPaths(allPathsRaw);
// Fallback to packet-level path
if (allPaths.length === 0) {
for (const p of packets) {
try {
const hops = JSON.parse(p.path_json || '[]');
if (hops.length > 0) { allPaths.push({ hops, observer: 'packet' }); break; }
} catch {}
}
}
// Get packet type info from first packet
packetMeta = packets[0] || null;
let decoded = null;
if (packetMeta) {
try { decoded = JSON.parse(packetMeta.decoded_json); } catch {}
}
renderResults(results, allPaths, allPathsRaw, decoded);
} catch (e) {
results.innerHTML = `<div class="trace-empty" style="color:var(--danger)">Error: ${e.message}</div>`;
}
}
function renderResults(container, allPaths, allPathsRaw, decoded) {
const uniqueObservers = [...new Set(traceData.map(t => t.observer))];
const typeName = packetMeta ? payloadTypeName(packetMeta.payload_type) : '—';
const typeClass = packetMeta ? payloadTypeColor(packetMeta.payload_type) : 'unknown';
// Compute timing
let t0 = null, tLast = null;
if (traceData.length > 0) {
const times = traceData.map(t => new Date(t.time).getTime()).filter(t => !isNaN(t));
if (times.length) {
t0 = Math.min(...times);
tLast = Math.max(...times);
}
}
const spreadMs = (t0 !== null && tLast !== null) ? tLast - t0 : 0;
container.innerHTML = `
<div class="trace-summary">
<div class="trace-stat">
<div class="trace-stat-value">${uniqueObservers.length}</div>
<div class="trace-stat-label">Observers</div>
</div>
<div class="trace-stat">
<div class="trace-stat-value">${traceData.length}</div>
<div class="trace-stat-label">Observations</div>
</div>
<div class="trace-stat">
<div class="trace-stat-value">${spreadMs > 0 ? (spreadMs / 1000).toFixed(1) + 's' : '—'}</div>
<div class="trace-stat-label">Time Spread</div>
</div>
<div class="trace-stat">
<div class="trace-stat-value"><span class="badge badge-${typeClass}">${typeName}</span></div>
<div class="trace-stat-label">Packet Type</div>
</div>
</div>
${allPaths.length > 0 ? renderPathGraph(allPaths, allPathsRaw, decoded) : ''}
${traceData.length > 0 ? renderTimeline(t0, spreadMs) : ''}
`;
}
function renderPathGraph(allPaths, allPathsRaw, decoded) {
// Collect unique nodes and edges across all observed paths
const nodeSet = new Set();
const edgeMap = new Map(); // "from→to" => Set of observer labels
nodeSet.add('Origin');
nodeSet.add('Dest');
for (const { hops, observer } of allPaths) {
const chain = ['Origin', ...hops, 'Dest'];
for (let i = 0; i < chain.length - 1; i++) {
nodeSet.add(chain[i]);
nodeSet.add(chain[i + 1]);
const key = chain[i] + '→' + chain[i + 1];
if (!edgeMap.has(key)) edgeMap.set(key, new Set());
edgeMap.get(key).add(observer);
}
}
// Attribute prefix observers to the edges they witnessed.
// Prefix paths are dropped from allPaths to avoid spurious layout edges, but
// their observers still corroborated the shared prefix segment — credit them
// to the edges that exist in the full-path graph.
const allPathsSet = new Set(allPaths);
for (const entry of allPathsRaw) {
if (allPathsSet.has(entry)) continue;
const chain = ['Origin', ...entry.hops]; // no 'Dest': prefix stopped here
for (let i = 0; i < chain.length - 1; i++) {
const key = chain[i] + '→' + chain[i + 1];
if (edgeMap.has(key)) edgeMap.get(key).add(entry.observer);
}
}
const nodes = [...nodeSet];
// Assign positions: lay out nodes left to right by their earliest appearance in any path
const order = new Map();
order.set('Origin', 0);
let maxCol = 0;
for (const { hops } of allPaths) {
const chain = ['Origin', ...hops, 'Dest'];
for (let i = 0; i < chain.length; i++) {
if (!order.has(chain[i])) {
order.set(chain[i], i);
}
maxCol = Math.max(maxCol, i);
}
}
order.set('Dest', maxCol);
// Group nodes by column for vertical stacking
const colGroups = new Map();
for (const [node, col] of order) {
if (!colGroups.has(col)) colGroups.set(col, []);
colGroups.get(col).push(node);
}
const colCount = maxCol + 1;
const svgW = Math.max(600, colCount * 200);
const maxRows = Math.max(...[...colGroups.values()].map(g => g.length));
const svgH = Math.max(160, maxRows * 80 + 60);
const colSpacing = svgW / (colCount + 1);
// Compute node positions
const nodePos = new Map();
for (const [col, group] of colGroups) {
const rowSpacing = svgH / (group.length + 1);
group.forEach((node, i) => {
nodePos.set(node, { x: (col + 1) * colSpacing, y: (i + 1) * rowSpacing });
});
}
// Colors for edges (cycle through)
const edgeColors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
const observerColorMap = new Map();
let colorIdx = 0;
for (const obsSet of edgeMap.values()) {
for (const obs of obsSet) {
if (!observerColorMap.has(obs)) {
observerColorMap.set(obs, edgeColors[colorIdx % edgeColors.length]);
colorIdx++;
}
}
}
// Build SVG
let edgesSvg = '';
for (const [key, observers] of edgeMap) {
const [from, to] = key.split('→');
const p1 = nodePos.get(from);
const p2 = nodePos.get(to);
if (!p1 || !p2) continue;
const obsArr = [...observers];
const thickness = Math.min(obsArr.length, 6);
// Use first observer's color, show count as tooltip
const color = observerColorMap.get(obsArr[0]) || '#6b7280';
const title = obsArr.length > 1 ? `${obsArr.length} observers: ${obsArr.join(', ')}` : obsArr[0];
edgesSvg += `<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}" stroke="${color}" stroke-width="${thickness}" stroke-opacity="0.6"><title>${escapeHtml(title)}</title></line>`;
// Arrowhead
const angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
const arrowLen = 8;
const ax = p2.x - 20 * Math.cos(angle);
const ay = p2.y - 20 * Math.sin(angle);
const a1x = ax - arrowLen * Math.cos(angle - 0.4);
const a1y = ay - arrowLen * Math.sin(angle - 0.4);
const a2x = ax - arrowLen * Math.cos(angle + 0.4);
const a2y = ay - arrowLen * Math.sin(angle + 0.4);
edgesSvg += `<polygon points="${ax},${ay} ${a1x},${a1y} ${a2x},${a2y}" fill="${color}" opacity="0.8"/>`;
}
let nodesSvg = '';
// Per-hop SNR overlay: TRACE packets only, numeric labels at hop midpoints
// (Tufte: no double-encoding via color/thickness — number is the signal).
const snrValues = (decoded && decoded.type === 'TRACE' && Array.isArray(decoded.snrValues))
? decoded.snrValues : null;
if (snrValues && snrValues.length > 0) {
// Build the hop chain from the first observed full path (Origin → hops → Dest).
const firstPath = allPaths[0];
if (firstPath && Array.isArray(firstPath.hops)) {
const chain = ['Origin', ...firstPath.hops, 'Dest'];
const hopCount = Math.min(snrValues.length, chain.length - 1);
for (let i = 0; i < hopCount; i++) {
const p1 = nodePos.get(chain[i]);
const p2 = nodePos.get(chain[i + 1]);
if (!p1 || !p2) continue;
const v = snrValues[i];
if (v == null || !Number.isFinite(Number(v))) continue;
const mx = (p1.x + p2.x) / 2;
const my = (p1.y + p2.y) / 2 - 6;
const label = Number(v).toFixed(1);
nodesSvg += `<text class="hop-snr" x="${mx}" y="${my}" text-anchor="middle" font-size="10" font-weight="600" fill="var(--text, #111827)">${label}</text>`;
}
}
}
for (const [node, pos] of nodePos) {
const isEndpoint = node === 'Origin' || node === 'Dest';
const r = isEndpoint ? 18 : 16;
const fill = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--accent-bg, rgba(59,130,246,0.12))';
const stroke = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--accent, #3b82f6)';
nodesSvg += `<circle cx="${pos.x}" cy="${pos.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="2"/>`;
if (isEndpoint) {
nodesSvg += `<text x="${pos.x}" y="${pos.y + 4}" text-anchor="middle" fill="white" font-size="10" font-weight="700">${escapeHtml(node)}</text>`;
} else {
// Label below the circle so it doesn't fight for space inside the small node
nodesSvg += `<text x="${pos.x}" y="${pos.y + r + 14}" text-anchor="middle" fill="var(--text, #111827)" font-size="10" font-weight="500">${escapeHtml(node)}</text>`;
}
}
// Legend: unique paths
const uniquePaths = [...new Set(allPaths.map(p => p.hops.join('→')))];
const legendHtml = uniquePaths.length > 1
? `<div class="trace-path-info" style="margin-top:8px">${uniquePaths.length} unique path${uniquePaths.length > 1 ? 's' : ''} observed by ${allPaths.length} observer${allPaths.length > 1 ? 's' : ''}</div>`
: `<div class="trace-path-info">${allPaths[0].hops.length} hop${allPaths[0].hops.length !== 1 ? 's' : ''} in relay path</div>`;
return `
<div class="trace-section">
<h3>Path Graph</h3>
<div style="overflow-x:auto;">
<svg width="${svgW}" height="${svgH}" style="display:block;margin:0 auto;">
${edgesSvg}
${nodesSvg}
</svg>
</div>
${legendHtml}
</div>`;
}
function renderTimeline(t0, spreadMs) {
const barWidth = spreadMs > 0 ? spreadMs : 1;
const rows = traceData.map((t, i) => {
const time = new Date(t.time);
const offsetMs = t0 !== null ? time.getTime() - t0 : 0;
const pct = spreadMs > 0 ? (offsetMs / barWidth) * 100 : 50;
const snrClass = t.snr != null ? (t.snr >= 0 ? 'good' : t.snr >= -10 ? 'ok' : 'bad') : '';
const delta = spreadMs > 0 ? `+${(offsetMs / 1000).toFixed(3)}s` : '';
return `<div class="tl-row">
<div class="tl-observer">${obsLink(t)}</div>
<div class="tl-bar-container">
<div class="tl-marker" style="left:${pct}%" title="${time.toISOString()}"></div>
</div>
<div class="tl-delta mono">${delta}</div>
<div class="tl-snr ${snrClass}">${t.snr != null ? Number(t.snr).toFixed(1) + ' dB' : '—'}</div>
<div class="tl-rssi">${t.rssi != null ? Number(t.rssi).toFixed(0) + ' dBm' : '—'}</div>
</div>`;
});
return `
<div class="trace-section">
<h3>Propagation Timeline</h3>
<div class="tl-header">
<span>Observer</span><span>Time</span><span>Δ</span><span>SNR</span><span>RSSI</span>
</div>
${rows.join('')}
</div>`;
}
function dedupePrefixPaths(rawPaths) {
return rawPaths.filter(({ hops }) => {
const sig = JSON.stringify(hops);
return !rawPaths.some(other => {
if (other.hops.length <= hops.length) return false;
return JSON.stringify(other.hops.slice(0, hops.length)) === sig;
});
});
}
if (typeof window !== 'undefined') {
window.TracesHelpers = { dedupePrefixPaths, renderPathGraph };
}
registerPage('traces', { init, destroy });
})();