Merge upstream/master and resolve RegionFilter conflict

This commit is contained in:
Chris Phipps
2026-05-30 20:06:24 -04:00
15 changed files with 465 additions and 8 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"768 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"769 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"36.25%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"35.66%","color":"red"}
+1
View File
@@ -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",
+2
View File
@@ -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
}
+40 -2
View File
@@ -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
+93
View File
@@ -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)
}
}
+1 -1
View File
@@ -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" {
+2
View File
@@ -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",
+23
View File
@@ -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);
+10
View File
@@ -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;
+32 -3
View File
@@ -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) {
+51
View File
@@ -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 "&region=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
};
})();
+17
View File
@@ -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;
}
+93
View File
@@ -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 &region= 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 &region= when filter set + showAll off', () => {
const ctx = buildCtx({ 'meshcore-region-filter': '["SJC"]' });
ctx.window.RegionShowAll.set(false);
assert.strictEqual(ctx.window.RegionFilter.nodesRegionQueryString(), '&region=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);
+98
View File
@@ -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);