From cd9b4c04d0ca3996424ffdb3b3c9b84347ac8c39 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 13 Sep 2026 13:19:39 -0400 Subject: [PATCH] test: unify frontend test runs and prevent inventory drift (#1965) Make `test-all.sh` the authoritative standalone frontend runner for npm and CI. Restore stale assertions and reject missing, duplicate, removed or undocumented inventory entries. Fixes #1858. Rebased onto `a2f039d4`. Retains release-routing, map scope-state and Scope Audit stylesheet tests, adds `test-packets-local-channels.js` to the sorted runner, and classifies `test-neighbor-map-btn-clip-e2e.js` under browser. Its separate CI browser step is preserved. Inventory: 280 root suites, 167 standalone, 113 requiring separate setup. The icon repair fixes two suites red on master: `test-issue-1648-m2-emoji-scan.js` and `test-issue-1648-m6-final-sweep.js`. Node/live configured-scope confirmations now use the existing accessible Phosphor check sprite. Values, visibility conditions and scanner assertions are preserved. - Red evidence: `89e45a9` inventory assertions; `4e255df` accessible confirmation assertion. The latest rebase also reproduced both unclassified-file failures before adding their entries. This follow-up only changes runner/classification configuration and counts; no test files modified. - Local validation: all 167 standalone suites; 27 M2 browser checks and 16 neighbor geometry checks in Chromium. Syntax, whitespace, PII, CSS-variable and XSS checks passed. - Browser coverage includes populated/empty/null configured scopes in node and live views. - No new dependencies, requests, application settings or Go changes. Workflow outside the unit step matches master. - Windows validation uses process-local UTF-8 settings. Encoding and node-reach confirmation follow-ups remain separate, as requested. ## Preflight override External `run-all.sh` is unavailable; applicable repository checks were run directly. --- .github/workflows/deploy.yml | 74 +-------- docs/frontend-testing.md | 32 ++++ package.json | 2 +- public/live.js | 2 +- public/nodes.js | 2 +- scripts/non-unit-tests.json | 130 ++++++++++++++++ test-all.sh | 225 +++++++++++++++++++-------- test-channel-colors.js | 10 +- test-channel-ux-followup.js | 4 +- test-channel-ux-round2.js | 8 +- test-clear-filters.js | 8 +- test-customizer-v2.js | 7 +- test-drag-manager.js | 6 +- test-fluid-scaffolding.js | 16 +- test-hop-resolver-affinity.js | 6 +- test-issue-1438-customizer-mcrole.js | 14 +- test-issue-1446-cb-preset-cascade.js | 9 +- test-issue-1470-card-bg-contrast.js | 8 +- test-issue-1646-compare-polish.js | 2 +- test-issue-1648-m2-icons-e2e.js | 49 ++++++ test-live-anims.js | 23 ++- test-map-clustering.js | 8 +- test-packets.js | 26 ++-- test-panel-corner.js | 1 + test-perf-disk-io-1120.js | 64 ++++---- test-preflight-xss-gate.js | 5 +- test-rx-coverage-escape.js | 4 +- test-test-inventory.js | 49 ++++++ 28 files changed, 551 insertions(+), 243 deletions(-) create mode 100644 docs/frontend-testing.md create mode 100644 scripts/non-unit-tests.json create mode 100644 test-test-inventory.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eb6b7db7..8b42e2d3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -221,78 +221,8 @@ jobs: node scripts/check-css-vars.js node scripts/test-check-css-vars.js - - name: Run JS unit tests (packet-filter) - run: | - set -e - node test-packet-filter.js - node test-packet-filter-time.js - node test-confidence-indicator.js - node test-1659-analytics-warmup.js - node test-channels-merge-1498-unit.js - node test-packets-local-channels.js - node test-issue-1518-home-url.js - node test-channel-decrypt-insecure-context.js - node test-live-region-filter.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-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-1375-scope-stats-fetch.js - node test-scope-audit-styles-linked.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-2001-map-scope-state.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-coverage-gate.js - node test-node-reach-coverage.js - node test-issue-1107-live-layout.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-issue-1770-mobile-row-clamp.js - node test-a11y-axe-routes-coverage.js - node test-issue-1956-release-routing.js + - name: Run JS unit tests + run: 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/docs/frontend-testing.md b/docs/frontend-testing.md new file mode 100644 index 00000000..ec2803bf --- /dev/null +++ b/docs/frontend-testing.md @@ -0,0 +1,32 @@ +# Frontend tests + +`test-all.sh` is the authoritative list of standalone frontend suites. Both +`npm run test:unit` and the CI **Run JS unit tests** step invoke it. `npm test` +and `npm run test:coverage` wrap the same runner with coverage reporting. + +The inventory currently covers 280 root suites: 167 standalone suites in the +runner and 113 suites classified separately by their prerequisites. + +Run `npm run test:unit` from a checkout with Node.js, a POSIX shell, Bash, and +Python 3 available. The XSS gate's fixture test uses Bash and Python. On +Windows, use Git Bash and ensure `python3` resolves to Python 3 rather than a +Microsoft Store alias; set `PYTHONUTF8=1` for the gate's Unicode output. +No server or browser is started by this runner. + +When adding a root `test-*.js` file: + +1. Add its `node test-name.js` command to `test-all.sh` if it runs standalone. +2. Otherwise add it to `scripts/non-unit-tests.json`, with a prerequisite or + reason explaining why it belongs outside the unit runner. Browser suites + still need explicit selection in the E2E workflow to become CI gates. +3. Run `node test-test-inventory.js`. The guard rejects unclassified files, + duplicate assignments, removed files left in a list, undocumented groups, + and local/CI entry points that bypass the unit runner. + +The separate inventory distinguishes plain Node Playwright scripts, suites +requiring the `@playwright/test` runner, and the `jsdom` integration suite. +Those last two dependencies are not currently declared in `package.json`; +classification records that limitation rather than installing them implicitly. +The existing E2E workflow retains its browser installation, local fixture +server, coverage collection, and repeat checks. Only use local servers for +browser tests; inventory membership alone does not mean a suite runs in CI. diff --git a/package.json b/package.json index b52d5d89..f9f5b5f6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "test": "npx c8 --reporter=text --reporter=text-summary sh test-all.sh", - "test:unit": "node test-packet-filter.js && node test-aging.js && node test-frontend-helpers.js", + "test:unit": "sh test-all.sh", "test:coverage": "npx c8 --reporter=text --reporter=html sh test-all.sh", "test:full-coverage": "sh scripts/combined-coverage.sh", "lint": "eslint public/*.js" diff --git a/public/live.js b/public/live.js index 3e6da628..f44fb054 100644 --- a/public/live.js +++ b/public/live.js @@ -2629,7 +2629,7 @@ - ${'configured_scope' in n && n.configured_scope !== null ? `` : ''} + ${'configured_scope' in n && n.configured_scope !== null ? `` : ''} ${'default_scope' in n ? ``; })() : ''} ${(n.role === 'repeater' || n.role === 'room') && Array.isArray(n.transported_scopes) && n.transported_scopes.length ? `` : ''} - ${'configured_scope' in n && n.configured_scope !== null ? `` : ''} + ${'configured_scope' in n && n.configured_scope !== null ? `` : ''} diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json new file mode 100644 index 00000000..85770ed6 --- /dev/null +++ b/scripts/non-unit-tests.json @@ -0,0 +1,130 @@ +{ + "browser": { + "reason": "Requires Playwright and Chromium; most suites also require a running local fixture server via BASE_URL. Existing E2E workflow steps retain their separate setup and selection. Never point these tests at production.", + "files": [ + "test-1110-live-filter.js", + "test-a11y-axe-1668.js", + "test-analytics-fluid-charts.js", + "test-audio-lab-1297-e2e.js", + "test-audio-live-1297-e2e.js", + "test-bottom-nav-1061-e2e.js", + "test-channel-color-picker-e2e.js", + "test-channel-decrypt-e2e.js", + "test-channel-fluid-e2e.js", + "test-channel-issue-1087-e2e.js", + "test-channel-issue-1111-e2e.js", + "test-channel-modal-e2e.js", + "test-channel-qr-e2e.js", + "test-channels-add-modal-e2e.js", + "test-channels-list-render-e2e.js", + "test-channels-selection-flow-e2e.js", + "test-channels-share-color-e2e.js", + "test-channels-ws-batch-e2e.js", + "test-channels-ws-race-1498-e2e.js", + "test-charts-fluid-1058-e2e.js", + "test-customize-branding-e2e.js", + "test-customize-display-e2e.js", + "test-customize-export-e2e.js", + "test-customize-theme-e2e.js", + "test-drag-manager-e2e.js", + "test-e2e-1267-mobile-vcr.js", + "test-e2e-playwright.js", + "test-filter-ux-e2e.js", + "test-gesture-hints-1065-e2e.js", + "test-gestures-1062-e2e.js", + "test-gestures-1185-scroll-discriminator-e2e.js", + "test-home-coverage-e2e.js", + "test-issue-1109-hamburger-dropdown-visible-e2e.js", + "test-issue-1122-packets-filter-ux-e2e.js", + "test-issue-1128-multi-viewport-e2e.js", + "test-issue-1128-packets-layout-e2e.js", + "test-issue-1136-live-region-e2e.js", + "test-issue-1146-path-link-contrast-e2e.js", + "test-issue-1147-section-order-e2e.js", + "test-issue-1150-404-state-e2e.js", + "test-issue-1151-orphan-separators-e2e.js", + "test-issue-1204-live-panel-structure-e2e.js", + "test-issue-1205-live-controls-anchor-e2e.js", + "test-issue-1206-resize-observer-leak-e2e.js", + "test-issue-1206-vcr-overlap-e2e.js", + "test-issue-1224-channels-mobile-ux-e2e.js", + "test-issue-1234-live-chrome-pass2-e2e.js", + "test-issue-1236-map-mobile-e2e.js", + "test-issue-1244-live-vcr-row-hints-e2e.js", + "test-issue-1273-qr-overlay-height-e2e.js", + "test-issue-1274-legend-coverage-e2e.js", + "test-issue-1279-legend-p2-e2e.js", + "test-issue-1281-location-row-e2e.js", + "test-issue-1306-collisions-terminology-e2e.js", + "test-issue-1329-map-controls-accordion-e2e.js", + "test-issue-1367-channels-chat-app-e2e.js", + "test-issue-1374-route-map-a11y-e2e.js", + "test-issue-1400-nav-vertical-clip.js", + "test-issue-1413-nav-overlap-e2e.js", + "test-issue-1486-collapse-reopens-detail-e2e.js", + "test-issue-1487-byop-modal-layout-e2e.js", + "test-issue-1510-live-nav-pin-e2e.js", + "test-issue-1567-corner-clears-drag-e2e.js", + "test-issue-1599-replay-freeze-e2e.js", + "test-issue-1630-reach-mobile-e2e.js", + "test-issue-1639-observers-sort-e2e.js", + "test-issue-1640-compare-discovery-e2e.js", + "test-issue-1648-m1-icons-e2e.js", + "test-issue-1648-m2-icons-e2e.js", + "test-issue-1648-m3-icons-e2e.js", + "test-issue-1648-m4-icons-e2e.js", + "test-issue-1648-m5-icons-e2e.js", + "test-issue-1657-analytics-channels-group-sprites-e2e.js", + "test-issue-1692-packets-init-parallel-e2e.js", + "test-issue-1705-subpath-contrast-e2e.js", + "test-issue-1758-ng-filter-rerenders-e2e.js", + "test-issue-1799-label-vocab-e2e.js", + "test-live-dedup.js", + "test-live-fullscreen-1572-e2e.js", + "test-live-layout-1178-1179-e2e.js", + "test-live-mql-leak-1180-e2e.js", + "test-live-multibyte-only-e2e.js", + "test-logo-default-sage-teal-e2e.js", + "test-logo-pulse-1173-e2e.js", + "test-logo-rebrand-e2e.js", + "test-logo-theme-e2e.js", + "test-map-modal-fluid-e2e.js", + "test-map-nodes-pagination-e2e.js", + "test-nav-drawer-1064-e2e.js", + "test-nav-fluid-1055-e2e.js", + "test-nav-more-floor-1139-e2e.js", + "test-nav-priority-1102-e2e.js", + "test-nav-priority-1311-e2e.js", + "test-nav-priority-1391-e2e.js", + "test-neighbor-map-btn-clip-e2e.js", + "test-node-reach-coverage-e2e.js", + "test-node-reach-e2e.js", + "test-nodes-export-e2e.js", + "test-observer-iata-1188-e2e.js", + "test-packets-scope-column.js", + "test-path-inspector-coverage-e2e.js", + "test-rx-coverage-mobile-nav-e2e.js", + "test-show-neighbors.js", + "test-slideover-1056-e2e.js", + "test-slideover-1168-munger-e2e.js", + "test-table-fluid-e2e.js", + "test-touch-gestures-coverage-e2e.js", + "test-touch-targets.js" + ] + }, + "playwrightRunner": { + "reason": "Requires the @playwright/test runner (not plain node), its configuration, Chromium, and a local server. @playwright/test is not currently declared in package.json; these suites are inventoried but not part of the standalone unit gate.", + "files": [ + "test-issue-1522-trace-url-sync-e2e.js", + "test-marker-outline-weight.js", + "test-path-inspector-e2e.js", + "test-pr-1490-live-map-gpu-animations-e2e.js" + ] + }, + "domIntegration": { + "reason": "Requires jsdom, which is not currently declared in package.json. This DOM integration suite is inventoried separately; no new dependency is installed by the unit gate.", + "files": [ + "test-table-sort.js" + ] + } +} diff --git a/test-all.sh b/test-all.sh index 7b65c293..2d59388b 100755 --- a/test-all.sh +++ b/test-all.sh @@ -1,82 +1,175 @@ #!/bin/sh -# Run all tests with coverage +# Authoritative standalone frontend test list for local runs and CI. +# Every root test is listed here or classified in scripts/non-unit-tests.json. set -e +cd "$(dirname "$0")" -echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" -echo " CoreScope โ€” Test Suite" -echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" -echo "" - -# Unit tests (deterministic, fast) -echo "โ”€โ”€ Unit Tests โ”€โ”€" -node test-packet-filter.js -node test-packet-filter-ux.js -node test-packets-local-channels.js +node test-test-inventory.js +node test-1108-region-hide-nodes.js +node test-1659-analytics-warmup.js +node test-a11y-1705-subpath-hop-prefix-e2e.js +node test-a11y-1715-dark-role-swatches.js +node test-a11y-1716-rf-range-btn-active.js +node test-a11y-1719-contrast-root-causes-e2e.js +node test-a11y-axe-1668-selftest.js +node test-a11y-axe-routes-coverage.js node test-aging.js -node test-issue-1065-gesture-hints-gates.js -node test-frontend-helpers.js -node test-issue-2001-map-scope-state.js -node test-scope-audit-styles-linked.js -node test-fetch-all-nodes-pagination.js -node test-my-repeaters-dashboard.js -node test-repeater-metric-scatter.js -node test-top-routes-overlay.js -node test-url-state.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-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-1890-og-url.js -node test-traces.js -node test-live-multibyte-filter.js -node test-issue-1956-release-routing.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-anl1-tooltip-render.js +node test-area-filter.js +node test-channel-color-picker.js +node test-channel-colors.js +node test-channel-decrypt-ecb.js +node test-channel-decrypt-insecure-context.js +node test-channel-decrypt-m345.js +node test-channel-fluid-layout.js +node test-channel-issue-1087.js +node test-channel-issue-1101.js +node test-channel-live-decrypt-userprefix.js +node test-channel-live-decrypt.js +node test-channel-modal-ux.js +node test-channel-psk-ux.js +node test-channel-qr-wiring.js +node test-channel-qr.js +node test-channel-sidebar-layout.js +node test-channel-ux-followup.js +node test-channel-ux-round2.js +node test-channels-merge-1498-unit.js +node test-clear-filters.js +node test-color-picker-ux.js +node test-compare-flood-filter.js +node test-compare-overlap.js +node test-confidence-indicator.js +node test-coverage-gate.js +node test-customizer-v2.js +node test-drag-manager.js +node test-embed-mode-1369.js +node test-fetch-all-nodes-pagination.js +node test-fluid-scaffolding.js +node test-frontend-helpers.js +node test-geofilter-draft.js +node test-hash-color.js +node test-hop-resolver-affinity.js +node test-issue-1065-gesture-hints-gates.js +node test-issue-1107-live-layout.js +node test-issue-1136-observer-iata-map.js +node test-issue-1166-first-seen-column.js +node test-issue-1189-composed-cell.js +node test-issue-1189-live-iata-badge.js +node test-issue-1279-p2-code-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-1361-cb-presets.js +node test-issue-1364-pill-no-clamp.js +node test-issue-1375-scope-stats-fetch.js node test-issue-1380-cb-reset-button.js +node test-issue-1380-cb-sim-overlay.js +node test-issue-1407-cb-preset-propagation.js +node test-issue-1409-no-encrypted-flood.js +node test-issue-1412-customizer-no-override.js +node test-issue-1415-packets-layout.js +node test-issue-1418-cb-preset-ramp.js +node test-issue-1418-deeplink-hops-channels.js +node test-issue-1418-edge-weights.js +node test-issue-1418-polish-review.js +node test-issue-1418-raw-hex-extraction.js +node test-issue-1418-spider-fan.js +node test-issue-1420-tile-providers.js +node test-issue-1438-customizer-mcrole.js +node test-issue-1438-marker-css-vars.js +node test-issue-1446-cb-preset-cascade.js node test-issue-1450-logo-aspect.js node test-issue-1454-channels-toggle.js node 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-card-bg-contrast.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-naive-banner-tone.js -node test-issue-1473-reserved-prefixes.js node test-issue-1473-prefix-generator.js +node test-issue-1473-reserved-prefixes.js +node test-issue-1485-live-anim-z.js +node test-issue-1488-marker-stroke-vars.js +node test-issue-1496-reset-all-complete.js +node test-issue-1509-detect-preset.js +node test-issue-1509-nav-active-bg.js +node test-issue-1518-home-url.js +node test-issue-1532-live-fullscreen.js +node test-issue-1562-observers-summary.js +node test-issue-1563-aggregate-and-inflight.js +node test-issue-1574-live-map-max-nodes.js +node test-issue-1606-pagination.js +node test-issue-1614-tile-url-function.js +node test-issue-1619-feed-detail-card-draggable.js +node test-issue-1633-hide-1byte-hops.js +node test-issue-1644-redesign.js +node test-issue-1646-compare-polish.js +node test-issue-1648-followup-phosphor-leaks.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-m4-emoji-scan.js +node test-issue-1648-m5-emoji-scan.js +node test-issue-1648-m6-final-sweep.js +node test-issue-1648-m6-lint-self.js +node test-issue-1668-m2-contrast.js +node test-issue-1668-m3-typography.js +node test-issue-1668-m4-per-route.js +node test-issue-1697-mqtt-mobile-e2e.js +node test-issue-1705-subpath-contrast.js +node test-issue-1753-copy-url-slash.js node test-issue-1770-mobile-row-clamp.js +node test-issue-1789-observer-firmware-cols.js +node test-issue-1825-observer-node-cross-links.js +node test-issue-1833-legend-toggle-vcr-offset.js +node test-issue-1836-crossnav-case-normalization.js +node test-issue-1843-node-qr-quiet-zone.js +node test-issue-1846-observers-width.js node test-issue-1849-trace-hashbytes.js -node test-nodes-export.js +node test-issue-1890-og-url.js +node test-issue-1956-release-routing.js +node test-issue-2001-map-scope-state.js +node test-live-anims.js +node test-live-dt-cap-1524.js +node test-live-legend-helper.js +node test-live-multibyte-filter.js +node test-live-region-filter.js +node test-live.js +node test-map-clustering.js +node test-mqtt-status-panel.js +node test-my-repeaters-dashboard.js +node test-naive-banner-tone.js +node test-node-reach-coverage-debounce.js +node test-node-reach-coverage.js node test-nodes-export-wiring.js +node test-nodes-export.js +node test-observer-iata-1188.js +node test-observer-naive-clock-1478.js +node test-observers-headings.js +node test-packet-filter-time.js +node test-packet-filter-ux.js +node test-packet-filter.js +node test-packets-local-channels.js +node test-packets.js +node test-panel-corner.js +node test-path-inspector.js +node test-payload-labels-content.js +node test-payload-labels-namespace.js +node test-perf-anomaly.js +node test-perf-disk-io-1120.js +node test-perf-go-runtime.js +node test-perf-render-1258.js +node test-preflight-xss-gate.js +node test-pull-to-reconnect-1091.js +node test-pull-to-reconnect.js +node test-repeater-metric-scatter.js +node test-rx-coverage-config-race.js +node test-rx-coverage-escape.js +node test-scope-audit-styles-linked.js +node test-slideover-1056-rowsel-strict.js +node test-top-routes-overlay.js +node test-traces.js +node test-url-state.js +node test-warmup-banner.js +node test-xss-escape-sinks.js -echo "" -echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" -echo " All tests passed" -echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" +echo "All standalone frontend suites passed" diff --git a/test-channel-colors.js b/test-channel-colors.js index 06c0e249..a3e2426b 100644 --- a/test-channel-colors.js +++ b/test-channel-colors.js @@ -146,20 +146,20 @@ test('getRowStyle returns empty string for null channel', function() { assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', null), ''); }); -test('getRowStyle returns border + background for assigned GRP_TXT channel', function() { +test('getRowStyle returns the channel accent border without replacing the row background', function() { const ctx = makeSandbox(); ctx.window.ChannelColors.set('#sf', '#ef4444'); const style = ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#sf'); - assert.ok(style.includes('border-left:4px solid #ef4444'), 'should have left border'); - assert.ok(style.includes('background:#ef44441a'), 'should have 10% opacity background'); + assert.strictEqual(style, 'border-left:3px solid #ef4444;'); + assert.ok(!style.includes('background'), 'row state/theme retains control of the background'); }); test('getRowStyle works with CHAN type (alias for GRP_TXT)', function() { const ctx = makeSandbox(); ctx.window.ChannelColors.set('#mesh', '#3b82f6'); const style = ctx.window.ChannelColors.getRowStyle('CHAN', '#mesh'); - assert.ok(style.includes('border-left:4px solid #3b82f6'), 'should have left border'); - assert.ok(style.includes('background:#3b82f61a'), 'should have background tint'); + assert.strictEqual(style, 'border-left:3px solid #3b82f6;'); + assert.ok(!style.includes('background'), 'alias also preserves the row background'); }); test('getRowStyle returns empty when channel has no assigned color', function() { diff --git a/test-channel-ux-followup.js b/test-channel-ux-followup.js index 6e2f284a..c41a24ee 100644 --- a/test-channel-ux-followup.js +++ b/test-channel-ux-followup.js @@ -43,8 +43,8 @@ assert(!/\$\{ch\.messageCount\s*\|\|\s*0\}\s*packets/.test(chSrc), console.log('\n=== Fix 3: privacy footer wording ==='); assert(!/Clear browser data to remove stored keys/.test(chSrc), 'old "Clear browser data to remove stored keys" copy is gone'); -assert(/Use\s+โœ•\s+to remove individual channels/.test(chSrc), - 'new copy points at the โœ• button for individual key removal'); +assert(/Use the close button to remove individual channels/.test(chSrc), + 'copy names the close button for individual key removal'); console.log('\n=== Fix 4: Share/reshare affordance on user-added rows ==='); // Source-level: data attribute and helper exist. Behavior-level checks diff --git a/test-channel-ux-round2.js b/test-channel-ux-round2.js index 4d223b6f..e18db123 100644 --- a/test-channel-ux-round2.js +++ b/test-channel-ux-round2.js @@ -64,12 +64,12 @@ assert(/channelDisplayName\(ch\)/.test(chSrc), console.log('\n=== Fix 2: share button has recognizable label ==='); assert(!/'โคด'/.test(chSrc) && !/"โคด"/.test(chSrc), 'bare โคด glyph no longer used as the share button content'); -// Tighten: assert the literal '๐Ÿ“ค Share' string is the glyph argument -// passed into the iconBtn(...) call for ch-share-btn โ€” this catches the +// Assert the Phosphor icon and visible Share text are passed together +// into the iconBtn(...) call for ch-share-btn โ€” this catches the // case where someone removes the icon from the button content but leaves // "Share" in an aria-label or title. -assert(/iconBtn\(\s*'ch-share-btn'[^)]*'๐Ÿ“ค Share'/.test(chSrc), - "iconBtn('ch-share-btn', ...) is called with '๐Ÿ“ค Share' as the glyph"); +assert(/iconBtn\(\s*'ch-share-btn'[^)]*#ph-share-network[^)]*<\/svg> Share'/.test(chSrc), + "share button content includes the Phosphor share icon and visible Share label"); console.log('\n=== Fix 3: โœ• delete button is a visibly red destructive button ==='); const removeRule = (cssSrc.match(/\.ch-remove-btn\s*\{[^}]*\}/) || [''])[0]; diff --git a/test-clear-filters.js b/test-clear-filters.js index 1eda5be4..b8c9184d 100644 --- a/test-clear-filters.js +++ b/test-clear-filters.js @@ -269,11 +269,11 @@ test('updatePacketsUrl shows clear button when time window != default', () => { const DEFAULT_TIME_WINDOW = 15; const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', - 'document', 'history', 'RegionFilter', 'buildPacketsQuery', + 'document', 'history', 'RegionFilter', 'buildPacketsQuery', 'location', updateUrlBody ); fn(filters, savedTimeWindowMin, DEFAULT_TIME_WINDOW, - ctx.document, ctx.history, ctx.RegionFilter, () => ''); + ctx.document, ctx.history, ctx.RegionFilter, () => '', { hash: '#/packets' }); assert.strictEqual(elements['clearFiltersBtn'].style.display, '', 'clear button should be visible when time window != default'); }); @@ -283,10 +283,10 @@ test('updatePacketsUrl hides clear button when all filters default', () => { const filters = {}; const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', - 'document', 'history', 'RegionFilter', 'buildPacketsQuery', + 'document', 'history', 'RegionFilter', 'buildPacketsQuery', 'location', updateUrlBody ); - fn(filters, 15, 15, ctx.document, ctx.history, ctx.RegionFilter, () => ''); + fn(filters, 15, 15, ctx.document, ctx.history, ctx.RegionFilter, () => '', { hash: '#/packets' }); assert.strictEqual(elements['clearFiltersBtn'].style.display, 'none', 'clear button should be hidden'); }); diff --git a/test-customizer-v2.js b/test-customizer-v2.js index a7163039..954491ec 100644 --- a/test-customizer-v2.js +++ b/test-customizer-v2.js @@ -162,11 +162,14 @@ test('accepts valid opacity', () => { // โ”€โ”€ computeEffective โ”€โ”€ console.log('\ncomputeEffective:'); -test('returns server defaults when overrides is {}', () => { +test('preserves all server sections when overrides is {} and supplies missing home defaults', () => { const { api } = loadCustomizer(); const defaults = { theme: { accent: '#aaa', text: '#bbb' }, nodeColors: { repeater: '#ccc' } }; const result = api.computeEffective(defaults, {}); - assert.deepStrictEqual(result, defaults); + const { home, ...serverSections } = result; + assert.deepStrictEqual(serverSections, defaults); + assert.ok(home && typeof home === 'object', 'missing home config gets built-in defaults'); + assert.strictEqual(defaults.home, undefined, 'server input is not mutated'); }); test('overrides a single key in a section', () => { diff --git a/test-drag-manager.js b/test-drag-manager.js index 1bec20ba..67285129 100644 --- a/test-drag-manager.js +++ b/test-drag-manager.js @@ -41,7 +41,11 @@ function makePanel(id) { }, getAttribute(k) { return attrs[k] || null; }, setAttribute(k, v) { attrs[k] = v; }, - removeAttribute(k) { delete attrs[k]; }, + removeAttribute(k) { + delete attrs[k]; + // DOMStringMap reflects removal of data-* attributes in real browsers. + if (k.startsWith('data-')) delete dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())]; + }, getBoundingClientRect() { return { left: parseFloat(style.left) || 0, diff --git a/test-fluid-scaffolding.js b/test-fluid-scaffolding.js index 3768f63b..04b2e77a 100644 --- a/test-fluid-scaffolding.js +++ b/test-fluid-scaffolding.js @@ -9,6 +9,7 @@ const path = require('path'); const assert = require('assert'); const css = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8'); +const rules = css.replace(/\/\*[\s\S]*?\*\//g, ''); let passed = 0, failed = 0; function test(name, fn) { @@ -18,18 +19,19 @@ function test(name, fn) { // --- Helpers --------------------------------------------------------------- -// Extract the :root { ... } block (first occurrence โ€” the light/default one). +// Base tokens can be split across multiple :root blocks. Theme overrides use +// different selectors; combine the base declarations in stylesheet order. function rootBlock() { - const m = css.match(/:root\s*\{([\s\S]*?)\}/); - if (!m) throw new Error(':root block not found in style.css'); - return m[1]; + const blocks = Array.from(rules.matchAll(/^:root\s*\{([^}]*)\}/gm), m => m[1]); + if (!blocks.length) throw new Error(':root block not found in style.css'); + return blocks.join('\n'); } // Find the value of a custom property declared in :root. function rootVar(name) { - const re = new RegExp(`${name}\\s*:\\s*([^;]+);`); - const m = rootBlock().match(re); - return m ? m[1].trim() : null; + const re = new RegExp(`${name}\\s*:\\s*([^;]+);`, 'g'); + const values = Array.from(rootBlock().matchAll(re), m => m[1].trim()); + return values.length ? values[values.length - 1] : null; } function assertClamp(name) { diff --git a/test-hop-resolver-affinity.js b/test-hop-resolver-affinity.js index a9ce2dda..14dfe51a 100644 --- a/test-hop-resolver-affinity.js +++ b/test-hop-resolver-affinity.js @@ -24,7 +24,9 @@ function assert(condition, msg) { // Two nodes share the same 1-byte prefix "ab" const nodeA = { public_key: 'ab1111', name: 'NodeA', role: 'repeater', lat: 37.0, lon: -122.0 }; const nodeB = { public_key: 'ab2222', name: 'NodeB', role: 'repeater', lat: 38.0, lon: -123.0 }; -const nodeC = { public_key: 'cd3333', name: 'NodeC', role: 'repeater', lat: 37.5, lon: -122.5 }; +// Keep the anchor distinctly closer to A. Equal degree offsets are not equal +// great-circle distances: longitude converges toward the poles. +const nodeC = { public_key: 'cd3333', name: 'NodeC', role: 'repeater', lat: 37.25, lon: -122.25 }; console.log('\n=== HopResolver Affinity Tests ===\n'); @@ -50,7 +52,7 @@ HopResolver.setAffinity({}); // No edges // With anchor at NodeC's position, NodeA is closer to NodeC than NodeB const result2 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null); -// NodeA (37, -122) is closer to NodeC (37.5, -122.5) than NodeB (38, -123) +// NodeA is 0.25 degrees away on each axis; NodeB is 0.75 degrees away. assert(result2['ab'].name === 'NodeA', 'Should pick NodeA (geo-closest) โ€” got: ' + result2['ab'].name); // Test 3: setAffinity with null/undefined doesn't crash diff --git a/test-issue-1438-customizer-mcrole.js b/test-issue-1438-customizer-mcrole.js index 6689d8ce..ff8989b3 100644 --- a/test-issue-1438-customizer-mcrole.js +++ b/test-issue-1438-customizer-mcrole.js @@ -40,6 +40,7 @@ function assert(cond, msg) { const cv2Src = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8'); const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8'); const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8'); +const styleSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8'); // โ”€โ”€โ”€ Extract the nodeColors-processing block from customize-v2.js. โ”€โ”€โ”€ function extractBlock(src, anchor) { @@ -163,12 +164,15 @@ console.log('\n=== #1438 FINAL C: server-only key does NOT clobber --mc-role-* ( blockA + '\n'; vm.runInContext(setup, env.sandbox); - // --mc-role-companion must remain the preset's value (no clobber from server). + // applyCSS clears inline preset values for server-only keys. The active + // body selector supplies the color; a vm does not load that stylesheet. const got = env.root.style.getPropertyValue('--mc-role-companion').toLowerCase(); - assert(got !== '#2563eb', - '--mc-role-companion is NOT the server-config legacy #2563eb (got ' + got + ')'); - assert(got === '#648fff', - '--mc-role-companion still reflects the active preset #648FFF (got ' + got + ')'); + assert(got === '' && env.body.style.getPropertyValue('--mc-role-companion') === '', + 'server-only companion leaves no inline role color overriding the preset'); + const presetRule = styleSrc.match(/body\[data-cb-preset="deut"\]\s*\{([^}]*)\}/); + assert(env.body.getAttribute('data-cb-preset') === 'deut' && presetRule && + /--mc-role-companion:\s*#648fff\s*;/i.test(presetRule[1]), + 'the selected body preset supplies companion #648FFF from style.css'); // --node-companion CAN take the server value (legacy compat is fine here). assert(env.root.style.getPropertyValue('--node-companion').toLowerCase() === '#2563eb', diff --git a/test-issue-1446-cb-preset-cascade.js b/test-issue-1446-cb-preset-cascade.js index 033a0b30..20ec2733 100644 --- a/test-issue-1446-cb-preset-cascade.js +++ b/test-issue-1446-cb-preset-cascade.js @@ -186,10 +186,13 @@ console.log('\n=== #1446 Scenario 5: clear preset โ†’ reverts to server config / vm.runInContext(presetsSrc, env.sandbox); vm.runInContext(cv2Src, env.sandbox); env.sandbox.window._customizerV2.init({ nodeColors: { repeater: '#aaaaaa' } }); - // Confirm deut is active first. + // The customizer removes inline role colors when a preset is active; + // its body selector in the real stylesheet supplies the selected color. const repWithPreset = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase(); - assert(repWithPreset === '#fe6100', - 'precondition: deut active โ†’ --mc-role-repeater = #FE6100 (got: ' + JSON.stringify(repWithPreset) + ')'); + const presetRule = styleSrc.match(/body\[data-cb-preset="deut"\]\s*\{([^}]*)\}/); + assert(repWithPreset === '' && env.body.getAttribute('data-cb-preset') === 'deut' && + presetRule && /--mc-role-repeater:\s*#fe6100\s*;/i.test(presetRule[1]), + 'precondition: deut selects the CSS repeater color #FE6100 without a root override'); // Now clear the preset. const hasClear = typeof env.sandbox.window.MeshCorePresets.clearPreset === 'function'; assert(hasClear, 'MeshCorePresets.clearPreset() exists'); diff --git a/test-issue-1470-card-bg-contrast.js b/test-issue-1470-card-bg-contrast.js index 9a1bf14c..14bc7a10 100644 --- a/test-issue-1470-card-bg-contrast.js +++ b/test-issue-1470-card-bg-contrast.js @@ -47,10 +47,10 @@ test('@media dark block sets --card-bg to var(--surface-2)', () => { test('[data-theme="dark"] block sets --card-bg to var(--surface-2)', () => { const css = read('public/style.css'); - const themeIdx = css.indexOf('[data-theme="dark"]'); - assert.ok(themeIdx !== -1, '[data-theme="dark"] block not found'); - const themeBlock = css.slice(themeIdx, themeIdx + 2000); - const m = themeBlock.match(/--card-bg\s*:\s*([^;]+);/); + const rules = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const blocks = Array.from(rules.matchAll(/\[data-theme="dark"\]\s*\{([^}]*)\}/g), m => m[1]); + assert.ok(blocks.length, '[data-theme="dark"] block not found'); + const m = blocks.join('\n').match(/--card-bg\s*:\s*([^;]+);/); assert.ok(m, '--card-bg not found in [data-theme="dark"] block'); assert.strictEqual(m[1].trim(), 'var(--surface-2)', `Expected var(--surface-2), got "${m[1].trim()}"`); diff --git a/test-issue-1646-compare-polish.js b/test-issue-1646-compare-polish.js index 6490e5b9..d77d4449 100644 --- a/test-issue-1646-compare-polish.js +++ b/test-issue-1646-compare-polish.js @@ -31,7 +31,7 @@ const fs = require('fs'); const path = require('path'); -const CSS = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8'); +const CSS = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); const COMPARE_JS = fs.readFileSync(path.join(__dirname, 'public/compare.js'), 'utf8'); // Token-rank used by font-size comparisons. Comments-only mirror of the diff --git a/test-issue-1648-m2-icons-e2e.js b/test-issue-1648-m2-icons-e2e.js index 301b041b..c31ce206 100644 --- a/test-issue-1648-m2-icons-e2e.js +++ b/test-issue-1648-m2-icons-e2e.js @@ -75,6 +75,53 @@ async function spriteRefsResolve(page, label) { else pass(`${label}: ${r.count} sprite refs rendered (samples: ${r.refs.join(', ')})`); } +async function checkConfiguredScope(page) { + const key = 'c1'.repeat(32); + const node = { public_key: key, name: 'Configured scope fixture', role: 'repeater' }; + await page.route('**/api/nodes/' + key, route => route.fulfill({ + json: { node, recentAdverts: [] }, + })); + await page.route('**/api/nodes/' + key + '/health', route => route.fulfill({ json: {} })); + const nodeListUrl = /\/api\/nodes(?:\?.*)?$/; + await page.route(nodeListUrl, route => route.fulfill({ + json: { nodes: [{ ...node, lat: 1, lon: 1 }], total: 1 }, + })); + try { + for (const live of [false, true]) { + for (const scope of ['#fixture', '', null]) { + node.configured_scope = scope; + // A fresh document also clears the application's node-detail cache. + await page.goto('about:blank'); + const route = live ? '/live?lat=1&lon=1&zoom=10' : '/nodes/' + key; + await page.goto(`${BASE}/#${route}`, { waitUntil: 'domcontentloaded' }); + if (live) await page.locator('#liveMap .live-node-marker').click(); + await page.locator(live ? '#nodeDetailContent table' : '#node-stats').waitFor({ state: 'visible', timeout: 8000 }); + const row = live + ? page.locator('#nodeDetailContent tr').filter({ hasText: 'Configured scope' }) + : page.locator('#row-configured-scope'); + const label = live ? 'live detail' : 'node detail'; + if (scope === null) { + assert.strictEqual(await row.count(), 0, 'unconfirmed scope must not render a confirmation row'); + pass(label + ' configured scope: unconfirmed evidence stays absent'); + continue; + } + assert.ok(await row.isVisible(), 'confirmed scope row must be visible, including an empty scope'); + const confirmation = row.getByRole('img', { name: 'confirmed', exact: true }); + assert.strictEqual(await confirmation.count(), 1, 'confirmation must have an accessible image role and name'); + assert.ok(await confirmation.isVisible(), 'confirmation icon must be visible'); + assert.strictEqual(await confirmation.locator('use').getAttribute('href'), '/icons/phosphor-sprite.svg#ph-check'); + assert.doesNotMatch(await row.textContent(), EMOJI_RE, 'confirmation must follow the Phosphor icon policy'); + assert.strictEqual((await row.locator('td').nth(1).textContent()).trim(), scope || 'none configured'); + pass(label + ' configured scope: accessible confirmation and value for ' + (scope || 'none configured')); + } + } + } finally { + await page.unroute('**/api/nodes/' + key); + await page.unroute('**/api/nodes/' + key + '/health'); + await page.unroute(nodeListUrl); + } +} + async function main() { const requireChromium = process.env.CHROMIUM_REQUIRE === '1'; let browser; @@ -96,6 +143,8 @@ async function main() { const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await ctx.newPage(); + await checkConfiguredScope(page); + // (a) /analytics await checkHeading(page, '/analytics', /Mesh Analytics/, '(a) /analytics'); await spriteRefsResolve(page, '(a) /analytics sprite'); diff --git a/test-live-anims.js b/test-live-anims.js index 31b4f4e8..91add623 100644 --- a/test-live-anims.js +++ b/test-live-anims.js @@ -2,6 +2,7 @@ 'use strict'; const fs = require('fs'); const assert = require('assert'); +const vm = require('vm'); const src = fs.readFileSync('public/live.js', 'utf8'); @@ -117,16 +118,22 @@ test2('drawAnimatedLine renderFades() has null guard', () => { assert.ok(fadeBody.includes('!pathsLayer'), 'renderFades() missing pathsLayer null guard'); }); -test2('pulseNode animatePulse() has null guard', () => { - const pulseStart = src2.indexOf('function animatePulse(now)'); - const pulseBody = src2.substring(pulseStart, pulseStart + 200); - assert.ok(pulseBody.includes('!animLayer'), 'animatePulse() missing animLayer null guard'); +function functionSource(name) { + const start = src2.indexOf(' function ' + name + '('); + assert.ok(start >= 0, name + ' exists'); + const next = src2.indexOf('\n function ', start + 1); + assert.ok(next > start, name + ' source boundary exists'); + return src2.slice(start, next); +} + +test2('pulseNode returns safely after either layer is destroyed', () => { + for (const layers of [{ animLayer: null, nodesLayer: {} }, { animLayer: {}, nodesLayer: null }]) { + assert.doesNotThrow(() => vm.runInNewContext(functionSource('pulseNode') + '\npulseNode("test", [0, 0], "ADVERT");', layers)); + } }); -test2('ghostPulse has null guard', () => { - const ghostStart = src2.indexOf('function ghostPulse(now)'); - const ghostBody = src2.substring(ghostStart, ghostStart + 200); - assert.ok(ghostBody.includes('!animLayer'), 'ghostPulse() missing animLayer null guard'); +test2('shared pulse/ghost canvas callback returns safely after the context is destroyed', () => { + assert.doesNotThrow(() => vm.runInNewContext(functionSource('renderAnimations') + '\nrenderAnimations(0);', { animCtx: null })); }); console.log(`\n${p2} passed, ${f2} failed\n`); diff --git a/test-map-clustering.js b/test-map-clustering.js index 25df326b..b2e5989f 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 letters disambiguate counts without relying on color alone. + assert.ok(/class="mc-pill role-repeater"[^>]*>R3]*>C2]*>M1= 0, `expected mc-cluster class, got: ${icon.className}`); }); diff --git a/test-packets.js b/test-packets.js index ee8a9100..589a406e 100644 --- a/test-packets.js +++ b/test-packets.js @@ -211,7 +211,7 @@ console.log('\n=== packets.js: getDetailPreview ==='); test('getDetailPreview handles CHAN type', () => { const result = api.getDetailPreview({ type: 'CHAN', text: 'hello world', channel: 'general' }); - assert(result.includes('๐Ÿ’ฌ')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-chat-circle"')); assert(result.includes('hello world')); assert(result.includes('chan-tag')); assert(result.includes('general')); @@ -229,7 +229,7 @@ console.log('\n=== packets.js: getDetailPreview ==='); type: 'ADVERT', name: 'TestNode', pubKey: 'abc123', flags: { repeater: true } }); - assert(result.includes('๐Ÿ“ก')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-broadcast"')); assert(result.includes('TestNode')); assert(result.includes('hop-link')); }); @@ -239,7 +239,7 @@ console.log('\n=== packets.js: getDetailPreview ==='); type: 'ADVERT', name: 'RoomNode', pubKey: 'abc', flags: { room: true } }); - assert(result.includes('๐Ÿ ')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-house-line"')); }); test('getDetailPreview handles ADVERT sensor', () => { @@ -247,7 +247,7 @@ console.log('\n=== packets.js: getDetailPreview ==='); type: 'ADVERT', name: 'Sensor1', pubKey: 'abc', flags: { sensor: true } }); - assert(result.includes('๐ŸŒก')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-thermometer"')); }); test('getDetailPreview handles ADVERT companion (default)', () => { @@ -255,14 +255,14 @@ console.log('\n=== packets.js: getDetailPreview ==='); type: 'ADVERT', name: 'Comp', pubKey: 'abc', flags: {} }); - assert(result.includes('๐Ÿ“ป')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-radio"')); }); test('getDetailPreview handles GRP_TXT with channelHash (no_key)', () => { const result = api.getDetailPreview({ type: 'GRP_TXT', channelHash: 0xAB, decryptionStatus: 'no_key' }); - assert(result.includes('๐Ÿ”’')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-lock"')); assert(result.includes('0xAB')); assert(result.includes('no key')); }); @@ -446,7 +446,7 @@ console.log('\n=== packets.js: getDetailPreview ==='); const result = api.getDetailPreview({ type: 'TXT_MSG', srcHash: 'abcdef01', destHash: '12345678' }); - assert(result.includes('โœ‰๏ธ')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-envelope"')); assert(result.includes('abcdef01')); assert(result.includes('12345678')); }); @@ -455,14 +455,14 @@ console.log('\n=== packets.js: getDetailPreview ==='); const result = api.getDetailPreview({ type: 'PATH', srcHash: 'aabb', destHash: 'ccdd' }); - assert(result.includes('๐Ÿ”€')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-shuffle"')); }); test('getDetailPreview handles REQ', () => { const result = api.getDetailPreview({ type: 'REQ', srcHash: 'aa', destHash: 'bb' }); - assert(result.includes('๐Ÿ”’')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-lock"')); assert(result.includes('aa')); }); @@ -470,7 +470,7 @@ console.log('\n=== packets.js: getDetailPreview ==='); const result = api.getDetailPreview({ type: 'RESPONSE', srcHash: 'aa', destHash: 'bb' }); - assert(result.includes('๐Ÿ”’')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-lock"')); }); test('getDetailPreview handles ANON_REQ', () => { @@ -493,7 +493,7 @@ console.log('\n=== packets.js: getDetailPreview ==='); test('getDetailPreview handles public_key fallback', () => { const result = api.getDetailPreview({ public_key: 'abcdef1234567890abcdef' }); - assert(result.includes('๐Ÿ“ก')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-broadcast"')); assert(result.includes('abcdef1234567890')); }); @@ -957,7 +957,7 @@ console.log('\n=== packets.js: buildGroupRowHtml ==='); }; const result = api.buildGroupRowHtml(p); assert(result.includes('group-header')); - assert(result.includes('โ–ถ')); // collapsed arrow + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-caret-up"')); // collapsed arrow }); test('buildGroupRowHtml shows observation count badge', () => { @@ -969,7 +969,7 @@ console.log('\n=== packets.js: buildGroupRowHtml ==='); }; const result = api.buildGroupRowHtml(p); assert(result.includes('badge-obs')); - assert(result.includes('๐Ÿ‘')); + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-eye"')); assert(result.includes('5')); }); diff --git a/test-panel-corner.js b/test-panel-corner.js index 06d4da8e..22299b27 100644 --- a/test-panel-corner.js +++ b/test-panel-corner.js @@ -147,6 +147,7 @@ function loadLiveModule(ctx) { ctx.globalThis = ctx; vm.createContext(ctx); + 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-perf-disk-io-1120.js b/test-perf-disk-io-1120.js index b9b69231..35509fda 100644 --- a/test-perf-disk-io-1120.js +++ b/test-perf-disk-io-1120.js @@ -156,7 +156,7 @@ await test('WAL >100 MB fires โš ๏ธ flag', async () => { const html = sb.getHtml(); // The warning appears in the WAL Size card; assert proximity by extracting // the WAL Size card's text content. - const walSection = html.match(/150\.0MB[^<]*โš ๏ธ/); + const walSection = html.match(/150\.0MB\s*]*>]*#ph-warning"/); assert.ok(walSection, 'expected โš ๏ธ next to 150MB WAL value, html=' + html.slice(html.indexOf('WAL Size') - 200, html.indexOf('WAL Size') + 200)); }); @@ -169,7 +169,7 @@ await test('WAL <100 MB does NOT fire โš ๏ธ flag', async () => { const html = sb.getHtml(); const walIdx = html.indexOf('WAL Size'); const slice = html.slice(Math.max(0, walIdx - 200), walIdx); - assert.ok(!/12\.3MB[^<]*โš ๏ธ/.test(slice), 'expected NO โš ๏ธ next to 12.3MB WAL value'); + assert.ok(!/12\.3MB\s*]*>]*#ph-warning"/.test(slice), 'expected NO warning icon next to 12.3MB WAL value'); }); await test('Cache hit <90% fires โš ๏ธ flag', async () => { @@ -179,7 +179,7 @@ await test('Cache hit <90% fires โš ๏ธ flag', async () => { await sb.pages.perf.init({ set innerHTML(v) {} }); await new Promise(r => setTimeout(r, 100)); const html = sb.getHtml(); - assert.ok(/85\.0%[^<]*โš ๏ธ/.test(html), 'expected โš ๏ธ next to 85.0% cache hit value'); + assert.ok(/85\.0%\s*]*>]*#ph-warning"/.test(html), 'expected warning icon next to 85.0% cache hit value'); }); await test('Cache hit โ‰ฅ90% does NOT fire โš ๏ธ flag', async () => { @@ -189,7 +189,7 @@ await test('Cache hit โ‰ฅ90% does NOT fire โš ๏ธ flag', async () => { await sb.pages.perf.init({ set innerHTML(v) {} }); await new Promise(r => setTimeout(r, 100)); const html = sb.getHtml(); - assert.ok(!/98\.7%[^<]*โš ๏ธ/.test(html), 'expected NO โš ๏ธ next to 98.7% cache hit value'); + assert.ok(!/98\.7%\s*]*>]*#ph-warning"/.test(html), 'expected NO warning icon next to 98.7% cache hit value'); }); // === #1167 must-fix #7: threshold boundary cases === @@ -203,7 +203,7 @@ await test('WAL exactly 100 MB does NOT fire โš ๏ธ (boundary, strict >)', async const html = sb.getHtml(); const walIdx = html.indexOf('WAL Size'); const slice = html.slice(Math.max(0, walIdx - 200), walIdx); - assert.ok(!/100\.0MB[^<]*โš ๏ธ/.test(slice), 'expected NO โš ๏ธ at exactly 100 MB WAL (boundary), slice=' + slice); + assert.ok(!/100\.0MB\s*]*>]*#ph-warning"/.test(slice), 'expected NO warning icon at exactly 100 MB WAL (boundary), slice=' + slice); }); await test('WAL infinitesimally over 100 MB DOES fire โš ๏ธ', async () => { @@ -213,7 +213,7 @@ await test('WAL infinitesimally over 100 MB DOES fire โš ๏ธ', async () => { await sb.pages.perf.init({ set innerHTML(v) {} }); await new Promise(r => setTimeout(r, 100)); const html = sb.getHtml(); - assert.ok(/100\.0MB[^<]*โš ๏ธ/.test(html), 'expected โš ๏ธ next to 100.0MB WAL value (just over threshold)'); + assert.ok(/100\.0MB\s*]*>]*#ph-warning"/.test(html), 'expected warning icon next to 100.0MB WAL value (just over threshold)'); }); await test('Cache hit exactly 90% does NOT fire โš ๏ธ (boundary, strict <)', async () => { @@ -223,7 +223,7 @@ await test('Cache hit exactly 90% does NOT fire โš ๏ธ (boundary, strict <)', as await sb.pages.perf.init({ set innerHTML(v) {} }); await new Promise(r => setTimeout(r, 100)); const html = sb.getHtml(); - assert.ok(!/90\.0%[^<]*โš ๏ธ/.test(html), 'expected NO โš ๏ธ at exactly 90.0% cache hit (boundary)'); + assert.ok(!/90\.0%\s*]*>]*#ph-warning"/.test(html), 'expected NO warning icon at exactly 90.0% cache hit (boundary)'); }); await test('Cache hit infinitesimally below 90% DOES fire โš ๏ธ', async () => { @@ -233,40 +233,35 @@ await test('Cache hit infinitesimally below 90% DOES fire โš ๏ธ', async () => { await sb.pages.perf.init({ set innerHTML(v) {} }); await new Promise(r => setTimeout(r, 100)); const html = sb.getHtml(); - assert.ok(/90\.0%[^<]*โš ๏ธ/.test(html), 'expected โš ๏ธ next to 90.0% cache hit value (just under threshold)'); + assert.ok(/90\.0%\s*]*>]*#ph-warning"/.test(html), 'expected warning icon next to 90.0% cache hit value (just under threshold)'); }); -await test('Backfill anomaly: rate >10ร— tx-rate WITH baseline txโ‰ฅ100 fires โš ๏ธ', async () => { - // Two-phase: prime the previous-snapshot cache, then tick again with - // a backfill rate >10ร— the tx rate AND tx_inserted past the baseline gate. +await test('Backfill anomaly: rate >10ร— its stable rolling baseline shows a warning', async () => { + // The current detector compares a source with its own rolling baseline. + // Prime a 60-second baseline at 1/s, then jump to 1000/s for the next tick. const sb = loadPerf(); - // Reset any previous cached snapshot - sb.ctx.window._perfWriteSourcesPrev = null; - const t0 = '2026-01-01T00:00:00Z'; - const t1 = '2026-01-01T00:00:01Z'; // 1s later - const phase1 = { sources: { tx_inserted: 100, backfill_path_json: 0 }, sampleAt: t0 }; - const phase2 = { sources: { tx_inserted: 105, backfill_path_json: 1000 }, sampleAt: t1 }; - // First render: no prev โ†’ no flags possible - stubFetch(sb, { ...basePerf, goRuntime }, goHealth, ioData, sqliteData, phase1); - await sb.pages.perf.init({ set innerHTML(v) {} }); - await new Promise(r => setTimeout(r, 50)); - // Second render: simulate tick with delta. Reuse the same fetch wiring. - stubFetch(sb, { ...basePerf, goRuntime }, goHealth, ioData, sqliteData, phase2); - await sb.pages.perf.init({ set innerHTML(v) {} }); - await new Promise(r => setTimeout(r, 50)); + const samples = [ + { sources: { tx_inserted: 100, backfill_path_json: 0 }, sampleAt: '2026-01-01T00:00:00Z' }, + { sources: { tx_inserted: 400, backfill_path_json: 60 }, sampleAt: '2026-01-01T00:01:00Z' }, + { sources: { tx_inserted: 405, backfill_path_json: 1060 }, sampleAt: '2026-01-01T00:01:01Z' }, + ]; + for (const sample of samples) { + stubFetch(sb, { ...basePerf, goRuntime }, goHealth, ioData, sqliteData, sample); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + } const html = sb.getHtml(); - // After phase2: tx_rate = 5/s, backfill_rate = 1000/s โ†’ ratio = 200x โ†’ โš ๏ธ const idx = html.indexOf('backfill_path_json'); assert.ok(idx >= 0, 'backfill_path_json row missing'); - const row = html.slice(idx, idx + 400); - assert.ok(row.includes('โš ๏ธ'), 'expected โš ๏ธ on backfill row when rate ratio >10ร—, row=' + row); + const row = html.slice(idx, html.indexOf('', idx)); + assert.ok(row.includes('1000.00') && row.includes('1.00'), 'renders current and baseline rates'); + assert.ok(row.includes('#ph-warning"'), 'expected warning on backfill row when rate ratio >10ร—, row=' + row); }); -await test('Backfill anomaly: tx_inserted <100 baseline guard SUPPRESSES โš ๏ธ', async () => { - // Same shape but tx_inserted stays well below the 100 floor; even a huge - // backfill rate ratio must NOT fire while we lack a meaningful baseline. +await test('Backfill anomaly: insufficient history suppresses the warning', async () => { + // Two snapshots cannot provide the required stable historical baseline, + // even if the current backfill rate is much larger than the tx rate. const sb = loadPerf(); - sb.ctx.window._perfWriteSourcesPrev = null; const t0 = '2026-01-01T00:00:00Z'; const t1 = '2026-01-01T00:00:01Z'; const phase1 = { sources: { tx_inserted: 5, backfill_path_json: 0 }, sampleAt: t0 }; @@ -279,8 +274,9 @@ await test('Backfill anomaly: tx_inserted <100 baseline guard SUPPRESSES โš ๏ธ' await new Promise(r => setTimeout(r, 50)); const html = sb.getHtml(); const idx = html.indexOf('backfill_path_json'); - const row = html.slice(idx, idx + 400); - assert.ok(!row.includes('โš ๏ธ'), 'expected NO โš ๏ธ on backfill row when tx_inserted<100, row=' + row); + assert.ok(idx >= 0, 'backfill_path_json row missing'); + const row = html.slice(idx, html.indexOf('', idx)); + assert.ok(!row.includes('#ph-warning"'), 'expected NO warning without a stable baseline, row=' + row); }); console.log(`\n${passed} passed, ${failed} failed\n`); diff --git a/test-preflight-xss-gate.js b/test-preflight-xss-gate.js index 0897d74e..0d738916 100644 --- a/test-preflight-xss-gate.js +++ b/test-preflight-xss-gate.js @@ -72,7 +72,9 @@ for (const c of cases) { failed++; continue; } - const tests = (c.tests || []).map(t => path.join(FIXTURE_DIR, t)); + // The shell protocol uses ':' separators. Relative paths avoid Windows + // drive-letter colons being mistaken for separators by its Python reader. + const tests = (c.tests || []).map(t => path.relative(__dirname, path.join(FIXTURE_DIR, t))); const env = Object.assign({}, process.env); if (tests.length > 0) { env.PREFLIGHT_TEST_FILES = tests.join(':'); @@ -80,6 +82,7 @@ for (const c of cases) { delete env.PREFLIGHT_TEST_FILES; } const res = spawnSync('bash', [SCRIPT, '--file', target], { + cwd: __dirname, env, encoding: 'utf8', }); diff --git a/test-rx-coverage-escape.js b/test-rx-coverage-escape.js index 3556546c..746d002f 100644 --- a/test-rx-coverage-escape.js +++ b/test-rx-coverage-escape.js @@ -17,12 +17,12 @@ const src = fs.readFileSync(path.join(__dirname, 'public', 'rx-coverage.js'), 'u // Slice from `var nm = o.name ...` through the end of the returned row string. const startMarker = 'var nm = o.name ? escapeHtml(o.name)'; -const endMarker = "o.nodes + '';"; +const endMarker = "}).join('');"; const startIdx = src.indexOf(startMarker); assert.ok(startIdx >= 0, 'could not locate row-builder start in rx-coverage.js'); const endIdx = src.indexOf(endMarker, startIdx); assert.ok(endIdx >= 0, 'could not locate row-builder end in rx-coverage.js'); -const block = src.slice(startIdx, endIdx + endMarker.length); +const block = src.slice(startIdx, endIdx); // Canonical escapeHtml (public/app.js). function escapeHtml(s) { diff --git a/test-test-inventory.js b/test-test-inventory.js new file mode 100644 index 00000000..81ff5f0f --- /dev/null +++ b/test-test-inventory.js @@ -0,0 +1,49 @@ +/* Every root test must have one explicit home; adding a file cannot skip CI silently. */ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +function checkInventory(files, groups) { + const assigned = new Set(); + const available = new Set(files); + for (const [group, spec] of Object.entries(groups)) { + assert.ok(spec.reason && spec.reason.trim(), group + ' needs a prerequisite/reason'); + for (const file of spec.files) { + assert.ok(available.has(file), group + ' lists a missing test: ' + file); + assert.ok(!assigned.has(file), 'test is listed more than once: ' + file); + assigned.add(file); + } + } + const missing = files.filter(file => !assigned.has(file)); + assert.deepStrictEqual(missing, [], 'unclassified root tests: ' + missing.join(', ')); +} + +// Exercise the guard itself: omissions, stale paths, duplicate classifications, +// and undocumented exclusions must fail, even when the rest of the list is valid. +const unit = files => ({ reason: 'Standalone Node checks', files }); +assert.doesNotThrow(() => checkInventory(['test-a.js'], { unit: unit(['test-a.js']) })); +assert.throws(() => checkInventory(['test-a.js', 'test-new.js'], { unit: unit(['test-a.js']) }), /unclassified root tests/); +assert.throws(() => checkInventory(['test-a.js'], { unit: unit(['test-old.js']) }), /missing test/); +assert.throws(() => checkInventory(['test-a.js'], { unit: unit(['test-a.js', 'test-a.js']) }), /more than once/); +assert.throws(() => checkInventory(['test-a.js'], { unit: unit(['test-a.js']), browser: unit(['test-a.js']) }), /more than once/); +assert.throws(() => checkInventory(['test-a.js'], { browser: { reason: '', files: ['test-a.js'] } }), /prerequisite\/reason/); + +const runner = fs.readFileSync(path.join(__dirname, 'test-all.sh'), 'utf8'); +const unitTests = Array.from(runner.matchAll(/^node (test-[^\s]+\.js)\s*$/gm), match => match[1]); +const groups = JSON.parse(fs.readFileSync(path.join(__dirname, 'scripts', 'non-unit-tests.json'), 'utf8')); +assert.ok(!Object.prototype.hasOwnProperty.call(groups, 'unit'), 'unit tests belong only in test-all.sh'); +const files = fs.readdirSync(__dirname).filter(file => /^test-.*\.js$/.test(file)).sort(); +checkInventory(files, { unit: unit(unitTests), ...groups }); + +const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); +assert.strictEqual(pkg.scripts['test:unit'], 'sh test-all.sh', 'npm test:unit must use the authoritative runner'); +assert.ok(pkg.scripts.test.includes('sh test-all.sh'), 'npm test must use the authoritative runner'); +assert.ok(pkg.scripts['test:coverage'].includes('sh test-all.sh'), 'coverage must use the authoritative runner'); +const workflow = fs.readFileSync(path.join(__dirname, '.github', 'workflows', 'deploy.yml'), 'utf8'); +const unitStep = workflow.match(/- name: Run JS unit tests[^\n]*\n([\s\S]*?)(?=\n - name:)/); +assert.ok(unitStep, 'CI must retain its JS unit-test step'); +assert.match(unitStep[1], /\bsh test-all\.sh\b/, 'CI must use the authoritative runner'); +assert.doesNotMatch(unitStep[1], /\bnode test-/, 'CI must not keep a second unit-test list'); +console.log('Test inventory: ' + files.length + ' root suites classified; ' + unitTests.length + ' run by local and CI unit checks.');
Last Seen${lastSeen}
Adverts${n.advert_count || 0}
Configured scope โœ“${n.configured_scope === '' ? 'none configured' : `${escapeHtml(n.configured_scope)}`}
Configured scope ${n.configured_scope === '' ? 'none configured' : `${escapeHtml(n.configured_scope)}`}
Scope${n.default_scope === null ? 'โ€”' : n.default_scope === '' ? 'unknown scope' : `${escapeHtml(n.default_scope)}` diff --git a/public/nodes.js b/public/nodes.js index f68b658f..5699bc95 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -735,7 +735,7 @@ return `
Bridge score โ“˜${bpct}% ${blabel}
Transported scopes${n.transported_scopes.map(sc => '' + escapeHtml(String(sc)) + '').join('')}
Configured scope โœ“${n.configured_scope === '' ? 'none configured' : `${escapeHtml(n.configured_scope)}`}
Configured scope ${n.configured_scope === '' ? 'none configured' : `${escapeHtml(n.configured_scope)}`}
First Seen${renderNodeTimestampHtml(n.first_seen)}
Total Packets${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' (seen ' + stats.totalObservations + 'ร—)' : ''}
Packets Today${stats.packetsToday || 0}