From 0c3832c79c551d750241a653e572e88874c9abf0 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 10:10:28 +0200 Subject: [PATCH 01/13] test: run every test-all.sh file and print a summary (#174) test-all.sh used `set -e`, so it stopped at the first failing file and the rest never ran. Each file now runs through run(): failures are counted, the summary lists every failed file, and the script exits 1 if any file failed. - register test-live-anims.js (fixed in #177, never registered) - test-test-all.js: runs the real test-all.sh under /bin/sh and dash with fixture files (a failure does not stop the run, is listed, exit is non-zero; all green exits 0) and checks the file list (exists, no duplicates, no Playwright/BASE_URL files) - test-privacy-page.js asserted the literal `set -e`; it now asserts the `run` registration line and the exit-1 path Co-Authored-By: Claude Opus 5.5 --- test-all.sh | 248 ++++++++++++++++++++++++------------------- test-privacy-page.js | 6 +- test-test-all.js | 127 ++++++++++++++++++++++ 3 files changed, 271 insertions(+), 110 deletions(-) create mode 100644 test-test-all.js diff --git a/test-all.sh b/test-all.sh index 181872c38..985f50418 100755 --- a/test-all.sh +++ b/test-all.sh @@ -1,125 +1,157 @@ #!/bin/sh -# Run all tests with coverage -set -e +# Run every unit test file, then print a summary. +# +# A failing file does not stop the run: every file runs, the failures are +# listed at the end, and the script exits 1 if any file failed. +# Only files that run with plain `node` and no server belong here. E2E files +# run in the Playwright step of .github/workflows/deploy.yml. + +passed=0 +failed=0 +failed_files= + +run() { + echo "" + echo "── $1" + if node "$1"; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + failed_files="$failed_files $1" + fi +} echo "═══════════════════════════════════════" echo " CoreScope — Test Suite" echo "═══════════════════════════════════════" -echo "" # Unit tests (deterministic, fast) +echo "" echo "── Unit Tests ──" -node test-packet-filter.js -node test-packet-filter-ux.js -node test-issue-121-clear-filters-selection.js -node test-clear-filters.js -node test-aging.js -node test-issue-1065-gesture-hints-gates.js -node test-frontend-helpers.js -node test-app-api-inflight-cleanup-rejection.js -node test-issue-120-distance-building.js -node test-privacy-page.js -node test-nav-dynamic-link-lifecycle.js -node test-nav-first-load-fit.js -node test-nav-priority-scheduler.js -node test-geo-filter.js -node test-fetch-all-nodes-pagination.js -node test-nodes-geo-scope-filter.js -node test-nodes-export.js -node test-nodes-export-wiring.js -node test-nodes-export-order.js -node test-nodes-export-narrow-wait.js -node test-my-repeaters-dashboard.js -node test-repeater-metric-scatter.js -node test-top-routes-overlay.js -node test-important-links-byte-filter.js -node test-url-state.js -node test-node-adverts.js -node test-perf-go-runtime.js -node test-channel-psk-ux.js -node test-channel-sidebar-layout.js -node test-channel-fluid-layout.js -node test-channel-modal-ux.js -node test-channel-decrypt-insecure-context.js -node test-channel-qr.js -node test-channel-qr-wiring.js -node test-channel-issue-1087.js -node test-issue-1409-no-encrypted-flood.js -node test-analytics-channels-integration.js -node test-analytics-foreign-traffic-tab.js -node test-analytics-nodes-without-scope.js -node test-analytics-table-ids-unique.js -node test-analytics-relay-airtime-dumbbell.js -node test-live-vcr-mode-contrast.js -node test-observers-headings.js -node test-issue-1789-observer-firmware-cols.js -node test-issue-1648-m1-emoji-scan.js -node test-issue-1648-m2-emoji-scan.js -node test-issue-1648-m3-emoji-scan.js -node test-issue-1648-m6-final-sweep.js -node test-issue-1648-m6-lint-self.js -node test-issue-1883-redirect-history.js -node test-issue-1890-og-url.js -node test-traces.js -node test-live-multibyte-filter.js -node test-issue-124-rx-coverage-viewport.js -node test-rx-coverage-escape.js -node test-issue-125-live-toggles-wiring.js -node test-issue-2052-touch-target-css.js -node test-channel-proposals.js -node test-packet-detail-channel-xss.js -node test-channels-name-escaping.js +run test-packet-filter.js +run test-packet-filter-ux.js +run test-issue-121-clear-filters-selection.js +run test-clear-filters.js +run test-aging.js +run test-issue-1065-gesture-hints-gates.js +run test-frontend-helpers.js +run test-app-api-inflight-cleanup-rejection.js +run test-issue-120-distance-building.js +run test-privacy-page.js +run test-nav-dynamic-link-lifecycle.js +run test-nav-first-load-fit.js +run test-nav-priority-scheduler.js +run test-geo-filter.js +run test-fetch-all-nodes-pagination.js +run test-nodes-geo-scope-filter.js +run test-nodes-export.js +run test-nodes-export-wiring.js +run test-nodes-export-order.js +run test-nodes-export-narrow-wait.js +run test-my-repeaters-dashboard.js +run test-repeater-metric-scatter.js +run test-top-routes-overlay.js +run test-important-links-byte-filter.js +run test-url-state.js +run test-node-adverts.js +run test-perf-go-runtime.js +run test-channel-psk-ux.js +run test-channel-sidebar-layout.js +run test-channel-fluid-layout.js +run test-channel-modal-ux.js +run test-channel-decrypt-insecure-context.js +run test-channel-qr.js +run test-channel-qr-wiring.js +run test-channel-issue-1087.js +run test-issue-1409-no-encrypted-flood.js +run test-analytics-channels-integration.js +run test-analytics-foreign-traffic-tab.js +run test-analytics-nodes-without-scope.js +run test-analytics-table-ids-unique.js +run test-analytics-relay-airtime-dumbbell.js +run test-live-vcr-mode-contrast.js +run test-observers-headings.js +run test-issue-1789-observer-firmware-cols.js +run test-issue-1648-m1-emoji-scan.js +run test-issue-1648-m2-emoji-scan.js +run test-issue-1648-m3-emoji-scan.js +run test-issue-1648-m6-final-sweep.js +run test-issue-1648-m6-lint-self.js +run test-issue-1883-redirect-history.js +run test-issue-1890-og-url.js +run test-traces.js +run test-live-multibyte-filter.js +run test-issue-124-rx-coverage-viewport.js +run test-rx-coverage-escape.js +run test-issue-125-live-toggles-wiring.js +run test-issue-2052-touch-target-css.js +run test-channel-proposals.js +run test-packet-detail-channel-xss.js +run test-channels-name-escaping.js # #1418 — route-view v2 (Tufte) coverage -node test-issue-1418-raw-hex-extraction.js -node test-issue-1418-edge-weights.js -node test-issue-1418-cb-preset-ramp.js -node test-issue-1418-spider-fan.js -node test-issue-1418-deeplink-hops-channels.js -node test-issue-1418-polish-review.js -node test-issue-1420-tile-providers.js -node test-issue-1614-tile-url-function.js -node test-issue-1438-marker-css-vars.js -node test-issue-1438-customizer-mcrole.js -node test-issue-1446-cb-preset-cascade.js -node test-issue-1380-cb-sim-overlay.js -node test-issue-1380-cb-reset-button.js -node test-issue-1450-logo-aspect.js -node test-issue-1454-channels-toggle.js -node test-issue-1456-score-labels.js +run test-issue-1418-raw-hex-extraction.js +run test-issue-1418-edge-weights.js +run test-issue-1418-cb-preset-ramp.js +run test-issue-1418-spider-fan.js +run test-issue-1418-deeplink-hops-channels.js +run test-issue-1418-polish-review.js +run test-issue-1420-tile-providers.js +run test-issue-1614-tile-url-function.js +run test-issue-1438-marker-css-vars.js +run test-issue-1438-customizer-mcrole.js +run test-issue-1446-cb-preset-cascade.js +run test-issue-1380-cb-sim-overlay.js +run test-issue-1380-cb-reset-button.js +run test-issue-1450-logo-aspect.js +run test-issue-1454-channels-toggle.js +run test-issue-1456-score-labels.js # #1461 mobile UX overhaul + #1470 node-detail tile helper (#1468 covered by E2E) -node test-issue-1461-mobile-page-actions.js -node test-issue-1470-node-tile-helper.js -node test-issue-1485-live-anim-z.js -node test-issue-1532-live-fullscreen.js -node test-issue-1833-legend-toggle-vcr-offset.js -node test-naive-banner-tone.js -node test-issue-1473-reserved-prefixes.js -node test-issue-1473-prefix-generator.js -node test-issue-1770-mobile-row-clamp.js -node test-issue-1849-trace-hashbytes.js -node test-node-analytics-hop-chart.js -node test-analytics-hop-depth-ui.js -node test-channels-ping-bot-reply.js -node test-channels-observed-path-hash-size.js -node test-packet-path-map.js -node test-area-nodes-map.js -node test-ping-scores.js -node test-observer-neighbors-report-badge.js -node test-observer-direct-neighbors-panel.js -node test-analytics-areas-tab.js -node test-observer-neighbors-tool.js -node test-new-nodes-tool.js -node test-node-changes-tool.js -node test-network-digest-tool.js -node test-position-gaps-tool.js -node test-gps-sanity-tool.js -node test-map-scope-filter.js -node test-issue-117-ws-watchdog.js -node test-issue-111-drawer-version.js +run test-issue-1461-mobile-page-actions.js +run test-issue-1470-node-tile-helper.js +run test-issue-1485-live-anim-z.js +run test-live-anims.js +run test-issue-1532-live-fullscreen.js +run test-issue-1833-legend-toggle-vcr-offset.js +run test-naive-banner-tone.js +run test-issue-1473-reserved-prefixes.js +run test-issue-1473-prefix-generator.js +run test-issue-1770-mobile-row-clamp.js +run test-issue-1849-trace-hashbytes.js +run test-node-analytics-hop-chart.js +run test-analytics-hop-depth-ui.js +run test-channels-ping-bot-reply.js +run test-channels-observed-path-hash-size.js +run test-packet-path-map.js +run test-area-nodes-map.js +run test-ping-scores.js +run test-observer-neighbors-report-badge.js +run test-observer-direct-neighbors-panel.js +run test-analytics-areas-tab.js +run test-observer-neighbors-tool.js +run test-new-nodes-tool.js +run test-node-changes-tool.js +run test-network-digest-tool.js +run test-position-gaps-tool.js +run test-gps-sanity-tool.js +run test-map-scope-filter.js +run test-issue-117-ws-watchdog.js +run test-issue-111-drawer-version.js + +# test-all.sh self-test (#174) +run test-test-all.js echo "" echo "═══════════════════════════════════════" +echo " $passed passed, $failed failed ($((passed + failed)) files)" +if [ "$failed" -gt 0 ]; then + echo " Failed:" + for f in $failed_files; do + echo " $f" + done + echo "═══════════════════════════════════════" + exit 1 +fi echo " All tests passed" echo "═══════════════════════════════════════" diff --git a/test-privacy-page.js b/test-privacy-page.js index dd69d0598..3de48b1e0 100644 --- a/test-privacy-page.js +++ b/test-privacy-page.js @@ -736,10 +736,12 @@ const sheetLinks = (doc) => doc.querySelectorAll('[data-bottom-nav-more-route]') await test('test-privacy-page.js is registered exactly once in test-all.sh', async () => { const sh = fs.readFileSync('test-all.sh', 'utf8'); - const lines = sh.split('\n').filter((l) => l.trim() === 'node test-privacy-page.js'); + const lines = sh.split('\n').filter((l) => l.trim() === 'run test-privacy-page.js'); assert.strictEqual(lines.length, 1, 'expected exactly one registration line in test-all.sh, got ' + lines.length); - assert(/^set -e$/m.test(sh), 'test-all.sh must keep its set -e semantics'); + // A failing file must still fail the suite (test-test-all.js checks this end to end). + assert(/^if \[ "\$failed" -gt 0 \]; then$/m.test(sh) && /^ exit 1$/m.test(sh), + 'test-all.sh must exit non-zero when a file fails'); }); console.log(`\n${passed} passed, ${failed} failed`); diff --git a/test-test-all.js b/test-test-all.js new file mode 100644 index 000000000..b2314685e --- /dev/null +++ b/test-test-all.js @@ -0,0 +1,127 @@ +/* test-test-all.js (#174) + * + * Self-test for test-all.sh, the unit suite CI runs. + * + * 1. Behaviour: a failing file does not stop the run, the summary lists every + * failed file, and the exit code is non-zero. The real test-all.sh is run + * under /bin/sh (and dash when present) in a temp dir, with its file list + * swapped for small fixture files. + * 2. File list: every entry exists, appears once and runs without a server + * or a browser (E2E files belong in the Playwright step of deploy.yml). + */ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const ROOT = __dirname; +const SRC = fs.readFileSync(path.join(ROOT, 'test-all.sh'), 'utf8'); +const RUN_LINE = /^run (\S+)$/; + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +function registeredFiles() { + return SRC.split('\n').map((l) => RUN_LINE.exec(l)).filter(Boolean).map((m) => m[1]); +} + +// test-all.sh with its file list replaced by `files`. +function scriptWith(files) { + const out = []; + let inserted = false; + for (const line of SRC.split('\n')) { + if (!RUN_LINE.test(line)) { out.push(line); continue; } + if (!inserted) { files.forEach((f) => out.push('run ' + f)); inserted = true; } + } + assert(inserted, 'test-all.sh has no `run ` lines'); + return out.join('\n'); +} + +const FIXTURES = { + 'pass-a.js': "console.log('pass-a ran');\n", + 'fail-b.js': "console.log('fail-b ran'); process.exit(3);\n", + 'throw-c.js': "throw new Error('boom');\n", + 'pass-d.js': "console.log('pass-d ran');\n", +}; + +function runSuite(shell, files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-all-')); + try { + for (const [name, body] of Object.entries(FIXTURES)) fs.writeFileSync(path.join(dir, name), body); + fs.writeFileSync(path.join(dir, 'test-all.sh'), scriptWith(files)); + const r = spawnSync(shell, ['test-all.sh'], { cwd: dir, encoding: 'utf8' }); + return { status: r.status, out: r.stdout + r.stderr }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// The indented lines under "Failed:" in the summary. +function failedList(out) { + const lines = out.split('\n'); + const i = lines.findIndex((l) => l.trim() === 'Failed:'); + if (i < 0) return []; + const list = []; + for (let j = i + 1; j < lines.length && /^ {4}\S/.test(lines[j]); j++) list.push(lines[j].trim()); + return list; +} + +const shells = []; +for (const s of ['/bin/sh', '/bin/dash', '/usr/bin/dash']) { + if (!fs.existsSync(s)) continue; + const real = fs.realpathSync(s); + if (!shells.some((x) => x.real === real)) shells.push({ path: s, real }); +} + +console.log('test-all.sh behaviour'); +for (const { path: sh } of shells) { + test(`${sh}: failures do not stop the run, are listed, and exit non-zero`, () => { + const r = runSuite(sh, ['pass-a.js', 'fail-b.js', 'throw-c.js', 'missing-e.js', 'pass-d.js']); + assert.notStrictEqual(r.status, 0, 'exit status must be non-zero, got ' + r.status); + assert(r.out.includes('pass-d ran'), 'pass-d.js (after the failures) must still run'); + assert(r.out.includes('2 passed, 3 failed (5 files)'), 'summary counts missing:\n' + r.out); + assert.deepStrictEqual(failedList(r.out), ['fail-b.js', 'throw-c.js', 'missing-e.js']); + assert(!r.out.includes('All tests passed'), 'must not claim success'); + }); + + test(`${sh}: all green exits 0 with no failure list`, () => { + const r = runSuite(sh, ['pass-a.js', 'pass-d.js']); + assert.strictEqual(r.status, 0, 'exit status ' + r.status + '\n' + r.out); + assert(r.out.includes('2 passed, 0 failed (2 files)'), 'summary counts missing:\n' + r.out); + assert(r.out.includes('All tests passed')); + assert.deepStrictEqual(failedList(r.out), []); + }); +} + +console.log('test-all.sh file list'); +const files = registeredFiles(); + +test('every registered file exists', () => { + const missing = files.filter((f) => !fs.existsSync(path.join(ROOT, f))); + assert.deepStrictEqual(missing, []); +}); + +test('no file is registered twice', () => { + const seen = new Set(); + const dup = files.filter((f) => (seen.has(f) ? true : (seen.add(f), false))); + assert.deepStrictEqual(dup, []); +}); + +test('no registered file needs a browser or a server (E2E belongs in deploy.yml)', () => { + const e2e = /require\(['"](@playwright\/test|playwright)['"]\)|chromium\.launch|process\.env\.BASE_URL/; + const bad = files.filter((f) => e2e.test(fs.readFileSync(path.join(ROOT, f), 'utf8'))); + assert.deepStrictEqual(bad, []); +}); + +test('test-all.sh registers itself through this test', () => { + assert(files.includes('test-test-all.js')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); From c2bbc13b206a4102fa8dfd3fa41c876c9f39aa5d Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 10:13:14 +0200 Subject: [PATCH 02/13] ci: run test-all.sh as the JS unit step (#174) The "Run JS unit tests" step in the Go Build & Test job listed 88 files, 44 of which test-all.sh also listed, while the 64 other test-all.sh files never ran in CI. The 44 files that were only in deploy.yml move into test-all.sh, and the step now runs `sh test-all.sh`: one list, every file runs once, and the step fails if any file fails. Node 22, local, 3 runs: old step (88 files) ~7.9 s, test-all.sh after this change (152 files) ~12.2 s. Keeping the old lines and adding a test-all.sh step would take ~16.8 s and run 44 files twice. Fork guards unchanged (9). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 95 ++---------------------------------- test-all.sh | 46 +++++++++++++++++ 2 files changed, 51 insertions(+), 90 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a048d5371..1e6d30972 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -134,97 +134,12 @@ jobs: node scripts/check-css-vars.js node scripts/test-check-css-vars.js - - name: Run JS unit tests (packet-filter) + - name: Run JS unit tests (test-all.sh) + # One list: test-all.sh runs every unit file and prints a summary of + # the failed ones; the step fails if any file fails (#174). run: | - set -e - node test-packet-filter.js - node test-packet-filter-time.js - node test-issue-121-clear-filters-selection.js - node test-clear-filters.js - node test-issue-147-packets-url-detail-params.js - node test-packet-path-map.js - node test-confidence-indicator.js - node test-1659-analytics-warmup.js - node test-issue-120-distance-building.js - node test-app-api-inflight-cleanup-rejection.js - node test-channels-merge-1498-unit.js - node test-channels-observed-path-hash-size.js - node test-issue-1518-home-url.js - node test-channel-decrypt-insecure-context.js - node test-live-region-filter.js - node test-issue-2052-touch-target-css.js - node test-channel-proposals.js - node test-packet-detail-channel-xss.js - node test-channels-name-escaping.js - node test-issue-1136-observer-iata-map.js - node test-channel-qr.js - node test-channel-qr-wiring.js - node test-channel-modal-ux.js - node test-channel-issue-1087.js - node test-issue-1409-no-encrypted-flood.js - node test-channel-issue-1101.js - node test-observer-iata-1188.js - node test-pull-to-reconnect-1091.js - node test-issue-117-ws-watchdog.js - node test-issue-111-drawer-version.js - node test-channel-fluid-layout.js - node test-issue-1279-p2-code-filter.js - node test-area-filter.js - node test-issue-1293-marker-shapes.js - node test-issue-1356-map-a11y.js - node test-issue-1360-pill-letter-count.js - node test-issue-1364-pill-no-clamp.js - node test-issue-1890-og-url.js - node test-issue-1375-scope-stats-fetch.js - node test-issue-1361-cb-presets.js - node test-issue-1380-cb-sim-overlay.js - node test-issue-1380-cb-reset-button.js - node test-issue-1407-cb-preset-propagation.js - node test-issue-1412-customizer-no-override.js - node test-issue-1418-raw-hex-extraction.js - node test-issue-1418-edge-weights.js - node test-issue-1418-cb-preset-ramp.js - node test-issue-1418-spider-fan.js - node test-issue-1418-deeplink-hops-channels.js - node test-issue-1418-polish-review.js - node test-issue-1420-tile-providers.js - node test-issue-1614-tile-url-function.js - node test-issue-1438-marker-css-vars.js - node test-issue-1846-observers-width.js - node test-issue-1562-observers-summary.js - node test-issue-1509-nav-active-bg.js - node test-issue-1509-detect-preset.js - node test-live.js - node test-issue-124-rx-coverage-viewport.js - node test-rx-coverage-escape.js - node test-issue-125-live-toggles-wiring.js - node test-coverage-gate.js - node test-node-reach-coverage.js - node test-reach-rank.js - node test-node-adverts.js - node test-issue-1107-live-layout.js - node test-issue-1833-legend-toggle-vcr-offset.js - node test-issue-1532-live-fullscreen.js - node test-issue-1619-feed-detail-card-draggable.js - node test-xss-escape-sinks.js - node test-preflight-xss-gate.js - node test-traces.js - node test-issue-1648-m4-emoji-scan.js - node test-issue-1753-copy-url-slash.js - node test-issue-1668-m3-typography.js - node test-mqtt-status-panel.js - node test-issue-1697-mqtt-mobile-e2e.js - node test-warmup-banner.js - node test-issue-1633-hide-1byte-hops.js - node test-issue-1668-m4-per-route.js - node test-a11y-axe-1668-selftest.js - node test-a11y-1716-rf-range-btn-active.js - node test-issue-1705-subpath-contrast.js - node test-analytics-table-ids-unique.js - node test-analytics-relay-airtime-dumbbell.js - node test-live-vcr-mode-contrast.js - node test-issue-1770-mobile-row-clamp.js - node test-a11y-axe-routes-coverage.js + node --version + sh test-all.sh - name: 🛡️ Preflight XSS gate — actual --diff check (PR only) # The fixture self-test above (test-preflight-xss-gate.js) only diff --git a/test-all.sh b/test-all.sh index a193327c2..8147f3612 100755 --- a/test-all.sh +++ b/test-all.sh @@ -140,6 +140,52 @@ run test-map-scope-filter.js run test-issue-117-ws-watchdog.js run test-issue-111-drawer-version.js +# Previously run only by the JS unit step in deploy.yml (#174) +run test-packet-filter-time.js +run test-confidence-indicator.js +run test-1659-analytics-warmup.js +run test-channels-merge-1498-unit.js +run test-issue-1518-home-url.js +run test-live-region-filter.js +run test-issue-1136-observer-iata-map.js +run test-channel-issue-1101.js +run test-observer-iata-1188.js +run test-pull-to-reconnect-1091.js +run test-issue-1279-p2-code-filter.js +run test-area-filter.js +run test-issue-1293-marker-shapes.js +run test-issue-1356-map-a11y.js +run test-issue-1360-pill-letter-count.js +run test-issue-1364-pill-no-clamp.js +run test-issue-1375-scope-stats-fetch.js +run test-issue-1361-cb-presets.js +run test-issue-1407-cb-preset-propagation.js +run test-issue-1412-customizer-no-override.js +run test-issue-1846-observers-width.js +run test-issue-1562-observers-summary.js +run test-issue-1509-nav-active-bg.js +run test-issue-1509-detect-preset.js +run test-live.js +run test-coverage-gate.js +run test-node-reach-coverage.js +run test-reach-rank.js +run test-issue-1107-live-layout.js +run test-issue-1619-feed-detail-card-draggable.js +run test-xss-escape-sinks.js +run test-preflight-xss-gate.js +run test-issue-1648-m4-emoji-scan.js +run test-issue-1753-copy-url-slash.js +run test-issue-1668-m3-typography.js +run test-mqtt-status-panel.js +run test-issue-1697-mqtt-mobile-e2e.js +run test-warmup-banner.js +run test-issue-1633-hide-1byte-hops.js +run test-issue-1668-m4-per-route.js +run test-a11y-axe-1668-selftest.js +run test-a11y-1716-rf-range-btn-active.js +run test-issue-1705-subpath-contrast.js +run test-a11y-axe-routes-coverage.js + # test-all.sh self-test (#174) run test-test-all.js From 4a9f7a2d531c569109d4a9ad74eb1bbb7ec5fbab Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 10:14:23 +0200 Subject: [PATCH 03/13] test: register 45 green unregistered unit tests in test-all.sh (#174) 70 root test-*.js files were run by neither test-all.sh nor deploy.yml. Of the 62 without "e2e" in the name, these 45 pass on Node 22 without node_modules or a server (3 consecutive full test-all.sh runs green). test-live-anims.js was registered in the first commit. The 16 red ones are listed in the PR and not changed here. Co-Authored-By: Claude Opus 5.5 --- test-all.sh | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test-all.sh b/test-all.sh index 8147f3612..9cb6399f4 100755 --- a/test-all.sh +++ b/test-all.sh @@ -186,6 +186,53 @@ run test-a11y-1716-rf-range-btn-active.js run test-issue-1705-subpath-contrast.js run test-a11y-axe-routes-coverage.js +# Green unit tests that no runner registered before (#174 inventory) +run test-1108-region-hide-nodes.js +run test-a11y-1715-dark-role-swatches.js +run test-analytics-distance-view-path.js +run test-analytics-wardriving-tab.js +run test-anl1-tooltip-render.js +run test-channel-color-picker.js +run test-channel-decrypt-ecb.js +run test-channel-decrypt-m345.js +run test-channel-live-decrypt-userprefix.js +run test-channel-live-decrypt.js +run test-color-picker-ux.js +run test-compare-flood-filter.js +run test-compare-overlap.js +run test-embed-mode-1369.js +run test-geofilter-draft.js +run test-hash-color.js +run test-issue-1166-first-seen-column.js +run test-issue-1189-composed-cell.js +run test-issue-1189-live-iata-badge.js +run test-issue-1415-packets-layout.js +run test-issue-1488-marker-stroke-vars.js +run test-issue-1496-reset-all-complete.js +run test-issue-1563-aggregate-and-inflight.js +run test-issue-1574-live-map-max-nodes.js +run test-issue-1606-pagination.js +run test-issue-1644-redesign.js +run test-issue-1648-followup-phosphor-leaks.js +run test-issue-1648-m5-emoji-scan.js +run test-issue-1668-m2-contrast.js +run test-issue-1825-observer-node-cross-links.js +run test-issue-1836-crossnav-case-normalization.js +run test-issue-1843-node-qr-quiet-zone.js +run test-live-dt-cap-1524.js +run test-live-legend-helper.js +run test-node-reach-coverage-debounce.js +run test-observer-detail-view-path.js +run test-observer-naive-clock-1478.js +run test-path-inspector.js +run test-payload-labels-content.js +run test-payload-labels-namespace.js +run test-perf-anomaly.js +run test-perf-render-1258.js +run test-pull-to-reconnect.js +run test-rx-coverage-config-race.js +run test-slideover-1056-rowsel-strict.js + # test-all.sh self-test (#174) run test-test-all.js From 792bce7aa4c3a7643511437f72d470a17270184b Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 10:16:21 +0200 Subject: [PATCH 04/13] ci: run four unregistered root E2Es in the Playwright step (#174) Of the eight root *e2e*.js files that no runner registered, these four pass locally (3/3 runs each, Node 22, Chromium 1208): - test-a11y-1705-subpath-hop-prefix-e2e.js - test-a11y-1719-contrast-root-causes-e2e.js - test-nodes-favorite-rerender-e2e.js - test-packet-trace-alignment-e2e.js None uses the fixture server. The other four stay out (listed in the PR). Fork guards unchanged (9). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1e6d30972..6750b4a16 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -518,6 +518,13 @@ jobs: BASE_URL=http://localhost:13581 node test-live-dedup.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-nodes-export-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-show-neighbors.js 2>&1 | tee -a e2e-output.txt + # Root E2Es without a runner until #174. They do not use the fixture + # server: the two contrast gates render static markup, and the other two + # serve their own pages. + CHROMIUM_REQUIRE=1 node test-a11y-1705-subpath-hop-prefix-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 node test-a11y-1719-contrast-root-causes-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 node test-nodes-favorite-rerender-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 node test-packet-trace-alignment-e2e.js 2>&1 | tee -a e2e-output.txt # #1616: slide-over focus-restore flake-gate. Runs the slide-over # E2E 20 consecutive times against the SAME backend instance so From 87afebbd22e9db40441ecb60b69a1cb5ddf7b62a Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 10:17:32 +0200 Subject: [PATCH 05/13] test: fail when a root test file runs nowhere (#174) test-test-all.js now lists every root test-*.js and requires each one to run in test-all.sh (`run `) or deploy.yml (`node `). The 21 files that cannot run yet are in KNOWN_UNREGISTERED with a reason; an entry that is deleted or gets registered must be removed, so the list can only shrink. Mutants (all red): a new unregistered root test; an allowlisted file registered; a test-all.sh line dropped; an E2E line dropped from deploy.yml; an allowlisted file deleted. Co-Authored-By: Claude Opus 5.5 --- test-test-all.js | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test-test-all.js b/test-test-all.js index b2314685e..c38d7c57e 100644 --- a/test-test-all.js +++ b/test-test-all.js @@ -8,6 +8,8 @@ * swapped for small fixture files. * 2. File list: every entry exists, appears once and runs without a server * or a browser (E2E files belong in the Playwright step of deploy.yml). + * 3. Registry: every root test-*.js runs in test-all.sh or deploy.yml, or is + * listed in KNOWN_UNREGISTERED with a reason. New files cannot go unrun. */ 'use strict'; @@ -123,5 +125,52 @@ test('test-all.sh registers itself through this test', () => { assert(files.includes('test-test-all.js')); }); +// Root test files that no runner runs yet, with the reason. Fix one, register +// it, and delete its entry here. Do not add a file to this list to get a new +// test past the check below: register it instead. +const KNOWN_UNREGISTERED = { + // red unit tests (stale after intended UI changes) + 'test-channel-colors.js': 'red: expects 4px border + tint, #675 changed it to 3px', + 'test-channel-ux-followup.js': 'red: copy text changed by the Phosphor migration', + 'test-channel-ux-round2.js': 'red: expects the 📤 glyph, now #ph-share-network', + 'test-customizer-v2.js': 'red: computeEffective adds home defaults since #525', + 'test-drag-manager.js': 'red: removeAttribute mock does not update dataset (#1567)', + 'test-fluid-scaffolding.js': 'red: reads only the first :root block', + 'test-hop-resolver-affinity.js': 'red: fixture geometry wrong since #874', + 'test-issue-1470-card-bg-contrast.js': 'red: indexOf matches a style.css comment', + 'test-issue-1646-compare-polish.js': 'red: font-size parser reads a comment', + 'test-map-clustering.js': 'red: pills read R3/C2/M1 since #1360', + 'test-packets.js': 'red: 13 emoji assertions after the Phosphor migration', + 'test-panel-corner.js': 'red: sandbox does not load payload-labels.js (#1799)', + 'test-perf-disk-io-1120.js': 'red: ⚠️ is #ph-warning; anomaly detector reworked (#1593)', + // need something CI's unit job does not have + 'test-marker-outline-weight.js': 'needs @playwright/test (not a dependency) and a server', + 'test-table-sort.js': 'needs jsdom (not a dependency)', + 'test-touch-targets.js': 'red in Chromium: expects 48px targets, CSS has 44px', + // E2E + 'test-channel-modal-e2e.js': 'red: Add button text and sidebar sections changed', + 'test-issue-1522-trace-url-sync-e2e.js': 'needs @playwright/test (not a dependency)', + 'test-node-reach-e2e.js': 'red: #nqMap .leaflet-container never visible', + 'test-path-inspector-e2e.js': 'needs @playwright/test (not a dependency)', + 'test-rx-coverage-mobile-nav-e2e.js': 'skips while clientRxCoverage is off (the default)', +}; + +console.log('root test registry'); +const deploy = fs.readFileSync(path.join(ROOT, '.github/workflows/deploy.yml'), 'utf8'); +const inDeploy = new Set([...deploy.matchAll(/\bnode (test-[\w.-]+\.js)\b/g)].map((m) => m[1])); +const runSomewhere = (f) => files.includes(f) || inDeploy.has(f); +const rootTests = fs.readdirSync(ROOT).filter((f) => /^test-.*\.js$/.test(f)); + +test('every root test-*.js runs in test-all.sh or deploy.yml', () => { + const orphans = rootTests.filter((f) => !runSomewhere(f) && !(f in KNOWN_UNREGISTERED)); + assert.deepStrictEqual(orphans, [], orphans.join(', ') + ' run nowhere: add `run ` to ' + + 'test-all.sh (unit) or a line to the Playwright step in deploy.yml (E2E)'); +}); + +test('KNOWN_UNREGISTERED has no stale entries', () => { + const stale = Object.keys(KNOWN_UNREGISTERED).filter((f) => !rootTests.includes(f) || runSomewhere(f)); + assert.deepStrictEqual(stale, [], stale.join(', ') + ': deleted or now registered, remove from KNOWN_UNREGISTERED'); +}); + console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); From 4c02e18e3e13c834760247039e46fb69ce4f9eb0 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 10:18:34 +0200 Subject: [PATCH 06/13] test(packet-path-map): first-to-hear flag and aria-hidden role icons (#174) Review nit from #177: the tests covered the repeater and room icons only. The new case checks all four role icons and the first-to-hear #ph-flag, each as an aria-hidden sprite icon at the start of its tooltip, and that no tooltip icon lacks aria-hidden="true". Mutants (all red): phIcon without aria-hidden; flag removed; flag swapped for another glyph; flag inlined without aria-hidden; sensor or client icon dropped. Co-Authored-By: Claude Opus 5.5 --- test-packet-path-map.js | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test-packet-path-map.js b/test-packet-path-map.js index c77df457d..8fa1540c4 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -1064,6 +1064,54 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ nodes with a known role get a role icon in their tooltip: ' + e.message); } })(); + await (async () => { + try { + // #174 (review of #177): every role icon and the first-to-hear flag + // are decorative sprite icons, hidden from screen readers. + const first = { + hops: 2, + points: [{ publicKey: 'pk3', name: 'SensorC', lat: 56.2, lon: 10.2, role: 'sensor' }], + observer: { name: 'FirstObs', lat: 56.3, lon: 10.3, role: 'client' }, + }; + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { + hops: 1, + points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: 56.0, lon: 10.0, role: 'repeater' }], + observer: { name: 'RoomObserver', lat: 56.1, lon: 10.1, role: 'room' }, + }, + first, + ], + first, + })); + + const tooltips = []; + ctx.L = { + map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip(t) { tooltips.push(t); return this; }, on() { return this; } }), + polyline: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const icon = (name) => ' '; + for (const [name, glyph] of [['RepeaterA', 'broadcast'], ['RoomObserver', 'house-line'], ['SensorC', 'thermometer'], ['FirstObs', 'radio']]) { + assert.ok(tooltips.some((t) => t.startsWith(icon(glyph)) && t.includes(name)), + 'expected ' + name + ' to start with an aria-hidden #ph-' + glyph + ' icon, got: ' + JSON.stringify(tooltips)); + } + const flag = tooltips.filter((t) => t.includes('First to hear it: FirstObs')); + assert.strictEqual(flag.length, 1, 'expected one first-to-hear tooltip, got: ' + JSON.stringify(tooltips)); + assert.ok(flag[0].startsWith(icon('flag') + 'First to hear it: FirstObs (2 hops)'), + 'expected the first-to-hear tooltip to start with an aria-hidden #ph-flag icon, got: ' + flag[0]); + const svgs = tooltips.join('').match(/]*>/g) || []; + assert.ok(svgs.length >= 5, 'expected at least 5 icons, got ' + svgs.length); + assert.deepStrictEqual(svgs.filter((t) => !/\baria-hidden="true"/.test(t)), [], 'every tooltip icon must be aria-hidden'); + passed++; + console.log(' ✅ role icons and the first-to-hear flag render as aria-hidden sprite icons'); + } catch (e) { failed++; console.log(' ❌ role icons and the first-to-hear flag render as aria-hidden sprite icons: ' + e.message); } + })(); + await (async () => { try { // A marker with a publicKey should register a click handler that From d4bb6ce49a664041a7d2062869adcd902a1d972c Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 10:19:04 +0200 Subject: [PATCH 07/13] test(roles): ROLE_COLORS falls back to documentElement without (#174) Review nit from #178: section G covered the read, but not the `document.body || document.documentElement` fallback. roles.js loads in , where body is still null. Section H nulls body, makes getComputedStyle throw on null (as browsers do) and serves a non-default value on documentElement only. Mutants: "getComputedStyle(document.body)" only is red in H; "documentElement only" (the pre-#178 code) is red in G. Co-Authored-By: Claude Opus 5.5 --- test-issue-1407-cb-preset-propagation.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test-issue-1407-cb-preset-propagation.js b/test-issue-1407-cb-preset-propagation.js index 0201b528d..67e23d474 100644 --- a/test-issue-1407-cb-preset-propagation.js +++ b/test-issue-1407-cb-preset-propagation.js @@ -243,6 +243,29 @@ console.log('\n=== #1407 G: ROLE_COLORS follows the preset delivered by body[dat 'preset deut active via body[data-cb-preset] only: ROLE_COLORS.repeater === #FE6100 (got ' + viaBody + ')'); } +console.log('\n=== #1407 H: ROLE_COLORS falls back to documentElement while is null ==='); +// Review nit from #178: roles.js loads in , where document.body is still +// null. _readCssVar must then resolve on documentElement, not hand null to +// getComputedStyle (which throws in browsers) and drop to the hardcoded default. +{ + const henv = makeSandbox(); + henv.sandbox.document.body = null; + const rootOnly = '#123456'; // differs from the Wong default, so a fallback shows + henv.sandbox.getComputedStyle = function (el) { + if (!el) throw new TypeError("getComputedStyle: parameter 1 is not of type 'Element'"); + return { + getPropertyValue: function (k) { + return el === henv.root && k === '--mc-role-repeater' ? rootOnly : ''; + } + }; + }; + vm.createContext(henv.sandbox); + vm.runInContext(rolesSrc, henv.sandbox); + const got = String(henv.sandbox.window.ROLE_COLORS.repeater).toLowerCase(); + assert(got === rootOnly, + 'document.body null: ROLE_COLORS.repeater resolves on documentElement (got ' + got + ')'); +} + console.log('\n=== Summary ==='); console.log(' passed: ' + passed); console.log(' failed: ' + failed); From ea08875d718e122efa79311a102423453867a01f Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 10:19:55 +0200 Subject: [PATCH 08/13] test(1648-m3): make the ping-bot emoji exceptions exact and channels.js-only (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nit from #178: the 🏓 and 🤖 exceptions were line substrings in the global allowlist, so any emoji on the pong or avatar line passed, and the same text in another M3 file passed too. They are now in ALLOW_EXACT['channels.js']: each must occur exactly once, only that occurrence is cut out, and the rest of the line is still scanned. Mutants, old test → new test: extra ✅ on the pong line green → red; `
🤖
` in app.js green → red; 📡 after the avatar green → red. Also red: a second avatar in channels.js, the pong text in app.js, the 🏓 removed (stale exception). Co-Authored-By: Claude Opus 5.5 --- test-issue-1648-m3-emoji-scan.js | 36 ++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/test-issue-1648-m3-emoji-scan.js b/test-issue-1648-m3-emoji-scan.js index a6effc2ec..5b207e831 100644 --- a/test-issue-1648-m3-emoji-scan.js +++ b/test-issue-1648-m3-emoji-scan.js @@ -68,22 +68,36 @@ const ALLOW_SUBSTRINGS = [ '// PATH_SYMBOLS_LEGEND', '// the ✕ disappears', '// userAdded so the', - // channels.js ping bot (2026-07-23, after the M3 sweep). The 🏓 is message - // *content* — the text of the CoreScopeBot reply, like the emoji in the - // home.js prose above — not UI chrome. - "text: '🏓 pong! '", - // channels.js ping-bot message avatar: still the 🤖 glyph because the sprite - // has no robot symbol yet. Known exception, matched narrowly so any other - // avatar/icon emoji still fails. TODO: add ph-robot to phosphor-sprite.svg, - // swap the avatar and drop this entry. - '>🤖', ]; +// Exact, file-specific exceptions. Each text must occur exactly once in its +// file; only that occurrence is cut out before the scan, so any other emoji on +// the same line, a second copy, or the same text in another file still fails. +const ALLOW_EXACT = { + 'channels.js': [ + // Ping bot (2026-07-23, after the M3 sweep). The 🏓 is message *content* + // (the text of the CoreScopeBot reply), not UI chrome. + "text: '🏓 pong! '", + // Ping-bot message avatar: still the 🤖 glyph because the sprite has no + // robot symbol yet. TODO: add ph-robot to phosphor-sprite.svg, swap the + // avatar and drop this entry. + '', + ], +}; + function scanFile(rel) { const abs = path.join(ROOT, rel); - const txt = fs.readFileSync(abs, 'utf8'); - const lines = txt.split('\n'); + let txt = fs.readFileSync(abs, 'utf8'); const hits = []; + for (const exact of ALLOW_EXACT[rel] || []) { + const n = txt.split(exact).length - 1; + if (n !== 1) { + hits.push({ file: rel, line: 0, kind: 'allow', text: `exception must occur exactly once, found ${n}: ${exact}` }); + continue; + } + txt = txt.replace(exact, ''); + } + const lines = txt.split('\n'); lines.forEach((line, idx) => { if (ALLOW_SUBSTRINGS.some(s => line.includes(s))) return; const e = EMOJI.test(line); From eb745eee2bc80a311cc9a1ae91af0094e2d2bea1 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 10:20:53 +0200 Subject: [PATCH 09/13] test(rx-coverage): escape node names and prefixes in the hex tooltip (#174) Follow-up from #173: coverageNodeRow (the RX coverage hex tooltip) escapes node names and prefixes, but no test covered it. Case 5 mounts the real page, lets createMap's deferred drawCoverage() run, answers /api/rx-coverage with one hex of hostile node names plus an unnamed node with a hostile prefix, and checks the exact escaped labels in the recorded tooltip. public/rx-coverage.js is unchanged; the vm harness is shared with cases 1-4 (mountPage). Mutants (all red in case 5): name unescaped; prefix unescaped; label always the prefix; escapeHtml without & escaping (also red in 1 and 2). Co-Authored-By: Claude Opus 5.5 --- test-rx-coverage-escape.js | 70 ++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/test-rx-coverage-escape.js b/test-rx-coverage-escape.js index e3065fa72..33752baeb 100644 --- a/test-rx-coverage-escape.js +++ b/test-rx-coverage-escape.js @@ -1,5 +1,6 @@ /* test-rx-coverage-escape.js — the RX coverage leaderboard must HTML-escape - * observer names and pubkeys (XSS guard, #14 / #170). + * observer names and pubkeys, and the hex tooltip must escape node names and + * prefixes (XSS guard, #14 / #170 / #174). * * Loads the REAL public/rx-coverage.js in a vm (like * test-issue-124-rx-coverage-viewport.js) with the real escapeHtml from @@ -25,13 +26,15 @@ async function test(name, fn) { } const flush = async () => { for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); }; -// Mounts the page and renders the leaderboard from `observers`. -// Returns the #rxBoard innerHTML and the sandbox's escapeHtml. -async function renderBoard(observers, hash) { +// Mounts the real page in a vm. Map timers are collected, not run, and every +// bound tooltip is recorded. +async function mountPage(hash) { const pending = []; + const timers = []; + const tooltips = []; const els = {}; const el = () => ({ innerHTML: '', addEventListener() {}, querySelectorAll: () => [], dataset: {} }); - const layer = () => ({ addTo() { return this; }, clearLayers() {}, bindTooltip() { return this; } }); + const layer = () => ({ addTo() { return this; }, clearLayers() {}, bindTooltip(t) { tooltips.push(t); return this; } }); const location = { hash: hash || '#/rx-coverage' }; const sandbox = { console: { log() {}, warn() {}, error() {} }, @@ -42,10 +45,12 @@ async function renderBoard(observers, hash) { history: { replaceState: (_s, _t, url) => { location.hash = url; } }, localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, getComputedStyle: () => ({ getPropertyValue: () => '' }), - L: { map: () => ({ setView() { return this; }, on() { return this; }, invalidateSize() {}, remove() {} }), + L: { map: () => ({ setView() { return this; }, on() { return this; }, invalidateSize() {}, remove() {}, + getZoom: () => 12, getCenter: () => ({ lat: 56, lng: 10 }), + getBounds: () => ({ getSouth: () => 55, getWest: () => 9, getNorth: () => 57, getEast: () => 11 }) }), tileLayer: () => layer(), layerGroup: () => layer(), polygon: () => layer() }, debounce: (fn) => fn, - setTimeout: () => 0, + setTimeout: (fn) => { timers.push(fn); return 0; }, clearTimeout: () => {}, fetch: (url) => new Promise((resolve, reject) => { pending.push({ url, resolve, reject }); }), }; @@ -58,11 +63,37 @@ async function renderBoard(observers, hash) { page.init({ innerHTML: '' }); await flush(); - const i = pending.findIndex((p) => /^\/api\/rx-leaderboard\?/.test(p.url)); - assert(i >= 0, 'the page did not request /api/rx-leaderboard'); - pending.splice(i, 1)[0].resolve({ ok: true, json: async () => ({ observers }) }); + const answer = (re, body) => { + const i = pending.findIndex((p) => re.test(p.url)); + assert(i >= 0, 'the page did not request ' + re); + pending.splice(i, 1)[0].resolve({ ok: true, json: async () => body }); + }; + return { sandbox, timers, tooltips, answer, esc: sandbox.escapeHtml }; +} + +// Renders the leaderboard from `observers`. +// Returns the #rxBoard innerHTML and the sandbox's escapeHtml. +async function renderBoard(observers, hash) { + const { sandbox, answer, esc } = await mountPage(hash); + answer(/^\/api\/rx-leaderboard\?/, { observers }); await flush(); - return { html: sandbox.document.getElementById('rxBoard').innerHTML, esc: sandbox.escapeHtml }; + return { html: sandbox.document.getElementById('rxBoard').innerHTML, esc }; +} + +// Draws one coverage hex whose properties.nodes are `nodes` and returns its +// tooltip HTML (built by the real coverageNodesHtml / coverageNodeRow). +async function renderHexTooltip(nodes) { + const { timers, tooltips, answer, esc } = await mountPage('#/rx-coverage?lat=56&lon=10&zoom=12'); + answer(/^\/api\/rx-leaderboard\?/, { observers: [] }); + await flush(); + timers.splice(0).forEach((fn) => fn()); // createMap's deferred first drawCoverage() + answer(/^\/api\/rx-coverage\?/, { features: [{ + geometry: { coordinates: [[[10, 56], [10.01, 56], [10.01, 56.01], [10, 56]]] }, + properties: { count: nodes.length, has_sig: true, best_snr: -3, nodes }, + }] }); + await flush(); + assert.strictEqual(tooltips.length, 1, 'expected one hex tooltip, got ' + tooltips.length); + return { html: tooltips[0], esc }; } // Strictly parses every leaderboard data row: the opening tag must consist of @@ -174,6 +205,23 @@ const obs = (pubkey, name, n) => ({ pubkey, name, score: n, cells: n, nodes: n, assert.strictEqual(r.attrs['aria-pressed'], 'false'); }); + await test('5. hex tooltip: hostile node names and prefixes are escaped (coverageNodeRow)', async () => { + const nodes = HOSTILE_NAMES.map((nm, i) => ({ prefix: 'ab' + i, name: nm, snr: -4 - i, count: i + 1 })) + .concat([{ prefix: '">', name: '', snr: null, count: 1 }]); + const { html, esc } = await renderHexTooltip(nodes); + assert(!/label; the label is the escaped name, or the + // escaped prefix in when the name is unresolved. + const labels = [...html.matchAll(/
([\s\S]*?)<\/span> m[1]); + assert.deepStrictEqual(labels, HOSTILE_NAMES.map((nm) => esc(nm)) + .concat(['' + esc('">') + ''])); + assert(html.includes('<script>alert(1)</script>'), '