mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-13 15:51:37 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 293efdb647 | |||
| 97833c523b | |||
| 76e130b313 | |||
| 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
|
||||
|
||||
@@ -16,6 +16,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
actions: write # issue #1702: required so the fallback `gh workflow run deploy.yml` dispatch is allowed
|
||||
|
||||
concurrency:
|
||||
group: release-fast-path-${{ github.ref }}
|
||||
|
||||
@@ -38,8 +38,10 @@ func TestReleaseFastPathWorkflowExists(t *testing.T) {
|
||||
t.Errorf("release-fast-path.yml: missing required push.tags trigger 'v[0-9]+.[0-9]+.[0-9]+'")
|
||||
}
|
||||
|
||||
// Permissions: needs packages:write to re-tag in GHCR, contents:read for checkout.
|
||||
for _, perm := range []string{"packages: write", "contents: read"} {
|
||||
// Permissions: needs packages:write to re-tag in GHCR, contents:read for
|
||||
// checkout, and actions:write so the fallback `gh workflow run deploy.yml`
|
||||
// dispatch is allowed (issue #1702 — fallback returned 403 without it).
|
||||
for _, perm := range []string{"packages: write", "contents: read", "actions: write"} {
|
||||
if !strings.Contains(src, perm) {
|
||||
t.Errorf("release-fast-path.yml: missing required permission %q", perm)
|
||||
}
|
||||
|
||||
+38
-12
@@ -1238,11 +1238,8 @@ func (s *Server) handlePostPacket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
decodedJSON := PayloadJSON(&decoded.Payload)
|
||||
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
nowEpoch := time.Now().Unix()
|
||||
|
||||
var obsID, obsName interface{}
|
||||
if body.Observer != nil {
|
||||
obsID = *body.Observer
|
||||
}
|
||||
var snr, rssi interface{}
|
||||
if body.Snr != nil {
|
||||
snr = *body.Snr
|
||||
@@ -1251,17 +1248,46 @@ func (s *Server) handlePostPacket(w http.ResponseWriter, r *http.Request) {
|
||||
rssi = *body.Rssi
|
||||
}
|
||||
|
||||
res, dbErr := s.db.conn.Exec(`INSERT INTO transmissions (hash, raw_hex, route_type, payload_type, payload_version, path_json, decoded_json, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
// v3 schema (cmd/ingestor/db.go:251-303): transmissions no longer carries
|
||||
// path_json (it lives on observations now), observations uses observer_idx
|
||||
// INTEGER (FK observers.rowid) and timestamp INTEGER (unix epoch).
|
||||
// Fix for #1196 — pre-fix code wrote v2 column names and silently
|
||||
// swallowed the observations insert error.
|
||||
res, dbErr := s.db.conn.Exec(`INSERT INTO transmissions (hash, raw_hex, route_type, payload_type, payload_version, decoded_json, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
contentHash, strings.ToUpper(hexStr), decoded.Header.RouteType, decoded.Header.PayloadType,
|
||||
decoded.Header.PayloadVersion, pathJSON, decodedJSON, now)
|
||||
decoded.Header.PayloadVersion, decodedJSON, now)
|
||||
if dbErr != nil {
|
||||
writeError(w, 500, "transmission insert: "+dbErr.Error())
|
||||
return
|
||||
}
|
||||
insertedID, _ := res.LastInsertId()
|
||||
|
||||
var insertedID int64
|
||||
if dbErr == nil {
|
||||
insertedID, _ = res.LastInsertId()
|
||||
s.db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, timestamp)
|
||||
// Resolve observer string → observers.rowid. INSERT OR IGNORE then SELECT
|
||||
// mirrors the ingestor's resolver (cmd/ingestor/db.go:778,799,906).
|
||||
var observerIdx interface{}
|
||||
if body.Observer != nil && *body.Observer != "" {
|
||||
obsID := *body.Observer
|
||||
if _, err := s.db.conn.Exec(
|
||||
`INSERT OR IGNORE INTO observers (id, name, last_seen, first_seen) VALUES (?, ?, ?, ?)`,
|
||||
obsID, obsID, now, now); err != nil {
|
||||
writeError(w, 500, "observer upsert: "+err.Error())
|
||||
return
|
||||
}
|
||||
var rowid int64
|
||||
if err := s.db.conn.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, obsID).Scan(&rowid); err != nil {
|
||||
writeError(w, 500, "observer lookup: "+err.Error())
|
||||
return
|
||||
}
|
||||
observerIdx = rowid
|
||||
}
|
||||
|
||||
if _, obsErr := s.db.conn.Exec(
|
||||
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
insertedID, obsID, obsName, snr, rssi, now)
|
||||
insertedID, observerIdx, snr, rssi, pathJSON, nowEpoch); obsErr != nil {
|
||||
writeError(w, 500, "observation insert: "+obsErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, PacketIngestResponse{
|
||||
|
||||
@@ -4718,3 +4718,69 @@ func TestListLimitsConfigurable(t *testing.T) {
|
||||
t.Errorf("expected limit to be capped at 1234, got %v", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostPacketPersistsV3Schema is the round-trip regression for #1196.
|
||||
// POST /api/packets must write the observation row using the v3 schema
|
||||
// (observer_idx INTEGER, timestamp INTEGER) and surface insert errors.
|
||||
// The pre-fix handler writes v2 columns (observer_id, observer_name,
|
||||
// RFC3339 timestamp) and silently swallows the obs insert error.
|
||||
func TestPostPacketPersistsV3Schema(t *testing.T) {
|
||||
const apiKey = "test-secret-key-strong-enough"
|
||||
srv, router := setupTestServerWithAPIKey(t, apiKey)
|
||||
|
||||
// FLOOD/ADVERT hex (header 0x11, path byte 0x00, payload bytes).
|
||||
// Mirrors TestDecodePacket_FloodHasNoCodes.
|
||||
const rawHex = "110011223344556677889900AABBCCDD"
|
||||
bodyJSON := `{"hex":"` + rawHex + `","observer":"obs1","snr":5.5,"rssi":-72}`
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/packets",
|
||||
bytes.NewReader([]byte(bodyJSON)))
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST /api/packets: expected 200, got %d (body: %s)",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
idF, _ := resp["id"].(float64)
|
||||
txID := int64(idF)
|
||||
if txID <= 0 {
|
||||
t.Fatalf("expected transmission id > 0, got %v (body: %s)",
|
||||
resp["id"], w.Body.String())
|
||||
}
|
||||
|
||||
// Resolve expected observer_idx from the seeded observers table.
|
||||
var wantIdx int64
|
||||
if err := srv.db.conn.QueryRow(
|
||||
"SELECT rowid FROM observers WHERE id = ?", "obs1",
|
||||
).Scan(&wantIdx); err != nil {
|
||||
t.Fatalf("lookup observer rowid: %v", err)
|
||||
}
|
||||
|
||||
// Assert the observation row was written with v3 columns.
|
||||
var (
|
||||
gotIdx int64
|
||||
gotTS int64
|
||||
)
|
||||
err := srv.db.conn.QueryRow(
|
||||
"SELECT observer_idx, timestamp FROM observations WHERE transmission_id = ?",
|
||||
txID,
|
||||
).Scan(&gotIdx, &gotTS)
|
||||
if err != nil {
|
||||
t.Fatalf("observation row missing for tx %d: %v (handler swallowed insert error?)", txID, err)
|
||||
}
|
||||
if gotIdx != wantIdx {
|
||||
t.Errorf("observer_idx: want %d, got %d", wantIdx, gotIdx)
|
||||
}
|
||||
nowSec := time.Now().Unix()
|
||||
if gotTS < nowSec-60 || gotTS > nowSec+60 {
|
||||
t.Errorf("timestamp: want unix int near %d, got %d", nowSec, gotTS)
|
||||
}
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
+14
-2
@@ -2997,8 +2997,20 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.subpath-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 12px; color: var(--text-muted); font-size: 0.9em; }
|
||||
.subpath-section { margin: 16px 0; }
|
||||
.subpath-section h5 { margin: 0 0 6px; font-size: 0.9em; }
|
||||
.subpath-selected { background: var(--accent, #3b82f6) !important; color: #fff; }
|
||||
.subpath-selected .hop-prefix { color: rgba(255,255,255,0.6); }
|
||||
/* #1705 — was background:var(--accent) (#4a9eff) + color:#fff = 2.75:1, and
|
||||
the muted hop-prefix line at rgba(255,255,255,0.6) composited over that
|
||||
accent to 1.87:1 (hard BLOCKER). Pin the row to --accent-strong
|
||||
(#2563eb, palette-blue-600) with --text-on-accent (#f9fafb): primary
|
||||
text 4.95:1, hop-prefix at --text-on-accent (full alpha) on the same
|
||||
bg = 4.95:1 — clears WCAG AA (≥4.5:1). The hop-prefix used to be
|
||||
visually "muted" via 60% alpha white; we drop the alpha-muting because
|
||||
no other token in either theme composites to ≥4.5:1 on this background.
|
||||
Trade-off accepted: the prefix and primary line are now equal-weight
|
||||
visually — the previous secondary styling was itself the source of the
|
||||
1.87:1 BLOCKER, so visual hierarchy here is recovered via type/weight,
|
||||
not color. */
|
||||
.subpath-selected { background: var(--accent-strong) !important; color: var(--text-on-accent); }
|
||||
.subpath-selected .hop-prefix { color: var(--text-on-accent); }
|
||||
tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* Hour distribution chart */
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test-a11y-1705-subpath-hop-prefix-e2e.js — Issue #1705 regression gate.
|
||||
*
|
||||
* Asserts that `.subpath-selected .hop-prefix` (the secondary line of a
|
||||
* subpath-detail table row in /#/analytics?tab=subpaths) meets WCAG AA
|
||||
* color-contrast (≥ 4.5:1) in BOTH dark and light themes.
|
||||
*
|
||||
* Why a dedicated test (the umbrella test-a11y-axe-1668.js does NOT catch this):
|
||||
* The umbrella axe gate scans every page in its initial paint. The
|
||||
* `.subpath-selected` class is only applied AFTER a user clicks a row,
|
||||
* so axe never sees it during the umbrella run. Issue #1705 is the
|
||||
* poster child for "state-only" a11y regressions slipping through.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Boot any page (we just need the production CSS loaded).
|
||||
* 2. Inject a minimal fragment carrying the production class names
|
||||
* (`.subpath-selected` + `.hop-prefix`).
|
||||
* 3. Read the BROWSER-RESOLVED RGB of:
|
||||
* - the row background (`.subpath-selected`)
|
||||
* - the prefix text (`.subpath-selected .hop-prefix`)
|
||||
* per theme.
|
||||
* 4. Composite the text color over the background (alpha-aware) and
|
||||
* compute the WCAG contrast ratio in Node. Assert >= 4.5:1.
|
||||
*
|
||||
* The contrast math is the same formula axe-core uses (sRGB → relative
|
||||
* luminance, then (L1+0.05)/(L2+0.05)). We do the composite locally
|
||||
* because the original BLOCKER (rgba(255,255,255,0.6) on --accent) is
|
||||
* exactly the case axe's color-contrast rule mis-evaluates without
|
||||
* a real composite step.
|
||||
*
|
||||
* We deliberately do NOT depend on @axe-core/playwright invoking
|
||||
* `axe.run` against the subpath state — color-contrast rule on a
|
||||
* rgba text color over a CSS-var background is the rule's known
|
||||
* weak spot (see issue body, "audit probe correctness fix"). A direct
|
||||
* composite-then-contrast computation is the authoritative regression
|
||||
* signal for this BLOCKER.
|
||||
*
|
||||
* Usage:
|
||||
* node test-a11y-1705-subpath-hop-prefix-e2e.js
|
||||
*
|
||||
* Env:
|
||||
* STYLE_CSS_PATH optional override; defaults to public/style.css next
|
||||
* to this script (same checkout the production server
|
||||
* serves from).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STYLE_CSS_PATH = process.env.STYLE_CSS_PATH || path.join(__dirname, 'public', 'style.css');
|
||||
const THEMES = ['dark', 'light'];
|
||||
|
||||
// -------------------- Pure helpers (unit-testable) --------------------
|
||||
|
||||
// Parse a CSS color string into {r,g,b,a} (0-255 channels, 0-1 alpha).
|
||||
// Handles #rgb, #rrggbb, rgb(...), rgba(...).
|
||||
function parseColor(s) {
|
||||
if (!s) return null;
|
||||
s = String(s).trim();
|
||||
let m = s.match(/^#([0-9a-f]{3})$/i);
|
||||
if (m) {
|
||||
const h = m[1];
|
||||
return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 };
|
||||
}
|
||||
m = s.match(/^#([0-9a-f]{6})$/i);
|
||||
if (m) {
|
||||
const h = m[1];
|
||||
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 };
|
||||
}
|
||||
m = s.match(/^rgba?\(([^)]+)\)$/i);
|
||||
if (m) {
|
||||
const parts = m[1].split(/[ ,/]+/).filter(Boolean).map(Number);
|
||||
if (parts.length >= 3) {
|
||||
return {
|
||||
r: parts[0],
|
||||
g: parts[1],
|
||||
b: parts[2],
|
||||
a: parts.length >= 4 && Number.isFinite(parts[3]) ? parts[3] : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Composite `fg` (rgba) over `bg` (assumed opaque rgba) → opaque rgba.
|
||||
function composite(fg, bg) {
|
||||
const a = fg.a;
|
||||
return {
|
||||
r: Math.round(fg.r * a + bg.r * (1 - a)),
|
||||
g: Math.round(fg.g * a + bg.g * (1 - a)),
|
||||
b: Math.round(fg.b * a + bg.b * (1 - a)),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// sRGB channel → linear (per WCAG 2.x).
|
||||
function srgbToLin(c) {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function relLuminance(rgb) {
|
||||
return 0.2126 * srgbToLin(rgb.r) + 0.7152 * srgbToLin(rgb.g) + 0.0722 * srgbToLin(rgb.b);
|
||||
}
|
||||
|
||||
// WCAG contrast ratio.
|
||||
function contrastRatio(rgb1, rgb2) {
|
||||
const L1 = relLuminance(rgb1);
|
||||
const L2 = relLuminance(rgb2);
|
||||
const [lo, hi] = L1 < L2 ? [L1, L2] : [L2, L1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
// Compose-then-contrast: text (may be rgba) over background (assumed opaque).
|
||||
function compositeContrast(textRgba, bgRgb) {
|
||||
const eff = textRgba.a < 1 ? composite(textRgba, bgRgb) : textRgba;
|
||||
return contrastRatio(eff, bgRgb);
|
||||
}
|
||||
|
||||
// Read CSS file synchronously.
|
||||
function readCss(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`a11y-1705: CSS file not found: ${filePath}`);
|
||||
}
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
// Extract every top-level block matching `selector { ... }`. Brace-balanced
|
||||
// so nested `@media (...) { :root { ... } }` doesn't break the scan.
|
||||
// Returns an array of body strings (the contents between matching braces).
|
||||
function extractBlocks(css, selectorPattern) {
|
||||
const re = new RegExp(`(?:^|[^a-zA-Z0-9_-])(${selectorPattern})\\s*\\{`, 'g');
|
||||
const blocks = [];
|
||||
let m;
|
||||
while ((m = re.exec(css))) {
|
||||
const start = m.index + m[0].length;
|
||||
let depth = 1;
|
||||
let i = start;
|
||||
while (i < css.length && depth > 0) {
|
||||
const ch = css[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
if (depth === 0) blocks.push(css.slice(start, i - 1));
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function inBlock(block, varName) {
|
||||
const re = new RegExp(`--${varName}\\s*:\\s*([^;\\n]+);`);
|
||||
const m = block.match(re);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
// Search across an ordered list of blocks; last writer wins (matches CSS cascade).
|
||||
function lookupVar(blocks, varName) {
|
||||
let val = null;
|
||||
for (const b of blocks) {
|
||||
const v = inBlock(b, varName);
|
||||
if (v) val = v;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
function extractTokensFromCss(css) {
|
||||
// Collect all :root and [data-theme="dark"] blocks (style.css has multiple
|
||||
// :root blocks — one for the palette layer, one inside the @media dark
|
||||
// media query, etc.).
|
||||
const rootBlocks = extractBlocks(css, ':root');
|
||||
const darkBlocks = extractBlocks(css, '\\[data-theme="dark"\\]');
|
||||
if (rootBlocks.length === 0) throw new Error('a11y-1705: no :root blocks found in style.css');
|
||||
if (darkBlocks.length === 0) throw new Error('a11y-1705: no [data-theme="dark"] blocks found in style.css');
|
||||
|
||||
// Resolve a var(...) reference recursively against (theme blocks first, then root blocks).
|
||||
function resolveValue(raw, themeBlocks) {
|
||||
raw = String(raw).trim().replace(/\s*!important\s*$/, '').trim();
|
||||
const v = raw.match(/^var\(\s*--([a-z0-9-]+)\s*(?:,\s*(.+))?\)$/i);
|
||||
if (!v) return raw;
|
||||
const name = v[1];
|
||||
const fallback = v[2];
|
||||
// Theme overrides win over :root.
|
||||
const fromTheme = lookupVar(themeBlocks, name);
|
||||
if (fromTheme) return resolveValue(fromTheme, themeBlocks);
|
||||
const fromRoot = lookupVar(rootBlocks, name);
|
||||
if (fromRoot) return resolveValue(fromRoot, themeBlocks);
|
||||
if (fallback) return resolveValue(fallback.trim(), themeBlocks);
|
||||
throw new Error(`a11y-1705: could not resolve var(--${name})`);
|
||||
}
|
||||
|
||||
// .subpath-selected { background: ...; ... }
|
||||
const selBlocks = extractBlocks(css, '\\.subpath-selected');
|
||||
if (selBlocks.length === 0) throw new Error('a11y-1705: .subpath-selected rule missing');
|
||||
// Pick the block that actually declares `background:` (first one wins).
|
||||
const bgRaw = (() => {
|
||||
for (const b of selBlocks) {
|
||||
const m = b.match(/background\s*:\s*([^;]+);/);
|
||||
if (m) return m[1].trim();
|
||||
}
|
||||
throw new Error('a11y-1705: .subpath-selected has no background declaration');
|
||||
})();
|
||||
|
||||
// .subpath-selected { color: ...; } — primary row text. Asserted separately
|
||||
// from the .hop-prefix child rule so a future token swap on either the
|
||||
// parent OR the child trips this gate (parent regression slipped past
|
||||
// earlier audits because only the child was probed).
|
||||
const primaryColorRaw = (() => {
|
||||
for (const b of selBlocks) {
|
||||
const m = b.match(/(?:^|[^-\w])color\s*:\s*([^;]+);/);
|
||||
if (m) return m[1].trim();
|
||||
}
|
||||
throw new Error('a11y-1705: .subpath-selected has no color declaration');
|
||||
})();
|
||||
|
||||
// .subpath-selected .hop-prefix { color: ...; }
|
||||
const prefixBlocks = extractBlocks(css, '\\.subpath-selected\\s+\\.hop-prefix');
|
||||
if (prefixBlocks.length === 0) throw new Error('a11y-1705: .subpath-selected .hop-prefix rule missing');
|
||||
const colorRaw = (() => {
|
||||
for (const b of prefixBlocks) {
|
||||
const m = b.match(/color\s*:\s*([^;]+);/);
|
||||
if (m) return m[1].trim();
|
||||
}
|
||||
throw new Error('a11y-1705: .subpath-selected .hop-prefix has no color declaration');
|
||||
})();
|
||||
|
||||
return {
|
||||
light: {
|
||||
bg: resolveValue(bgRaw, []), // light → only :root
|
||||
text: resolveValue(colorRaw, []),
|
||||
primaryText: resolveValue(primaryColorRaw, []),
|
||||
bgRaw, colorRaw, primaryColorRaw,
|
||||
},
|
||||
dark: {
|
||||
bg: resolveValue(bgRaw, darkBlocks),
|
||||
text: resolveValue(colorRaw, darkBlocks),
|
||||
primaryText: resolveValue(primaryColorRaw, darkBlocks),
|
||||
bgRaw, colorRaw, primaryColorRaw,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------- Main: CSS-driven assertion --------------------
|
||||
|
||||
async function main() {
|
||||
console.log(`a11y-1705: reading ${STYLE_CSS_PATH}`);
|
||||
const css = readCss(STYLE_CSS_PATH);
|
||||
|
||||
const tokens = extractTokensFromCss(css);
|
||||
let failures = 0;
|
||||
|
||||
for (const theme of THEMES) {
|
||||
const { bg: bgStr, text: textStr, primaryText: primaryStr } = tokens[theme];
|
||||
const bg = parseColor(bgStr);
|
||||
const text = parseColor(textStr);
|
||||
const primary = parseColor(primaryStr);
|
||||
if (!bg) throw new Error(`a11y-1705: unparsable bg "${bgStr}" for theme=${theme}`);
|
||||
if (!text) throw new Error(`a11y-1705: unparsable text "${textStr}" for theme=${theme}`);
|
||||
if (!primary) throw new Error(`a11y-1705: unparsable primary "${primaryStr}" for theme=${theme}`);
|
||||
|
||||
// 1. .subpath-selected .hop-prefix (the original BLOCKER surface).
|
||||
const ratio = compositeContrast(text, bg);
|
||||
const ok = ratio >= 4.5;
|
||||
console.log(
|
||||
` ${ok ? 'PASS' : 'FAIL'} theme=${theme} [hop-prefix] bg=${bgStr} text=${textStr} composite=${JSON.stringify(text.a < 1 ? composite(text, bg) : text)} ratio=${ratio.toFixed(2)}:1 (need ≥4.5:1)`
|
||||
);
|
||||
if (!ok) failures++;
|
||||
|
||||
// 2. .subpath-selected primary row text — guards against the parent
|
||||
// rule regressing independently of the child (e.g. someone swaps
|
||||
// `color: var(--text-on-accent)` back to `#fff` without touching
|
||||
// the .hop-prefix line). #1705 review-r1 must-fix.
|
||||
const ratioPrimary = compositeContrast(primary, bg);
|
||||
const okPrimary = ratioPrimary >= 4.5;
|
||||
console.log(
|
||||
` ${okPrimary ? 'PASS' : 'FAIL'} theme=${theme} [primary] bg=${bgStr} text=${primaryStr} composite=${JSON.stringify(primary.a < 1 ? composite(primary, bg) : primary)} ratio=${ratioPrimary.toFixed(2)}:1 (need ≥4.5:1)`
|
||||
);
|
||||
if (!okPrimary) failures++;
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\nFAIL: .subpath-selected text violates WCAG AA color-contrast in ${failures} probe(s) (issue #1705)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nPASS: .subpath-selected primary + .hop-prefix ≥4.5:1 in dark + light themes (issue #1705)`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error('test-a11y-1705 fatal:', err && err.stack || err);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseColor,
|
||||
composite,
|
||||
contrastRatio,
|
||||
compositeContrast,
|
||||
extractTokensFromCss,
|
||||
};
|
||||
@@ -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