mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-14 07:50:05 +00:00
Merge upstream/master and resolve RegionFilter conflict
This commit is contained in:
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"768 passed","color":"brightgreen"}
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"769 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"36.25%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"35.66%","color":"red"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user