diff --git a/.badges/e2e-tests.json b/.badges/e2e-tests.json index 31f5ca4e..742c162b 100644 --- a/.badges/e2e-tests.json +++ b/.badges/e2e-tests.json @@ -1 +1 @@ -{"schemaVersion":1,"label":"e2e tests","message":"768 passed","color":"brightgreen"} +{"schemaVersion":1,"label":"e2e tests","message":"769 passed","color":"brightgreen"} diff --git a/.badges/frontend-coverage.json b/.badges/frontend-coverage.json index f2f425ed..8a281f7b 100644 --- a/.badges/frontend-coverage.json +++ b/.badges/frontend-coverage.json @@ -1 +1 @@ -{"schemaVersion":1,"label":"frontend coverage","message":"36.25%","color":"red"} +{"schemaVersion":1,"label":"frontend coverage","message":"35.66%","color":"red"} diff --git a/.eslintrc.json b/.eslintrc.json index 80f397a9..d178d5df 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -52,6 +52,7 @@ "ROLE_STYLE": "readonly", "ROUTE_TYPES": "readonly", "RegionFilter": "readonly", + "RegionShowAll": "readonly", "SITE_CONFIG": "readonly", "SKEW_SEVERITY_COLORS": "readonly", "SKEW_SEVERITY_LABELS": "readonly", diff --git a/cmd/server/config.go b/cmd/server/config.go index 661ca4bc..2c594c5f 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -359,9 +359,11 @@ func LoadConfig(baseDirs ...string) (*Config, error) { continue } cfg.NormalizeTimestampConfig() + applyCORSEnv(cfg) return cfg, nil } cfg.NormalizeTimestampConfig() + applyCORSEnv(cfg) return cfg, nil // defaults } diff --git a/cmd/server/cors.go b/cmd/server/cors.go index 9edc1ed6..9ed42b9b 100644 --- a/cmd/server/cors.go +++ b/cmd/server/cors.go @@ -1,10 +1,47 @@ package main -import "net/http" +import ( + "net/http" + "os" + "strings" +) + +// applyCORSEnv overlays cfg.CORSAllowedOrigins from the CORS_ALLOWED_ORIGINS +// env var when it is set and non-empty. Tokens are comma-separated, trimmed, +// and empties dropped. The env var is the ops-friendly override; it lets +// operators add cross-domain embed origins without editing config.json +// (issue #1369). An unset or empty env var leaves cfg untouched, so +// per-deployment config.json values still apply. +func applyCORSEnv(cfg *Config) { + raw, ok := os.LookupEnv("CORS_ALLOWED_ORIGINS") + if !ok { + return + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + s := strings.TrimSpace(p) + if s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + // Env var present but only whitespace — treat as unset, do not clobber. + return + } + cfg.CORSAllowedOrigins = out +} // corsMiddleware returns a middleware that sets CORS headers based on the // configured allowed origins. When CORSAllowedOrigins is empty (default), // no Access-Control-* headers are added, preserving browser same-origin policy. +// +// Embed contract (issue #1369): the cross-domain surface is read-only. The +// middleware advertises only GET, HEAD, and OPTIONS in Access-Control-Allow- +// Methods so iframes / server-side fetchers cannot opt into POST/PUT/DELETE +// via CORS. Same-origin writes (admin UI, API-key holders on the canonical +// origin) are unaffected — they never go through the preflight path. +// Credentialed CORS is intentionally NOT enabled. func (s *Server) corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origins := s.cfg.CORSAllowedOrigins @@ -52,7 +89,8 @@ func (s *Server) corsMiddleware(next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Origin", reqOrigin) w.Header().Set("Vary", "Origin") } - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + // Read-only embed contract — see comment above. + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key") // Handle preflight diff --git a/cmd/server/cors_embed_1369_test.go b/cmd/server/cors_embed_1369_test.go new file mode 100644 index 00000000..d57b82fe --- /dev/null +++ b/cmd/server/cors_embed_1369_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" +) + +// Issue #1369: CORS_ALLOWED_ORIGINS env override + embed support. +// +// Red commit: these tests fail until LoadConfig honors the env var and the +// CORS middleware advertises GET/HEAD/OPTIONS (the embed contract is +// read-only cross-origin access). + +// TestCORS_EnvOverridesConfig — env var CORS_ALLOWED_ORIGINS replaces config. +func TestCORS_EnvOverridesConfig_1369(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", "https://blog.example.com,https://embed.example.com") + cfg, err := LoadConfig("/nonexistent") + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if len(cfg.CORSAllowedOrigins) != 2 { + t.Fatalf("expected 2 origins from env, got %v", cfg.CORSAllowedOrigins) + } + if cfg.CORSAllowedOrigins[0] != "https://blog.example.com" || + cfg.CORSAllowedOrigins[1] != "https://embed.example.com" { + t.Fatalf("env parse wrong: %v", cfg.CORSAllowedOrigins) + } +} + +// TestCORS_EnvEmptyKeepsConfig — empty env var does not clobber file config. +func TestCORS_EnvEmptyKeepsConfig_1369(t *testing.T) { + os.Unsetenv("CORS_ALLOWED_ORIGINS") + cfg := &Config{CORSAllowedOrigins: []string{"https://example.com"}} + applyCORSEnv(cfg) + if len(cfg.CORSAllowedOrigins) != 1 || cfg.CORSAllowedOrigins[0] != "https://example.com" { + t.Fatalf("unset env should not clobber config; got %v", cfg.CORSAllowedOrigins) + } +} + +// TestCORS_EnvTrimsWhitespace — comma-separated env tokens are trimmed. +func TestCORS_EnvTrimsWhitespace_1369(t *testing.T) { + t.Setenv("CORS_ALLOWED_ORIGINS", " https://a.example , https://b.example ") + cfg := &Config{} + applyCORSEnv(cfg) + if len(cfg.CORSAllowedOrigins) != 2 { + t.Fatalf("expected 2, got %v", cfg.CORSAllowedOrigins) + } + if cfg.CORSAllowedOrigins[0] != "https://a.example" || cfg.CORSAllowedOrigins[1] != "https://b.example" { + t.Fatalf("not trimmed: %v", cfg.CORSAllowedOrigins) + } +} + +// TestCORS_EmbedContractGETHEAD — embed contract is read-only; the +// Access-Control-Allow-Methods header must advertise GET, HEAD, OPTIONS only +// (no POST/PUT/DELETE) so iframes/server-side fetchers know writes are not +// CORS-permitted. DJB hardening: minimum surface. +func TestCORS_EmbedContractGETHEAD_1369(t *testing.T) { + srv := newTestServerWithCORS([]string{"https://embed.example.com"}) + handler := srv.corsMiddleware(dummyHandler) + + req := httptest.NewRequest("GET", "/api/health", nil) + req.Header.Set("Origin", "https://embed.example.com") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + methods := rr.Header().Get("Access-Control-Allow-Methods") + if methods != "GET, HEAD, OPTIONS" { + t.Fatalf("expected read-only methods 'GET, HEAD, OPTIONS', got %q", methods) + } +} + +// TestCORS_PreflightPOSTRejected — preflight asking for POST from an allowed +// origin must NOT echo POST in Allow-Methods. The middleware advertises only +// the read-only set; preflight succeeds (browser then blocks the POST). +func TestCORS_PreflightPOSTRejected_1369(t *testing.T) { + srv := newTestServerWithCORS([]string{"https://embed.example.com"}) + handler := srv.corsMiddleware(dummyHandler) + + req := httptest.NewRequest("OPTIONS", "/api/anything", nil) + req.Header.Set("Origin", "https://embed.example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("preflight expected 204, got %d", rr.Code) + } + if got := rr.Header().Get("Access-Control-Allow-Methods"); got != "GET, HEAD, OPTIONS" { + t.Fatalf("preflight must advertise read-only methods only, got %q", got) + } +} diff --git a/cmd/server/cors_test.go b/cmd/server/cors_test.go index acafaaee..676016a0 100644 --- a/cmd/server/cors_test.go +++ b/cmd/server/cors_test.go @@ -51,7 +51,7 @@ func TestCORS_AllowlistMatch(t *testing.T) { if v := rr.Header().Get("Access-Control-Allow-Origin"); v != "https://good.example" { t.Fatalf("expected origin echo, got %q", v) } - if v := rr.Header().Get("Access-Control-Allow-Methods"); v != "GET, POST, OPTIONS" { + if v := rr.Header().Get("Access-Control-Allow-Methods"); v != "GET, HEAD, OPTIONS" { t.Fatalf("expected methods header, got %q", v) } if v := rr.Header().Get("Access-Control-Allow-Headers"); v != "Content-Type, X-API-Key" { diff --git a/config.example.json b/config.example.json index beccc1d4..dc7aa684 100644 --- a/config.example.json +++ b/config.example.json @@ -17,6 +17,8 @@ "_comment": "vacuumOnStartup: run one-time full VACUUM to enable incremental auto-vacuum on existing DBs. Executed by the INGESTOR at startup, BEFORE the MQTT subscriber starts (#1283), so there is no contention with concurrent writes. Blocks ingestor startup for minutes on large DBs; requires 2x DB file size in free disk space. incrementalVacuumPages: free pages returned to OS after each retention reaper cycle (default 1024). See #919." }, "_comment_ingestorStats": "Ingestor publishes a 1-Hz stats snapshot consumed by the server's /api/perf/io and /api/perf/write-sources endpoints (#1120). Path is configured via the CORESCOPE_INGESTOR_STATS environment variable on the INGESTOR process. Default: /tmp/corescope-ingestor-stats.json. The writer uses O_NOFOLLOW + 0o600, so a pre-planted symlink in /tmp cannot be used to clobber an arbitrary file. SECURITY: in shared-tmp environments (multi-tenant hosts), point CORESCOPE_INGESTOR_STATS at a private directory like /var/lib/corescope/ingestor-stats.json that only the corescope user can write to.", + "corsAllowedOrigins": [], + "_comment_corsAllowedOrigins": "Cross-origin allowlist for embed scenarios (#1369). Exact-match origins, e.g. [\"https://blog.example.com\", \"https://embed.example.com\"]. When empty (default), no Access-Control-* headers are sent and browsers enforce same-origin. When non-empty, only the listed origins receive CORS headers, and Access-Control-Allow-Methods is limited to GET, HEAD, OPTIONS (the cross-domain surface is read-only — same-origin admin writes are unaffected). Use [\"*\"] to allow any origin (NOT recommended for write-capable deployments). Operators can override per-deployment with the CORS_ALLOWED_ORIGINS environment variable (comma-separated). No credentialed CORS is enabled. To embed the map or channels pages cross-domain, add the embedding origin here and use the URL pattern '/#/map?embed=1' or '/#/channels?embed=1' — embed mode hides the top-nav, bottom-nav, and side drawer for full-bleed iframe rendering.", "https": { "cert": "/path/to/cert.pem", "key": "/path/to/key.pem", diff --git a/public/app.js b/public/app.js index f31d39b4..fb35c0e0 100644 --- a/public/app.js +++ b/public/app.js @@ -175,6 +175,21 @@ function getHashParams() { return new URLSearchParams(location.hash.split('?')[1] || ''); } +// shouldEmbedRoute — issue #1369. Returns true when the SPA should render in +// "embed" mode (chrome suppressed: no top-nav, no bottom-nav, no side drawer, +// content full-bleed). Triggered by ?embed=1 in the hash query string. +// +// Allowlisted to /#/map and /#/channels — the two surfaces operators asked +// for in the cross-domain embed scenario. Other pages have chrome assumptions +// we are not committing to in embed mode (Tufte: ship narrow, expand later +// only when there is a real ask). +function shouldEmbedRoute(basePage, hashSearch) { + if (basePage !== 'map' && basePage !== 'channels') return false; + if (!hashSearch) return false; + var params = new URLSearchParams(hashSearch); + return params.get('embed') === '1'; +} + function getDistanceUnit() { var stored = localStorage.getItem('meshcore-distance-unit'); if (stored === 'km') return 'km'; @@ -928,6 +943,14 @@ function navigate() { // Pages with fixed-height containers (maps, virtual-scroll, split-panels) const fixedPages = { packets: 1, nodes: 1, map: 1, live: 1, channels: 1, 'audio-lab': 1 }; app.classList.toggle('app-fixed', basePage in fixedPages); + + // Issue #1369: ?embed=1 chrome suppression for cross-domain iframe embeds. + // Toggles body.embed; CSS in style.css hides top-nav / bottom-nav / nav-drawer + // and zeroes body padding so /#/map and /#/channels render full-bleed. + try { + var hashSearch = (location.hash.split('?')[1] || ''); + document.body.classList.toggle('embed', shouldEmbedRoute(basePage, hashSearch)); + } catch (_) { /* DOM may be missing in some test contexts */ } if (pages[basePage]?.init) { const t0 = performance.now(); pages[basePage].init(app, routeParam); diff --git a/public/live.css b/public/live.css index 6789b207..fe64c6ca 100644 --- a/public/live.css +++ b/public/live.css @@ -449,6 +449,16 @@ .live-toggles .live-region-filter-container { display: inline-flex; align-items: center; } .live-toggles .live-region-filter-container .region-dropdown-trigger { font-size: inherit; padding: 2px 6px; } +/* #1108 — "Show all nodes" sibling of the region dropdown. Reuses the + .live-toggles label rhythm so it lines up with the other inline toggles. */ +.live-toggles .live-show-all-region-nodes { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: inherit; + white-space: nowrap; +} + /* ---- Leaflet overrides for dark theme ---- */ .live-page .leaflet-control-zoom a { background: color-mix(in srgb, var(--surface-1) 92%, transparent) !important; diff --git a/public/live.js b/public/live.js index 893db5e3..fb39623a 100644 --- a/public/live.js +++ b/public/live.js @@ -1424,7 +1424,32 @@ setObserverIataMap(buildObserverIataMap(data)); }).catch(function () { /* leave map empty; filter will hide all when active */ }); RegionFilter.init(rfEl, { dropdown: true }); - regionFilterChangeHandler = RegionFilter.onChange(function () { /* selection persisted by RegionFilter; future packets reflect it */ }); + regionFilterChangeHandler = RegionFilter.onChange(function() { + // #1108 — when the region selection changes, reload visible map + // nodes so non-region nodes disappear (or reappear) immediately. + // The packet feed already filters live via packetMatchesRegion. + try { loadNodes(); } catch (e) { /* loadNodes not yet defined during init order edge cases */ } + }); + // #1108 — "Show all nodes (faded)" sub-toggle, sibling to the region + // dropdown. Off by default = hide non-region nodes; on = legacy + // show-everything behavior. + (function initShowAllNodesToggle() { + if (!window.RegionShowAll) return; + var wrap = document.createElement('label'); + wrap.className = 'live-show-all-region-nodes'; + wrap.title = 'When a region is selected, show every node on the map (legacy behavior). Off = hide non-region nodes.'; + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.id = 'liveShowAllRegionNodes'; + cb.checked = RegionShowAll.get(); + wrap.appendChild(cb); + wrap.appendChild(document.createTextNode(' Show all nodes')); + rfEl.parentNode.insertBefore(wrap, rfEl.nextSibling); + cb.addEventListener('change', function() { + RegionShowAll.set(cb.checked); + try { loadNodes(); } catch (e) {} + }); + })(); })(); // Node filter input — autocomplete-as-you-type (#1110) @@ -2286,9 +2311,13 @@ async function loadNodes(beforeTs) { try { const aqs = AreaFilter.areaQueryString(); + // #1108 — honor region selector for visible map nodes unless + // "Show all nodes" is enabled. Empty string when no region set. + const rqs = (window.RegionFilter && typeof RegionFilter.nodesRegionQueryString === 'function') + ? RegionFilter.nodesRegionQueryString() : ''; const url = beforeTs - ? `/api/nodes?limit=2000&before=${encodeURIComponent(new Date(beforeTs).toISOString())}${aqs}` - : `/api/nodes?limit=2000${aqs}`; + ? `/api/nodes?limit=2000&before=${encodeURIComponent(new Date(beforeTs).toISOString())}${aqs}${rqs}` + : `/api/nodes?limit=2000${aqs}${rqs}`; // Full reload (no beforeTs): clear existing markers so switching areas // removes nodes that no longer belong to the selected area. if (!beforeTs) { diff --git a/public/region-filter.js b/public/region-filter.js index 31dfbabe..69f88fc7 100644 --- a/public/region-filter.js +++ b/public/region-filter.js @@ -213,6 +213,49 @@ if (_container) render(_container); } + /** + * #1108 — "Show all nodes (faded)" toggle. + * + * When a region is selected, the default behavior (showAll = false) is to + * HIDE non-region nodes on the map: the operator is looking at a region for + * a reason, and far-away nodes are visual noise. When the toggle is ON + * (showAll = true), legacy behavior is restored — all nodes load, region + * scoping only applies to packet feeds / metrics. + * + * State persists across reloads in localStorage. Default: false (hide). + */ + var SHOW_ALL_KEY = 'mc-region-show-all-nodes'; + var _showAllListeners = []; + function showAllGet() { + try { return localStorage.getItem(SHOW_ALL_KEY) === 'true'; } + catch (e) { return false; } + } + function showAllSet(v) { + var bool = !!v; + try { + if (bool) localStorage.setItem(SHOW_ALL_KEY, 'true'); + else localStorage.removeItem(SHOW_ALL_KEY); + } catch (e) { /* ignore */ } + _showAllListeners.forEach(function (fn) { fn(bool); }); + } + function showAllOnChange(fn) { _showAllListeners.push(fn); return fn; } + function showAllOffChange(fn) { + _showAllListeners = _showAllListeners.filter(function (f) { return f !== fn; }); + } + + /** + * Build a node-list query fragment that respects the "show all nodes" toggle. + * Returns "®ion=SJC,SFO" only when a region is selected AND showAll is + * OFF; otherwise empty string. Use this for /api/nodes? requests on map + * surfaces where the operator expects the visible markers to follow the + * region selector. Other surfaces (packets, metrics) should keep using + * regionQueryString() which is unconditional. + */ + function nodesRegionQueryString() { + if (showAllGet()) return ''; + return regionQueryString(); + } + // Expose globally window.RegionFilter = { init: initFilter, @@ -220,9 +263,17 @@ getSelected: getSelected, getRegionParam: getRegionParam, regionQueryString: regionQueryString, + nodesRegionQueryString: nodesRegionQueryString, onChange: onChange, offChange: offChange, fetchRegions: fetchRegions, setSelected: setSelected }; + window.RegionShowAll = { + get: showAllGet, + set: showAllSet, + onChange: showAllOnChange, + offChange: showAllOffChange, + STORAGE_KEY: SHOW_ALL_KEY + }; })(); diff --git a/public/style.css b/public/style.css index 34d67493..aaf9f8ff 100644 --- a/public/style.css +++ b/public/style.css @@ -4659,3 +4659,20 @@ body { touch-action: pan-y; } } .mc-route-edge { stroke: CanvasText !important; } } + +/* =========================================================================== + Issue #1369 — embed mode (?embed=1 on /#/map and /#/channels) + Hide all chrome for full-bleed cross-domain iframe embeds. body.embed is + toggled in app.js navigate() based on shouldEmbedRoute(). + =========================================================================== */ +body.embed .top-nav, +body.embed [data-bottom-nav], +body.embed .nav-drawer, +body.embed .nav-drawer-backdrop { display: none !important; } +body.embed { padding: 0 !important; margin: 0 !important; } +/* App container occupies full viewport in embed mode — top-nav (52px) and + bottom-nav reserve are gone, so reclaim the full dvh. */ +body.embed #app.app-fixed { + height: 100vh !important; + height: 100dvh !important; +} diff --git a/test-1108-region-hide-nodes.js b/test-1108-region-hide-nodes.js new file mode 100644 index 00000000..c693bfee --- /dev/null +++ b/test-1108-region-hide-nodes.js @@ -0,0 +1,93 @@ +/** + * Issue #1108 — Hide non-region nodes when a region is selected on Live map. + * + * Unit tests for the public helpers added to region-filter.js: + * - RegionShowAll.get() / set() with localStorage persistence + * - RegionFilter.nodesRegionQueryString() — returns ®ion= when filter + * active AND showAll is OFF; empty string otherwise. + * + * These tests load the module via vm sandbox with mocked globals + * (no DOM, no fetch). Mirrors the pattern from test-area-filter.js. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` \u2713 ${name}`); } + catch (e) { failed++; console.error(` \u2717 ${name}: ${e.message}`); } +} + +function buildCtx(initialStorage) { + const storage = Object.assign(Object.create(null), initialStorage || {}); + const localStorage = { + getItem: (k) => (k in storage ? storage[k] : null), + setItem: (k, v) => { storage[k] = String(v); }, + removeItem: (k) => { delete storage[k]; }, + }; + const ctx = { + window: {}, + document: { addEventListener() {} }, + localStorage, + fetch: async () => ({ json: async () => ({}) }), + console, + setTimeout, clearTimeout, + }; + ctx.window = ctx; + vm.createContext(ctx); + const src = fs.readFileSync(__dirname + '/public/region-filter.js', 'utf8'); + vm.runInContext(src, ctx); + return ctx; +} + +console.log('#1108 RegionShowAll + nodesRegionQueryString unit tests'); + +test('RegionShowAll exposed on window', () => { + const ctx = buildCtx(); + assert.ok(ctx.window.RegionShowAll, 'expected window.RegionShowAll'); + assert.strictEqual(typeof ctx.window.RegionShowAll.get, 'function'); + assert.strictEqual(typeof ctx.window.RegionShowAll.set, 'function'); +}); + +test('RegionShowAll.get() defaults to false', () => { + const ctx = buildCtx(); + assert.strictEqual(ctx.window.RegionShowAll.get(), false); +}); + +test('RegionShowAll.set(true) persists; rebuild loads it back', () => { + const ctx = buildCtx(); + ctx.window.RegionShowAll.set(true); + assert.strictEqual(ctx.window.RegionShowAll.get(), true); + const ctx2 = buildCtx({ 'mc-region-show-all-nodes': 'true' }); + assert.strictEqual(ctx2.window.RegionShowAll.get(), true); +}); + +test('RegionShowAll.set(false) clears persisted value', () => { + const ctx = buildCtx({ 'mc-region-show-all-nodes': 'true' }); + assert.strictEqual(ctx.window.RegionShowAll.get(), true); + ctx.window.RegionShowAll.set(false); + assert.strictEqual(ctx.window.RegionShowAll.get(), false); +}); + +test('nodesRegionQueryString returns ®ion= when filter set + showAll off', () => { + const ctx = buildCtx({ 'meshcore-region-filter': '["SJC"]' }); + ctx.window.RegionShowAll.set(false); + assert.strictEqual(ctx.window.RegionFilter.nodesRegionQueryString(), '®ion=SJC'); +}); + +test('nodesRegionQueryString empty when showAll on (legacy show-everything behavior)', () => { + const ctx = buildCtx({ 'meshcore-region-filter': '["SJC"]' }); + ctx.window.RegionShowAll.set(true); + assert.strictEqual(ctx.window.RegionFilter.nodesRegionQueryString(), ''); +}); + +test('nodesRegionQueryString empty when no region selected', () => { + const ctx = buildCtx(); + ctx.window.RegionShowAll.set(false); + assert.strictEqual(ctx.window.RegionFilter.nodesRegionQueryString(), ''); +}); + +console.log(`\nResults: ${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); diff --git a/test-embed-mode-1369.js b/test-embed-mode-1369.js new file mode 100644 index 00000000..480d5f13 --- /dev/null +++ b/test-embed-mode-1369.js @@ -0,0 +1,98 @@ +/* Unit tests for issue #1369 embed-mode helper. + * + * Red commit: shouldEmbedRoute() does not exist yet; this test fails on import. + * Green commit: define shouldEmbedRoute(basePage, hashSearch) in public/app.js + * and expose for tests. + * + * The contract: + * - returns true ONLY when basePage is 'map' or 'channels' AND the hash + * query string contains embed=1 (e.g. '#/map?embed=1' → search='embed=1'). + * - false for any other route, false when embed param is missing or != '1'. + * - the route-allowlist is deliberate: other pages have chrome assumptions + * that we are not committing to support in embed mode (Tufte: scope tight, + * ship the two surfaces operators asked for, no more). + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +// Load app.js into a sandbox. We only need the helper, so we wrap with a +// minimal browser shim that no-ops everything app.js touches at import time. +const appSrc = fs.readFileSync(path.join(__dirname, 'public', 'app.js'), 'utf8'); +const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {}, matchMedia: () => ({ matches: false }) }, + document: { + readyState: 'complete', + documentElement: { setAttribute: () => {}, getAttribute: () => null, classList: { add: () => {}, remove: () => {}, toggle: () => {} } }, + body: { classList: { add: () => {}, remove: () => {}, toggle: () => {} } }, + createElement: () => ({ id: '', textContent: '', innerHTML: '', classList: { add: () => {}, remove: () => {} }, setAttribute: () => {}, appendChild: () => {} }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Math, JSON, RegExp, Error, TypeError, Map, Set, + Array, Object, String, Number, Boolean, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, URLSearchParams, + setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + location: { hash: '', search: '' }, + CustomEvent: class {}, + navigator: { language: 'en-US' }, + requestAnimationFrame: () => 0, +}; +ctx.globalThis = ctx; +ctx.self = ctx; +try { + vm.createContext(ctx); + vm.runInContext(appSrc, ctx, { filename: 'app.js' }); +} catch (e) { + // app.js does a lot at import; if any helper init blows up, it's fine as + // long as shouldEmbedRoute is bound by the time we test it. + console.log(' (app.js init threw: ' + e.message + ' — proceeding)'); +} + +console.log('issue #1369 — shouldEmbedRoute'); +test('exists', () => { + assert.strictEqual(typeof ctx.shouldEmbedRoute, 'function', 'shouldEmbedRoute must be defined on window/global'); +}); +test('map + embed=1 → true', () => { + assert.strictEqual(ctx.shouldEmbedRoute('map', 'embed=1'), true); +}); +test('channels + embed=1 → true', () => { + assert.strictEqual(ctx.shouldEmbedRoute('channels', 'embed=1'), true); +}); +test('map + embed=1 mixed with other params → true', () => { + assert.strictEqual(ctx.shouldEmbedRoute('map', 'region=SFO&embed=1&zoom=8'), true); +}); +test('packets + embed=1 → false (route not in allowlist)', () => { + assert.strictEqual(ctx.shouldEmbedRoute('packets', 'embed=1'), false); +}); +test('nodes + embed=1 → false', () => { + assert.strictEqual(ctx.shouldEmbedRoute('nodes', 'embed=1'), false); +}); +test('map + no embed param → false', () => { + assert.strictEqual(ctx.shouldEmbedRoute('map', 'region=SFO'), false); +}); +test('map + embed=0 → false', () => { + assert.strictEqual(ctx.shouldEmbedRoute('map', 'embed=0'), false); +}); +test('map + empty search → false', () => { + assert.strictEqual(ctx.shouldEmbedRoute('map', ''), false); +}); + +console.log('\n' + '═'.repeat(40)); +console.log(' embed-mode helper: ' + passed + ' passed, ' + failed + ' failed'); +console.log('═'.repeat(40) + '\n'); +if (failed > 0) process.exit(1);