diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a048d5371..6750b4a16 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 @@ -603,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 diff --git a/test-all.sh b/test-all.sh index 9184cd0a4..6a8b15d2f 100755 --- a/test-all.sh +++ b/test-all.sh @@ -1,126 +1,254 @@ #!/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-issue-147-packets-url-detail-params.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-issue-147-packets-url-detail-params.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 + +# 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 + +# 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 +run test-map-clustering.js +run test-panel-corner.js +run test-customizer-v2.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-customizer-v2.js b/test-customizer-v2.js index a7163039c..be902e694 100644 --- a/test-customizer-v2.js +++ b/test-customizer-v2.js @@ -164,7 +164,9 @@ test('accepts valid opacity', () => { console.log('\ncomputeEffective:'); test('returns server defaults when overrides is {}', () => { const { api } = loadCustomizer(); - const defaults = { theme: { accent: '#aaa', text: '#bbb' }, nodeColors: { repeater: '#ccc' } }; + // The server config carries `home` since #525; without it computeEffective + // fills in DEFAULT_HOME. + const defaults = { theme: { accent: '#aaa', text: '#bbb' }, nodeColors: { repeater: '#ccc' }, home: { heroTitle: 'Mesh' } }; const result = api.computeEffective(defaults, {}); assert.deepStrictEqual(result, defaults); }); 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); 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); diff --git a/test-map-clustering.js b/test-map-clustering.js index 25df326b5..43f48e8ff 100644 --- a/test-map-clustering.js +++ b/test-map-clustering.js @@ -115,10 +115,10 @@ console.log('\n=== map.js: clustering ==='); assert.ok(icon && icon._isDivIcon, 'expected an L.divIcon'); const html = icon.html || ''; assert.ok(/>66<') >= 0, `total count 6 not in html: ${html}`); - // Role pill counts should appear - assert.ok(html.indexOf('>3<') >= 0, `repeater pill (3) not in html: ${html}`); - assert.ok(html.indexOf('>2<') >= 0, `companion pill (2) not in html: ${html}`); - assert.ok(html.indexOf('>1<') >= 0, `room pill (1) not in html: ${html}`); + // Role pills read letter + count since #1360 (R = repeater, C = companion, M = room) + assert.ok(html.indexOf('>R3<') >= 0, `repeater pill (R3) not in html: ${html}`); + assert.ok(html.indexOf('>C2<') >= 0, `companion pill (C2) not in html: ${html}`); + assert.ok(html.indexOf('>M1<') >= 0, `room pill (M1) not in html: ${html}`); // CoreScope-themed wrapper class assert.ok((icon.className || '').indexOf('mc-cluster') >= 0, `expected mc-cluster class, got: ${icon.className}`); }); 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 diff --git a/test-panel-corner.js b/test-panel-corner.js index 06d4da8e7..135ccd060 100644 --- a/test-panel-corner.js +++ b/test-panel-corner.js @@ -147,6 +147,8 @@ function loadLiveModule(ctx) { ctx.globalThis = ctx; vm.createContext(ctx); + // index.html loads payload-labels.js before live.js, which throws without it (#1799). + vm.runInContext(fs.readFileSync(path.join(__dirname, 'public', 'payload-labels.js'), 'utf8'), ctx); vm.runInContext(src, ctx, { timeout: 3000 }); return ctx.window._panelCorner; } diff --git a/test-pr-1490-live-map-gpu-animations-e2e.js b/test-pr-1490-live-map-gpu-animations-e2e.js index 4295b8a61..87bc58f85 100644 --- a/test-pr-1490-live-map-gpu-animations-e2e.js +++ b/test-pr-1490-live-map-gpu-animations-e2e.js @@ -18,7 +18,9 @@ * (20 > 5, so the cap actually runs); * 4. the engine goes back to sleep (isAnimating false); * 5. the fading polylines are drawn on the animations pane: with - * preferCanvas:true Leaflet adds a renderer canvas to that pane. + * preferCanvas:true Leaflet adds one renderer canvas per pane, so the + * burst may add a renderer canvas to the animations pane and to no + * other pane (a fade line without `pane:` lands on overlayPane). * * Headless Chromium may throttle requestAnimationFrame, so the drain is * driven by awaiting rAF from inside the page (as in @@ -36,9 +38,11 @@ const { chromium } = require('playwright'); const BASE = process.env.BASE_URL || 'http://localhost:13581'; const BURST = 20; // > the recentPaths cap of 5 const PATH_CAP = 5; -// One animation takes ~660ms at 1x. The bound is generous for slow CI -// runners and the instrumented frontend; the measured time is logged. -const DRAIN_TIMEOUT_MS = 5000; +// One animation takes ~660ms at 1x; the burst runs in parallel, so the queue +// drains in ~0.7s locally and in CI. 2.5s leaves headroom for the instrumented +// frontend (10/10 local runs well below it) and still catches a stalled or +// serialised engine. The measured time is logged. +const DRAIN_TIMEOUT_MS = 2500; const VIEWPORTS = [ { name: 'desktop', viewport: { width: 1400, height: 900 } }, { name: 'mobile', viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true }, @@ -75,6 +79,17 @@ async function runViewport(browser, vp) { assert(z === '625', 'animations pane z-index is ' + z + ', want 625'); }); + // Leaflet canvas renderers per pane (the engine's own canvas has no class). + const rendererCanvases = () => page.evaluate(() => { + const out = {}; + document.querySelectorAll('#liveMap .leaflet-pane > canvas.leaflet-zoom-animated:not(.leaflet-heatmap-layer)').forEach((c) => { + const pane = (c.parentElement.className.match(/leaflet-([\w-]+)-pane/) || [])[1] || '?'; + out[pane] = (out[pane] || 0) + 1; + }); + return out; + }); + const renderersBefore = await rendererCanvases(); + const queued = await page.evaluate((count) => { for (let i = 0; i < count; i++) { window._liveDrawAnimatedLine([37.4, -122.0], [37.5, -122.1], '#00ff00', null, null, '00AA', 'test-1490-' + i); @@ -119,9 +134,12 @@ async function runViewport(browser, vp) { assert(asleep, 'isAnimating stayed true after the queue drained'); }); - await step(vp.name + ': the fading polylines render on the animations pane', async () => { - const n = await page.locator('#liveMap .leaflet-animations-pane > canvas.leaflet-zoom-animated').count(); - assert(n >= 1, 'no Leaflet renderer canvas on .leaflet-animations-pane — the fades went to another pane'); + await step(vp.name + ': the fading polylines render on the animations pane only', async () => { + const after = await rendererCanvases(); + const seen = 'before ' + JSON.stringify(renderersBefore) + ', after ' + JSON.stringify(after); + assert((after.animations || 0) === 1, 'want exactly one Leaflet renderer canvas on .leaflet-animations-pane — ' + seen); + const grew = Object.keys(after).filter((pane) => pane !== 'animations' && after[pane] > (renderersBefore[pane] || 0)); + assert(grew.length === 0, 'the burst added a renderer canvas to ' + grew.join(', ') + ' — a fade line left the animations pane; ' + seen); }); await step(vp.name + ': no page errors', async () => { 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-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>'), '