mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-12 22:09:44 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0878d91e97 | ||
|
|
2a6a5b578e | ||
|
|
6ec08acb9a | ||
|
|
99313ea2a8 |
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"725 passed","color":"brightgreen"}
|
||||
{"schemaVersion":1,"label":"e2e tests","message":"717 passed","color":"brightgreen"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"36.05%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"38.43%","color":"red"}
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
"PULL_THRESHOLD_PX": "readonly",
|
||||
"PacketFilter": "readonly",
|
||||
"PathInspector": "readonly",
|
||||
"PrefixReserved": "readonly",
|
||||
"QRCode": "readonly",
|
||||
"ROLE_COLORS": "readonly",
|
||||
"ROLE_EMOJI": "readonly",
|
||||
@@ -60,12 +59,6 @@
|
||||
"SlideOver": "readonly",
|
||||
"TILE_DARK": "readonly",
|
||||
"TILE_LIGHT": "readonly",
|
||||
"MC_TILE_PROVIDERS": "readonly",
|
||||
"MC_setDarkTileProvider": "readonly",
|
||||
"MC_getDarkTileProvider": "readonly",
|
||||
"MC_setServerDefaultTileProvider": "readonly",
|
||||
"MC_applyTileFilter": "readonly",
|
||||
"MC_DARK_TILE_DEFAULT": "readonly",
|
||||
"TYPE_COLORS": "readonly",
|
||||
"TableResponsive": "readonly",
|
||||
"TableSort": "readonly",
|
||||
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -99,7 +99,6 @@ jobs:
|
||||
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
|
||||
@@ -112,16 +111,6 @@ jobs:
|
||||
node test-issue-1364-pill-no-clamp.js
|
||||
node test-issue-1375-scope-stats-fetch.js
|
||||
node test-issue-1361-cb-presets.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-1438-marker-css-vars.js
|
||||
node test-live.js
|
||||
|
||||
- name: 🧹 Frontend lint (eslint no-undef) — issue #1342
|
||||
@@ -277,13 +266,12 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1311-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1391-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1413-nav-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1400-nav-vertical-clip.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1185-scroll-discriminator-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gesture-hints-1065-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1402-gesture-hints-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-touch-gestures-coverage-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
+1
-10
@@ -286,24 +286,15 @@ func LoadConfig(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
// ResolvedSources returns the final list of MQTT sources to connect to.
|
||||
//
|
||||
// Scheme mapping:
|
||||
//
|
||||
// mqtt:// → tcp:// (paho plain TCP)
|
||||
// mqtts:// → ssl:// (paho TLS over TCP)
|
||||
// ws:// (paho WebSocket — passed through, no mapping needed)
|
||||
// wss:// (paho WebSocket TLS — passed through, no mapping needed)
|
||||
func (c *Config) ResolvedSources() []MQTTSource {
|
||||
for i := range c.MQTTSources {
|
||||
// paho uses tcp:// and ssl:// for plain MQTT; ws:// and wss:// are accepted natively.
|
||||
// paho uses tcp:// and ssl:// not mqtt:// and mqtts://
|
||||
b := c.MQTTSources[i].Broker
|
||||
if strings.HasPrefix(b, "mqtt://") {
|
||||
c.MQTTSources[i].Broker = "tcp://" + b[7:]
|
||||
} else if strings.HasPrefix(b, "mqtts://") {
|
||||
c.MQTTSources[i].Broker = "ssl://" + b[8:]
|
||||
}
|
||||
// ws:// and wss:// pass through unchanged — paho handles WebSocket
|
||||
// connections natively via gorilla/websocket.
|
||||
}
|
||||
return c.MQTTSources
|
||||
}
|
||||
|
||||
@@ -394,93 +394,3 @@ func TestMQTTSourceRegionField(t *testing.T) {
|
||||
t.Fatalf("expected region PDX, got %q", cfg.MQTTSources[0].Region)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvedSourcesSchemeMapping verifies that mqtt:// and mqtts:// are translated
|
||||
// to the paho-native tcp:// and ssl:// schemes, while ws:// and wss:// pass through
|
||||
// unchanged (paho handles WebSocket connections natively).
|
||||
func TestResolvedSourcesSchemeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"mqtt://host:1883", "tcp://host:1883"},
|
||||
{"mqtts://host:8883", "ssl://host:8883"},
|
||||
{"tcp://host:1883", "tcp://host:1883"},
|
||||
{"ssl://host:8883", "ssl://host:8883"},
|
||||
{"ws://host:9001", "ws://host:9001"},
|
||||
{"wss://host:9001", "wss://host:9001"},
|
||||
{"ws://host:9001/mqtt", "ws://host:9001/mqtt"},
|
||||
{"wss://host:9001/mqtt", "wss://host:9001/mqtt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
cfg := &Config{
|
||||
MQTTSources: []MQTTSource{
|
||||
{Name: "test", Broker: tt.input, Topics: []string{"meshcore/#"}},
|
||||
},
|
||||
}
|
||||
sources := cfg.ResolvedSources()
|
||||
if got := sources[0].Broker; got != tt.want {
|
||||
t.Errorf("ResolvedSources(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigWSSource verifies that a WebSocket MQTT source round-trips through
|
||||
// LoadConfig correctly — username/password preserved, scheme unchanged.
|
||||
func TestLoadConfigWSSource(t *testing.T) {
|
||||
t.Setenv("DB_PATH", "")
|
||||
t.Setenv("MQTT_BROKER", "")
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
os.WriteFile(cfgPath, []byte(`{
|
||||
"dbPath": "test.db",
|
||||
"mqttSources": [
|
||||
{
|
||||
"name": "local-tcp",
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topics": ["meshcore/#"]
|
||||
},
|
||||
{
|
||||
"name": "wsmqtt-ws",
|
||||
"broker": "wss://wsmqtt.example.com/mqtt",
|
||||
"username": "corescope",
|
||||
"password": "s3cr3t",
|
||||
"topics": ["meshcore/#"]
|
||||
}
|
||||
]
|
||||
}`), 0o644)
|
||||
|
||||
cfg, err := LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.MQTTSources) != 2 {
|
||||
t.Fatalf("mqttSources len=%d, want 2", len(cfg.MQTTSources))
|
||||
}
|
||||
|
||||
tcp := cfg.MQTTSources[0]
|
||||
if tcp.Name != "local-tcp" {
|
||||
t.Errorf("name=%s, want local-tcp", tcp.Name)
|
||||
}
|
||||
|
||||
ws := cfg.MQTTSources[1]
|
||||
if ws.Name != "wsmqtt-ws" {
|
||||
t.Errorf("name=%s, want wsmqtt-ws", ws.Name)
|
||||
}
|
||||
if ws.Broker != "wss://wsmqtt.example.com/mqtt" {
|
||||
t.Errorf("broker=%s, want wss://wsmqtt.example.com/mqtt", ws.Broker)
|
||||
}
|
||||
if ws.Username != "corescope" {
|
||||
t.Errorf("username=%s, want corescope", ws.Username)
|
||||
}
|
||||
if ws.Password != "s3cr3t" {
|
||||
t.Errorf("password=%s, want s3cr3t", ws.Password)
|
||||
}
|
||||
|
||||
sources := cfg.ResolvedSources()
|
||||
if sources[1].Broker != "wss://wsmqtt.example.com/mqtt" {
|
||||
t.Errorf("ResolvedSources wss broker=%s, want unchanged", sources[1].Broker)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-17
@@ -748,11 +748,9 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
|
||||
err := s.stmtGetObserverRowid.QueryRow(data.ObserverID).Scan(&rowid)
|
||||
if err == nil {
|
||||
observerIdx = &rowid
|
||||
// observer.last_seen and last_packet_at answer "when did the analyzer
|
||||
// last hear from this observer" — both are ingest-time questions.
|
||||
// Per-packet rxTime is stored separately on observations/transmissions
|
||||
// using envelope time (see InsertTransmission above). See #1465.
|
||||
_, _ = s.stmtUpdateObserverLastSeen.Exec(ingestNow, ingestNow, ingestNow, ingestNow, rowid)
|
||||
// Update observer last_seen and last_packet_at on every packet to prevent
|
||||
// low-traffic observers from appearing offline (#463)
|
||||
_, _ = s.stmtUpdateObserverLastSeen.Exec(ingestNow, rxTime, ingestNow, rxTime, rowid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,20 +1043,14 @@ func (s *Store) RunIncrementalVacuum(pages int) {
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint runs a WAL checkpoint (TRUNCATE mode).
|
||||
// Returns the number of WAL frames checkpointed (0 if WAL was already empty).
|
||||
// TRUNCATE resets the WAL file to zero bytes when all frames are checkpointed;
|
||||
// if active readers hold frames, it checkpoints what it can and leaves the rest.
|
||||
func (s *Store) Checkpoint() int {
|
||||
var busy, walFrames, checkpointed int
|
||||
if err := s.db.QueryRow("PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &walFrames, &checkpointed); err != nil {
|
||||
// Checkpoint forces a WAL checkpoint to release the WAL lock file,
|
||||
// preventing lock contention with a new process starting up.
|
||||
func (s *Store) Checkpoint() {
|
||||
if _, err := s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
log.Printf("[db] WAL checkpoint error: %v", err)
|
||||
return 0
|
||||
} else {
|
||||
log.Println("[db] WAL checkpoint complete")
|
||||
}
|
||||
if walFrames > 0 {
|
||||
log.Printf("[db] WAL checkpoint: %d/%d frames checkpointed (blocked=%v)", checkpointed, walFrames, busy != 0)
|
||||
}
|
||||
return checkpointed
|
||||
}
|
||||
|
||||
// BackfillPathJSONAsync launches the path_json backfill in a background goroutine.
|
||||
|
||||
+7
-23
@@ -554,26 +554,18 @@ func TestInsertTransmissionUpdatesObserverLastSeen(t *testing.T) {
|
||||
PathJSON: "[]",
|
||||
DecodedJSON: `{"type":"TXT_MSG"}`,
|
||||
}
|
||||
before := time.Now().Unix()
|
||||
if _, err := s.InsertTransmission(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := time.Now().Unix()
|
||||
|
||||
// Verify last_seen was updated to INGEST time, not envelope time (#1465).
|
||||
// Verify last_seen was updated
|
||||
var lastSeenAfter string
|
||||
s.db.QueryRow("SELECT last_seen FROM observers WHERE id = ?", "obs1").Scan(&lastSeenAfter)
|
||||
if lastSeenAfter == oldTime {
|
||||
t.Error("observer last_seen was NOT updated after packet insertion — low-traffic observers will appear offline")
|
||||
}
|
||||
ls, err := time.Parse(time.RFC3339, lastSeenAfter)
|
||||
if err != nil {
|
||||
t.Fatalf("last_seen %q not RFC3339: %v", lastSeenAfter, err)
|
||||
}
|
||||
if ls.Unix() < before-5 || ls.Unix() > after+5 {
|
||||
t.Errorf("expected last_seen ≈ server now (in [%d, %d]), got %s (epoch %d). "+
|
||||
"observer.last_seen must use ingest time, not envelope time (#1465).",
|
||||
before, after, lastSeenAfter, ls.Unix())
|
||||
if lastSeenAfter != "2026-03-25T01:00:00Z" {
|
||||
t.Errorf("expected last_seen=2026-03-25T01:00:00Z, got %s", lastSeenAfter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,26 +598,18 @@ func TestLastPacketAtUpdatedOnPacketOnly(t *testing.T) {
|
||||
PathJSON: "[]",
|
||||
DecodedJSON: `{"type":"TXT_MSG"}`,
|
||||
}
|
||||
before := time.Now().Unix()
|
||||
if _, err := s.InsertTransmission(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := time.Now().Unix()
|
||||
|
||||
s.db.QueryRow("SELECT last_packet_at FROM observers WHERE id = ?", "obs1").Scan(&lastPacketAt)
|
||||
if !lastPacketAt.Valid {
|
||||
t.Fatal("expected last_packet_at to be non-NULL after InsertTransmission")
|
||||
}
|
||||
// last_packet_at, like last_seen, is "when did the analyzer last receive a
|
||||
// packet from this observer" — an ingest-time question, independent of the
|
||||
// envelope timestamp. See #1465.
|
||||
lp, err := time.Parse(time.RFC3339, lastPacketAt.String)
|
||||
if err != nil {
|
||||
t.Fatalf("last_packet_at %q not RFC3339: %v", lastPacketAt.String, err)
|
||||
}
|
||||
if lp.Unix() < before-5 || lp.Unix() > after+5 {
|
||||
t.Errorf("expected last_packet_at ≈ server now (in [%d, %d]), got %s (epoch %d)",
|
||||
before, after, lastPacketAt.String, lp.Unix())
|
||||
// InsertTransmission uses `now = data.Timestamp || time.Now()`, so last_packet_at
|
||||
// should match the packet's Timestamp when provided (same source-of-truth as last_seen).
|
||||
if lastPacketAt.String != "2026-04-24T12:00:00Z" {
|
||||
t.Errorf("expected last_packet_at=2026-04-24T12:00:00Z, got %s", lastPacketAt.String)
|
||||
}
|
||||
|
||||
// UpsertObserver again (status path) — last_packet_at should NOT change
|
||||
|
||||
+17
-62
@@ -150,21 +150,6 @@ func main() {
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", packetDays)
|
||||
}
|
||||
|
||||
// Hourly WAL checkpoint to prevent unbounded WAL growth.
|
||||
// TRUNCATE resets the WAL file to zero bytes when all frames are flushed;
|
||||
// if the server's read connection holds frames, remaining pages stay in the
|
||||
// WAL until the next tick. Staggered 30s after startup to avoid competing
|
||||
// with the initial burst of ingest writes.
|
||||
walCheckpointTicker := time.NewTicker(1 * time.Hour)
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
store.Checkpoint()
|
||||
for range walCheckpointTicker.C {
|
||||
store.Checkpoint()
|
||||
}
|
||||
}()
|
||||
log.Printf("[db] WAL checkpoint scheduled every 1h")
|
||||
|
||||
// Daily neighbor_edges retention (#1287 — moved from cmd/server).
|
||||
{
|
||||
nDays := cfg.NeighborEdgesDaysOrDefault()
|
||||
@@ -388,7 +373,6 @@ func main() {
|
||||
}
|
||||
statsTicker.Stop()
|
||||
pruneQueueTicker.Stop()
|
||||
walCheckpointTicker.Stop()
|
||||
stopWatchdog()
|
||||
store.LogStats() // final stats on shutdown
|
||||
for _, c := range clients {
|
||||
@@ -452,9 +436,7 @@ func buildMQTTOpts(source MQTTSource) *mqtt.ClientOptions {
|
||||
}
|
||||
if source.RejectUnauthorized != nil && !*source.RejectUnauthorized {
|
||||
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
} else if strings.HasPrefix(source.Broker, "ssl://") || strings.HasPrefix(source.Broker, "wss://") {
|
||||
// TLS with system CA pool — valid for ssl:// MQTT brokers and
|
||||
// wss:// WebSocket brokers behind a publicly-trusted certificate.
|
||||
} else if strings.HasPrefix(source.Broker, "ssl://") {
|
||||
opts.SetTLSConfig(&tls.Config{})
|
||||
}
|
||||
return opts
|
||||
@@ -505,11 +487,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
name, _ := msg["origin"].(string)
|
||||
iata := parts[1]
|
||||
meta := extractObserverMeta(msg)
|
||||
// observer.last_seen is "when did the analyzer last hear from this
|
||||
// observer" — fundamentally an ingest-time question. Passing "" makes
|
||||
// UpsertObserverAt use time.Now(), independent of the envelope timestamp
|
||||
// (which can be stale/skewed even when well-formed). See #1465.
|
||||
if err := store.UpsertObserverAt(observerID, name, iata, meta, ""); err != nil {
|
||||
if err := store.UpsertObserverAt(observerID, name, iata, meta, resolveRxTime(msg, tag)); err != nil {
|
||||
log.Printf("MQTT [%s] observer status error: %v", tag, err)
|
||||
}
|
||||
// Insert metrics sample from status message
|
||||
@@ -731,10 +709,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
if mqttMsg.Region != "" {
|
||||
effectiveRegion = mqttMsg.Region
|
||||
}
|
||||
// Same as the status-path call above: observer.last_seen is ingest
|
||||
// time, not envelope time. Per-packet rxTime (stored in observations
|
||||
// via InsertTransmission) still uses envelope time. See #1465.
|
||||
if err := store.UpsertObserverAt(observerID, origin, effectiveRegion, nil, ""); err != nil {
|
||||
if err := store.UpsertObserverAt(observerID, origin, effectiveRegion, nil, mqttMsg.Timestamp); err != nil {
|
||||
log.Printf("MQTT [%s] observer upsert error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
@@ -1111,7 +1086,7 @@ func resolveRxTime(msg map[string]interface{}, tag string) string {
|
||||
if raw == "" {
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
t, naive, err := parseEnvelopeTime(raw)
|
||||
t, err := parseEnvelopeTime(raw)
|
||||
if err != nil {
|
||||
log.Printf("MQTT [%s] unparseable timestamp %q, using ingest time", tag, raw)
|
||||
return now.Format(time.RFC3339)
|
||||
@@ -1130,30 +1105,13 @@ func resolveRxTime(msg map[string]interface{}, tag string) string {
|
||||
log.Printf("MQTT [%s] stale timestamp %q (>30d old), using ingest time", tag, raw)
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
// Symmetric naive-timestamp clamp (issue #1463). Naive (zone-less) ISO
|
||||
// values from observers in non-UTC zones are parsed as-if UTC, leaving a
|
||||
// residual offset equal to the observer's UTC offset:
|
||||
// - UTC+N observer → value appears N hours in the future
|
||||
// - UTC-N observer → value appears N hours in the past
|
||||
// The past case was silently stored verbatim, poisoning last_seen and
|
||||
// rendering UTC-N observers perpetually "Stale" in the UI. Collapse any
|
||||
// naive value more than 15 min off server-now to now() — well-behaved
|
||||
// observers (Z-suffixed or explicit offset) are untouched regardless of
|
||||
// skew so legitimate buffered uploads remain accurate.
|
||||
const naiveTolerance = 15 * time.Minute
|
||||
if naive {
|
||||
delta := t.Sub(now)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > naiveTolerance {
|
||||
log.Printf("MQTT [%s] naive timestamp %q off by %s, using ingest time", tag, raw, delta.Round(time.Second))
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
// Legacy soft clamp for zone-aware near-future values: any value ahead of
|
||||
// now is from a slightly skewed observer clock — collapse to now so we
|
||||
// don't render ⚠️ in the UI for live packets from those nodes.
|
||||
// Soft clamp: naive local-clock timestamps from UTC+N observers are parsed
|
||||
// as-if UTC, making them appear N hours in the future. A UTC+2 observer's
|
||||
// live packet looks 2h ahead, but it is NOT a buffered packet — the whole
|
||||
// point of using rxTime is to preserve the past timestamp for packets that
|
||||
// were buffered offline. If rxTime is ahead of now, the packet is live and
|
||||
// ingest time is the correct value. This also prevents storing future
|
||||
// timestamps that would show ⚠️ in the UI for every packet from UTC+N nodes.
|
||||
if t.After(now) {
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
@@ -1163,22 +1121,19 @@ func resolveRxTime(msg map[string]interface{}, tag string) string {
|
||||
// parseEnvelopeTime parses the MQTT envelope timestamp. Two on-wire forms
|
||||
// occur: zone-aware ISO8601 (RFC3339), and a naive local-clock ISO string
|
||||
// with no zone (python datetime.isoformat()). Zone-aware layouts are tried
|
||||
// first; naive layouts are assumed UTC but the caller is informed via the
|
||||
// returned `naive` flag so it can apply a symmetric clamp (see issue #1463).
|
||||
func parseEnvelopeTime(s string) (time.Time, bool, error) {
|
||||
// Zone-aware first — RFC3339 demands Z or ±HH:MM.
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t, false, nil
|
||||
}
|
||||
// first; naive layouts are assumed UTC, leaving a bounded residual offset
|
||||
// equal to the observer's UTC offset for naive-timestamp uploaders.
|
||||
func parseEnvelopeTime(s string) (time.Time, error) {
|
||||
for _, layout := range []string{
|
||||
time.RFC3339, // 2026-05-16T10:00:00Z / +02:00
|
||||
"2006-01-02T15:04:05.999999", // python isoformat w/ microseconds
|
||||
"2006-01-02T15:04:05", // naive ISO
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true, nil
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, false, fmt.Errorf("unrecognized timestamp layout: %q", s)
|
||||
return time.Time{}, fmt.Errorf("unrecognized timestamp layout: %q", s)
|
||||
}
|
||||
|
||||
// deriveHashtagChannelKey derives an AES-128 key from a channel name.
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package main
|
||||
|
||||
// Regression tests for issue #1465 — observer.last_seen MUST always reflect
|
||||
// ingest time (server wall clock), never the MQTT envelope timestamp. Observers
|
||||
// with broken clocks (wrong TZ, RTC drift, replayed retained messages) must
|
||||
// NOT be able to drag the analyzer's "last heard from" field into the past
|
||||
// or future.
|
||||
//
|
||||
// Per-packet rxTime semantics (envelope time with naive-clamp from #1464)
|
||||
// are out of scope here — those continue to use envelope time. This file
|
||||
// asserts only the observer.last_seen path.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status path: envelope timestamp is a well-formed RFC3339 value 3h in the
|
||||
// past. observer.last_seen must be server wall clock, NOT the envelope value.
|
||||
func TestStatusMessage_ObserverLastSeen_AlwaysIngestTime_PastEnvelope_1465(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
stale := time.Now().UTC().Add(-3 * time.Hour).Format(time.RFC3339)
|
||||
before := time.Now().Unix()
|
||||
|
||||
payload := []byte(`{"status":"online","origin":"obs-past","timestamp":"` + stale + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs-past/status", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
after := time.Now().Unix()
|
||||
|
||||
var lastSeen string
|
||||
if err := store.db.QueryRow(`SELECT last_seen FROM observers WHERE id = ?`, "obs-past").Scan(&lastSeen); err != nil {
|
||||
t.Fatalf("scan last_seen: %v", err)
|
||||
}
|
||||
ls, err := time.Parse(time.RFC3339, lastSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("last_seen %q not RFC3339: %v", lastSeen, err)
|
||||
}
|
||||
if ls.Unix() < before-5 || ls.Unix() > after+5 {
|
||||
t.Errorf("observer.last_seen = %q (epoch %d); want in [%d, %d] (server wall clock). "+
|
||||
"Envelope reported well-formed stale %q (3h ago) — must NOT drag last_seen into the past. Issue #1465.",
|
||||
lastSeen, ls.Unix(), before, after, stale)
|
||||
}
|
||||
}
|
||||
|
||||
// Status path: envelope timestamp 5 min in the future. observer.last_seen
|
||||
// must still be server wall clock.
|
||||
func TestStatusMessage_ObserverLastSeen_AlwaysIngestTime_FutureEnvelope_1465(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
future := time.Now().UTC().Add(5 * time.Minute).Format(time.RFC3339)
|
||||
before := time.Now().Unix()
|
||||
|
||||
payload := []byte(`{"status":"online","origin":"obs-future","timestamp":"` + future + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs-future/status", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
after := time.Now().Unix()
|
||||
|
||||
var lastSeen string
|
||||
if err := store.db.QueryRow(`SELECT last_seen FROM observers WHERE id = ?`, "obs-future").Scan(&lastSeen); err != nil {
|
||||
t.Fatalf("scan last_seen: %v", err)
|
||||
}
|
||||
ls, err := time.Parse(time.RFC3339, lastSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("last_seen %q not RFC3339: %v", lastSeen, err)
|
||||
}
|
||||
if ls.Unix() < before-5 || ls.Unix() > after+5 {
|
||||
t.Errorf("observer.last_seen = %q (epoch %d); want in [%d, %d] (server wall clock). "+
|
||||
"Envelope reported well-formed future %q (5 min ahead) — must NOT drag last_seen into the future. Issue #1465.",
|
||||
lastSeen, ls.Unix(), before, after, future)
|
||||
}
|
||||
}
|
||||
|
||||
// Packet path: a transmission whose envelope timestamp is 3h in the past
|
||||
// MUST still bump observer.last_seen to server wall clock — observer is
|
||||
// clearly alive (we just ingested a packet from it), regardless of what
|
||||
// its clock claims.
|
||||
func TestPacketMessage_ObserverLastSeen_AlwaysIngestTime_PastEnvelope_1465(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
source := MQTTSource{Name: "test"}
|
||||
|
||||
stale := time.Now().UTC().Add(-3 * time.Hour).Format(time.RFC3339)
|
||||
before := time.Now().Unix()
|
||||
|
||||
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
||||
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"obs-pkt","timestamp":"` + stale + `"}`)
|
||||
msg := &mockMessage{topic: "meshcore/SJC/obs-pkt/packets", payload: payload}
|
||||
|
||||
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
||||
after := time.Now().Unix()
|
||||
|
||||
var lastSeen string
|
||||
if err := store.db.QueryRow(`SELECT last_seen FROM observers WHERE id = ?`, "obs-pkt").Scan(&lastSeen); err != nil {
|
||||
t.Fatalf("scan last_seen: %v", err)
|
||||
}
|
||||
ls, err := time.Parse(time.RFC3339, lastSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("last_seen %q not RFC3339: %v", lastSeen, err)
|
||||
}
|
||||
if ls.Unix() < before-5 || ls.Unix() > after+5 {
|
||||
t.Errorf("packet-path observer.last_seen = %q (epoch %d); want in [%d, %d] (server wall clock). "+
|
||||
"Envelope stale = %q. Observer just delivered a packet; last_seen must be NOW. Issue #1465.",
|
||||
lastSeen, ls.Unix(), before, after, stale)
|
||||
}
|
||||
}
|
||||
+10
-86
@@ -7,27 +7,23 @@ import (
|
||||
|
||||
func TestParseEnvelopeTime(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
ok bool
|
||||
wantNaive bool
|
||||
name string
|
||||
in string
|
||||
ok bool
|
||||
}{
|
||||
{"rfc3339 utc", "2026-05-16T10:00:00Z", true, false},
|
||||
{"rfc3339 offset", "2026-05-16T12:00:00+02:00", true, false},
|
||||
{"naive iso", "2026-05-16T10:00:00", true, true},
|
||||
{"naive iso micros", "2026-05-16T10:00:00.123456", true, true},
|
||||
{"garbage", "not-a-time", false, false},
|
||||
{"empty", "", false, false},
|
||||
{"rfc3339 utc", "2026-05-16T10:00:00Z", true},
|
||||
{"rfc3339 offset", "2026-05-16T12:00:00+02:00", true},
|
||||
{"naive iso", "2026-05-16T10:00:00", true},
|
||||
{"naive iso micros", "2026-05-16T10:00:00.123456", true},
|
||||
{"garbage", "not-a-time", false},
|
||||
{"empty", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, naive, err := parseEnvelopeTime(c.in)
|
||||
_, err := parseEnvelopeTime(c.in)
|
||||
if (err == nil) != c.ok {
|
||||
t.Fatalf("parseEnvelopeTime(%q): want ok=%v, got err=%v", c.in, c.ok, err)
|
||||
}
|
||||
if err == nil && naive != c.wantNaive {
|
||||
t.Fatalf("parseEnvelopeTime(%q): want naive=%v, got %v", c.in, c.wantNaive, naive)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -82,75 +78,3 @@ func TestResolveRxTime(t *testing.T) {
|
||||
t.Errorf("recent timestamp <30d: got %q want %q", got, recent)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: issue #1463 — naive (zone-less) ISO timestamps from observers
|
||||
// in negative-UTC-offset zones (e.g. California PDT, UTC−7) were interpreted
|
||||
// as UTC, producing rxTime values 7h in the past that poisoned `last_seen`
|
||||
// and rendered the observer perpetually "Stale" in the UI. The symmetric
|
||||
// clamp now collapses any naive timestamp more than 15 min off server-now to
|
||||
// `now()`, while zone-aware timestamps (RFC3339 with Z or offset) are still
|
||||
// honored verbatim regardless of skew (those are well-behaved observers).
|
||||
func TestResolveRxTimeNaiveTimestampClamp(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
mustParse := func(s string) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("result %q is not RFC3339: %v", s, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
nearNow := func(s string) bool {
|
||||
d := mustParse(s).Sub(now)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
return d <= time.Minute
|
||||
}
|
||||
|
||||
// California observer (UTC-7) emitting a naive local-clock timestamp:
|
||||
// must NOT be stored verbatim 7h in the past — clamp to ~now.
|
||||
naivePast := now.Add(-7 * time.Hour).Format("2006-01-02T15:04:05")
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": naivePast}, "test"); !nearNow(got) {
|
||||
t.Errorf("naive past timestamp (UTC-7 observer): got %q, expected ~now (clamped)", got)
|
||||
}
|
||||
|
||||
// Naive future just minutes ahead (UTC+N observer, existing soft-clamp
|
||||
// behavior): still clamped to now.
|
||||
naiveFuture := now.Add(5 * time.Minute).Format("2006-01-02T15:04:05")
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": naiveFuture}, "test"); !nearNow(got) {
|
||||
t.Errorf("naive future timestamp: got %q, expected ~now (clamped)", got)
|
||||
}
|
||||
|
||||
// Naive microsecond layout (python isoformat without tz) — same clamp.
|
||||
naivePastMicros := now.Add(-7 * time.Hour).Format("2006-01-02T15:04:05.000000")
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": naivePastMicros}, "test"); !nearNow(got) {
|
||||
t.Errorf("naive past timestamp w/ micros: got %q, expected ~now (clamped)", got)
|
||||
}
|
||||
|
||||
// Well-behaved observer: Z-suffixed past timestamp passes through verbatim
|
||||
// even if it's hours old (legitimate buffered uploads must be preserved).
|
||||
zPast := now.Add(-7 * time.Hour).Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": zPast}, "test"); got != zPast {
|
||||
t.Errorf("Z-suffixed past timestamp must pass through: got %q want %q", got, zPast)
|
||||
}
|
||||
|
||||
// Well-behaved observer with explicit offset (UTC-7) — canonicalize to UTC
|
||||
// but preserve the moment in time. Must equal the same moment in UTC.
|
||||
offsetLoc := time.FixedZone("PDT", -7*3600)
|
||||
offsetMoment := now.Add(-7 * time.Hour).In(offsetLoc)
|
||||
offsetStr := offsetMoment.Format(time.RFC3339)
|
||||
wantUTC := offsetMoment.UTC().Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": offsetStr}, "test"); got != wantUTC {
|
||||
t.Errorf("offset-suffixed timestamp: got %q want %q", got, wantUTC)
|
||||
}
|
||||
|
||||
// Naive timestamp within tolerance window (2 min in past, observer that
|
||||
// happens to be in UTC) — within tolerance, passes through verbatim.
|
||||
naiveCloseStr := now.Add(-2 * time.Minute).Format("2006-01-02T15:04:05")
|
||||
naiveCloseWant := now.Add(-2 * time.Minute).Format(time.RFC3339)
|
||||
if got := resolveRxTime(map[string]interface{}{"timestamp": naiveCloseStr}, "test"); got != naiveCloseWant {
|
||||
t.Errorf("naive timestamp within tolerance: got %q, expected %q (verbatim)", got, naiveCloseWant)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +92,6 @@ type Config struct {
|
||||
|
||||
DebugAffinity bool `json:"debugAffinity,omitempty"`
|
||||
|
||||
// MapDarkTileProvider selects the default dark-mode basemap provider for
|
||||
// new visitors. The client may override per-browser via the customizer
|
||||
// (persisted to localStorage). Allowed values: "carto-dark" (default),
|
||||
// "esri-darkgray-labels", "voyager-inverted", "positron-inverted". See
|
||||
// public/map-tile-providers.js for the registry. #1420.
|
||||
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
|
||||
|
||||
// ObserverBlacklist is a list of observer public keys to exclude from API
|
||||
// responses (defense in depth — ingestor drops at ingest, server filters
|
||||
// any that slipped through from a prior unblocked window).
|
||||
|
||||
@@ -126,7 +126,6 @@ func main() {
|
||||
default:
|
||||
log.Printf("[memlimit] no soft memory limit set (GOMEMLIMIT unset, packetStore.maxMemoryMB=0); recommend setting one to avoid container OOM-kill")
|
||||
}
|
||||
warnIfMemlimitUnderprovisioned(limit)
|
||||
}
|
||||
|
||||
// Resolve DB path
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cgroupUnlimitedThreshold is the sentinel above which a cgroup memory value
|
||||
// means "no limit". cgroup v1 encodes unlimited as math.MaxInt64 (page-aligned
|
||||
// near 1<<63); 1<<62 is a safe upper bound that excludes all real limits while
|
||||
// staying well below the unlimited sentinel.
|
||||
const cgroupUnlimitedThreshold = int64(1 << 62)
|
||||
|
||||
// applyMemoryLimit configures Go's soft memory limit (GOMEMLIMIT).
|
||||
//
|
||||
// Behavior:
|
||||
@@ -40,74 +30,3 @@ func applyMemoryLimit(maxMemoryMB int, envSet bool) (int64, string) {
|
||||
debug.SetMemoryLimit(limit)
|
||||
return limit, "derived"
|
||||
}
|
||||
|
||||
// readCgroupMemoryMBFn is the package-level hook used by
|
||||
// warnIfMemlimitUnderprovisioned. Tests override it to inject deterministic
|
||||
// cgroup values without needing a Linux kernel with cgroup mounts.
|
||||
var readCgroupMemoryMBFn = readCgroupMemoryMB
|
||||
|
||||
// readCgroupMemoryMB returns the container's memory limit from cgroup, in MiB.
|
||||
// Returns 0 when unavailable (non-Linux, unlimited, or read error).
|
||||
func readCgroupMemoryMB() int64 {
|
||||
// cgroup v2: single file, value in bytes or literal "max"
|
||||
if b, err := os.ReadFile("/sys/fs/cgroup/memory.max"); err == nil {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s != "max" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil && v > 0 {
|
||||
return v / (1024 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
// cgroup v1: values near math.MaxInt64 represent "unlimited"
|
||||
if b, err := os.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes"); err == nil {
|
||||
if v, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64); err == nil {
|
||||
if v > 0 && v < cgroupUnlimitedThreshold {
|
||||
return v / (1024 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// memlimitUnderprovisioned reports whether effectiveMB is less than half of
|
||||
// cgroupMB. Extracted for unit testing the comparison boundary.
|
||||
func memlimitUnderprovisioned(effectiveMB, cgroupMB int64) bool {
|
||||
return effectiveMB > 0 && cgroupMB > 0 && effectiveMB*2 < cgroupMB
|
||||
}
|
||||
|
||||
// warnIfMemlimitUnderprovisioned logs a warning when GOMEMLIMIT is below 50%
|
||||
// of the container cgroup memory limit, which causes the Go GC to thrash.
|
||||
// In one reported incident (#1264) 82% of CPU was GC with a 1536 MiB limit
|
||||
// on a 7.7 GB container — all endpoints 3-100x slower until maxMemoryMB was
|
||||
// bumped and the process restarted.
|
||||
//
|
||||
// limitBytes is the value returned by applyMemoryLimit:
|
||||
// - source="derived": the limit we set ourselves (> 0)
|
||||
// - source="env": 0 — we did not touch the runtime; read it back below
|
||||
// - source="none": 0 — no limit set at all; runtime default is math.MaxInt64,
|
||||
// which the >= cgroupUnlimitedThreshold guard below catches and skips
|
||||
func warnIfMemlimitUnderprovisioned(limitBytes int64) {
|
||||
cgroupMB := readCgroupMemoryMBFn()
|
||||
if cgroupMB <= 0 {
|
||||
return
|
||||
}
|
||||
effective := limitBytes
|
||||
if effective <= 0 {
|
||||
// Either GOMEMLIMIT was set via env (source="env") or no limit was
|
||||
// configured (source="none"). Read the runtime's current value:
|
||||
// - env case: returns whatever the operator set
|
||||
// - none case: returns math.MaxInt64, caught by the guard below
|
||||
// debug.SetMemoryLimit(-1) leaves the limit unchanged and returns it.
|
||||
effective = debug.SetMemoryLimit(-1)
|
||||
}
|
||||
if effective <= 0 || effective >= cgroupUnlimitedThreshold {
|
||||
return
|
||||
}
|
||||
effectiveMB := effective / (1024 * 1024)
|
||||
if memlimitUnderprovisioned(effectiveMB, cgroupMB) {
|
||||
log.Printf("[memlimit] WARN: GOMEMLIMIT=%d MiB is <50%% of container limit %d MiB — "+
|
||||
"GC may thrash under load; consider bumping packetStore.maxMemoryMB "+
|
||||
"(suggested: ~%d MiB, roughly 2/3 of container limit)",
|
||||
effectiveMB, cgroupMB, cgroupMB*2/3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -55,109 +52,3 @@ func TestApplyMemoryLimit_None(t *testing.T) {
|
||||
t.Fatalf("expected limit=0, got %d", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemlimitUnderprovisioned(t *testing.T) {
|
||||
cases := []struct {
|
||||
effective, cgroup int64
|
||||
want bool
|
||||
}{
|
||||
{512, 1536, true}, // 512*2=1024 < 1536 → underprovisioned
|
||||
{768, 1536, false}, // 768*2=1536 == 1536 → not under (boundary)
|
||||
{1024, 1536, false},
|
||||
{0, 1536, false}, // no effective limit → skip
|
||||
{512, 0, false}, // no cgroup info → skip
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := memlimitUnderprovisioned(c.effective, c.cgroup)
|
||||
if got != c.want {
|
||||
t.Errorf("memlimitUnderprovisioned(%d, %d) = %v, want %v", c.effective, c.cgroup, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureLog redirects the default logger to a buffer for the duration of f,
|
||||
// then restores the previous writer. Returns captured output.
|
||||
func captureLog(f func()) string {
|
||||
var buf bytes.Buffer
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&buf)
|
||||
defer log.SetOutput(prev)
|
||||
f()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// TestWarnIfMemlimitUnderprovisioned_EmitsWarning verifies the warning IS
|
||||
// logged when the injected cgroup reader reports a container limit more than
|
||||
// 2x larger than the effective GOMEMLIMIT.
|
||||
func TestWarnIfMemlimitUnderprovisioned_EmitsWarning(t *testing.T) {
|
||||
defer debug.SetMemoryLimit(-1)
|
||||
// Effective: 512 MiB; container: 2048 MiB → 512*2=1024 < 2048 → warn
|
||||
debug.SetMemoryLimit(int64(512) * 1024 * 1024)
|
||||
|
||||
orig := readCgroupMemoryMBFn
|
||||
readCgroupMemoryMBFn = func() int64 { return 2048 }
|
||||
defer func() { readCgroupMemoryMBFn = orig }()
|
||||
|
||||
out := captureLog(func() {
|
||||
warnIfMemlimitUnderprovisioned(int64(512) * 1024 * 1024)
|
||||
})
|
||||
if !strings.Contains(out, "[memlimit] WARN") {
|
||||
t.Errorf("expected warning log, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfMemlimitUnderprovisioned_NoWarnWhenAdequate verifies no warning
|
||||
// when GOMEMLIMIT is >= 50% of the container limit.
|
||||
func TestWarnIfMemlimitUnderprovisioned_NoWarnWhenAdequate(t *testing.T) {
|
||||
defer debug.SetMemoryLimit(-1)
|
||||
// Effective: 1024 MiB; container: 1536 MiB → 1024*2=2048 >= 1536 → no warn
|
||||
debug.SetMemoryLimit(int64(1024) * 1024 * 1024)
|
||||
|
||||
orig := readCgroupMemoryMBFn
|
||||
readCgroupMemoryMBFn = func() int64 { return 1536 }
|
||||
defer func() { readCgroupMemoryMBFn = orig }()
|
||||
|
||||
out := captureLog(func() {
|
||||
warnIfMemlimitUnderprovisioned(int64(1024) * 1024 * 1024)
|
||||
})
|
||||
if strings.Contains(out, "[memlimit] WARN") {
|
||||
t.Errorf("unexpected warning when limit is adequate: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfMemlimitUnderprovisioned_NoCgroupNoLog verifies early exit when
|
||||
// no cgroup info is available (non-Linux / non-container).
|
||||
func TestWarnIfMemlimitUnderprovisioned_NoCgroupNoLog(t *testing.T) {
|
||||
defer debug.SetMemoryLimit(-1)
|
||||
debug.SetMemoryLimit(int64(512) * 1024 * 1024)
|
||||
|
||||
orig := readCgroupMemoryMBFn
|
||||
readCgroupMemoryMBFn = func() int64 { return 0 }
|
||||
defer func() { readCgroupMemoryMBFn = orig }()
|
||||
|
||||
out := captureLog(func() {
|
||||
warnIfMemlimitUnderprovisioned(int64(512) * 1024 * 1024)
|
||||
})
|
||||
if strings.Contains(out, "[memlimit] WARN") {
|
||||
t.Errorf("unexpected warning when cgroup unavailable: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfMemlimitUnderprovisioned_NoneSource verifies that when no limit
|
||||
// was configured (source="none", limitBytes=0), the function reads back
|
||||
// math.MaxInt64 from the runtime and skips the warning.
|
||||
func TestWarnIfMemlimitUnderprovisioned_NoneSource(t *testing.T) {
|
||||
defer debug.SetMemoryLimit(-1)
|
||||
debug.SetMemoryLimit(int64(1<<63 - 1)) // math.MaxInt64 = "no limit"
|
||||
|
||||
orig := readCgroupMemoryMBFn
|
||||
readCgroupMemoryMBFn = func() int64 { return 2048 }
|
||||
defer func() { readCgroupMemoryMBFn = orig }()
|
||||
|
||||
out := captureLog(func() {
|
||||
warnIfMemlimitUnderprovisioned(0) // source="none" passes limit=0
|
||||
})
|
||||
if strings.Contains(out, "[memlimit] WARN") {
|
||||
t.Errorf("unexpected warning when no limit configured: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestHandleNodePaths_HopName_CanonicalPathShowsTarget_1144 is the regression
|
||||
// test for issue #1144.
|
||||
//
|
||||
// Bug: the biased hop resolver picked a GPS-having sibling over the actual target
|
||||
// node when the target had no GPS coordinates, causing the wrong name in hop slots.
|
||||
//
|
||||
// Fix: the canonical-path branch (Option A) uses lookupNode(resolvedPK) with the
|
||||
// full pubkey stored in resolved_path, bypassing the biased resolver entirely.
|
||||
// This test verifies that when two nodes share a short prefix ("37"), the hop
|
||||
// display uses the stored resolved_path pubkey and shows the correct target name.
|
||||
func TestHandleNodePaths_HopName_CanonicalPathShowsTarget_1144(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
recentEpoch := time.Now().Add(-1 * time.Hour).Unix()
|
||||
|
||||
targetPK := "37cf0832aaaabbbb" // no GPS
|
||||
siblingPK := "37bb000011112222" // has GPS — biased resolver picks this without fix
|
||||
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'CJS SF Mission', 'repeater', 0, 0, ?, '2026-01-01', 1)`, targetPK, recent)
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'Templeton Hills', 'repeater', 35.5, -120.7, ?, '2026-01-01', 1)`, siblingPK, recent)
|
||||
|
||||
// TX: resolved_path = [targetPK] → canonical path (Option A) → lookupNode(targetPK)
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (1, 'AA', 'hash1144', ?)`, recent)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (1, NULL, '["37"]', ?, ?)`, recentEpoch, `["`+targetPK+`"]`)
|
||||
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+targetPK+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /paths: code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp NodePathsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp.Paths) != 1 {
|
||||
t.Fatalf("expected 1 path, got %d", len(resp.Paths))
|
||||
}
|
||||
if len(resp.Paths[0].Hops) != 1 {
|
||||
t.Fatalf("expected 1 hop, got %d", len(resp.Paths[0].Hops))
|
||||
}
|
||||
hop := resp.Paths[0].Hops[0]
|
||||
// The "37" prefix resolves to TWO candidates; the canonical path must use
|
||||
// the stored resolved_path pubkey (targetPK) and display the target's name,
|
||||
// NOT the GPS-having sibling.
|
||||
if hop.Name != "CJS SF Mission" {
|
||||
if hop.Name == "Templeton Hills" {
|
||||
t.Errorf("hop name = %q (sibling mis-resolution #1144): canonical path must show target name %q", hop.Name, "CJS SF Mission")
|
||||
} else {
|
||||
t.Errorf("hop name = %q, want %q", hop.Name, "CJS SF Mission")
|
||||
}
|
||||
}
|
||||
if hop.Pubkey != targetPK {
|
||||
t.Errorf("hop pubkey = %q, want %q", hop.Pubkey, targetPK)
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestHandleNodePaths_SortByRecency_1145 is the regression test for issue #1145.
|
||||
//
|
||||
// Prior to the fix, paths were returned in map-iteration order (non-deterministic).
|
||||
// After the fix, paths are sorted by LastSeen descending (newest first), with
|
||||
// Count as a tiebreaker (higher first).
|
||||
//
|
||||
// Setup: target node "aa..." is reached via three distinct paths.
|
||||
//
|
||||
// Path A (via relay "11..."): 3 transmissions, last seen 2026-01-03 (oldest)
|
||||
// Path B (via relay "22..."): 1 transmission, last seen 2026-05-01 (newest)
|
||||
// Path C (direct — "aa..." only): 2 transmissions, last seen 2026-03-02 (middle)
|
||||
//
|
||||
// Expected sort: B (newest) → C (middle) → A (oldest)
|
||||
// Also covers: when LastSeen is equal, Count descending is the tiebreaker.
|
||||
func TestHandleNodePaths_SortByRecency_1145(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
targetPK := "aabbccdd11111111"
|
||||
relay1PK := "1111111100000000"
|
||||
relay2PK := "2222222200000000"
|
||||
|
||||
epoch := func(ts string) int64 {
|
||||
v, _ := time.Parse(time.RFC3339, ts)
|
||||
return v.Unix()
|
||||
}
|
||||
|
||||
// Only the target node needs to be in the nodes table.
|
||||
// Relay pubkeys appear only in resolved_path; they don't need a nodes row.
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'Target', 'repeater', 0, 0, '2026-05-01T00:00:00Z', '2026-01-01T00:00:00Z', 1)`, targetPK)
|
||||
|
||||
// -- Path A (via relay1): 3 txs, last seen 2026-01-03 → group sig "relay1PK→targetPK" --
|
||||
for txID, ts := range map[int]string{
|
||||
1: "2026-01-01T00:00:00Z",
|
||||
2: "2026-01-02T00:00:00Z",
|
||||
3: "2026-01-03T00:00:00Z",
|
||||
} {
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, 'AA', ?, ?)`,
|
||||
txID, "hashA"+string(rune('0'+txID)), ts)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (?, NULL, '["11", "aa"]', ?, ?)`,
|
||||
txID, epoch(ts), `["`+relay1PK+`", "`+targetPK+`"]`)
|
||||
}
|
||||
|
||||
// -- Path B (via relay2): 1 tx, last seen 2026-05-01 → group sig "relay2PK→targetPK" --
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (4, 'BB', 'hashB1', '2026-05-01T00:00:00Z')`)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (4, NULL, '["22", "aa"]', ?, ?)`,
|
||||
epoch("2026-05-01T00:00:00Z"), `["`+relay2PK+`", "`+targetPK+`"]`)
|
||||
|
||||
// -- Path C (direct — target is sole hop): 2 txs, last seen 2026-03-02 --
|
||||
for txID, ts := range map[int]string{
|
||||
5: "2026-03-01T00:00:00Z",
|
||||
6: "2026-03-02T00:00:00Z",
|
||||
} {
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, 'CC', ?, ?)`,
|
||||
txID, "hashC"+string(rune('0'+txID)), ts)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (?, NULL, '["aa"]', ?, ?)`,
|
||||
txID, epoch(ts), `["`+targetPK+`"]`)
|
||||
}
|
||||
|
||||
// Wire up server + store
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+targetPK+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /paths: code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp NodePathsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Paths) != 3 {
|
||||
t.Fatalf("expected 3 distinct paths, got %d: %+v", len(resp.Paths), resp.Paths)
|
||||
}
|
||||
if resp.TotalTransmissions != 6 {
|
||||
t.Errorf("expected TotalTransmissions=6, got %d", resp.TotalTransmissions)
|
||||
}
|
||||
|
||||
// Sort order: B (newest, 2026-05-01) → C (middle, 2026-03-02) → A (oldest, 2026-01-03)
|
||||
wantCounts := []int{1, 2, 3}
|
||||
for i, want := range wantCounts {
|
||||
got := resp.Paths[i].Count
|
||||
if got != want {
|
||||
t.Errorf("Paths[%d].Count = %d, want %d (sort order wrong — paths must be newest-first)", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleNodePaths_SortCountTiebreaker_1145 verifies that when two paths
|
||||
// have identical LastSeen, the one with higher Count appears first.
|
||||
func TestHandleNodePaths_SortCountTiebreaker_1145(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
targetPK := "ccddeeFF11111111"
|
||||
relay1PK := "aaaa111100000000"
|
||||
relay2PK := "bbbb222200000000"
|
||||
sameTS := "2026-04-15T12:00:00Z"
|
||||
epoch := func(ts string) int64 {
|
||||
v, _ := time.Parse(time.RFC3339, ts)
|
||||
return v.Unix()
|
||||
}
|
||||
|
||||
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'Tgt', 'repeater', 0, 0, ?, '2026-01-01T00:00:00Z', 1)`, targetPK, sameTS)
|
||||
|
||||
// Path X: 3 txs, all at sameTS → higher count
|
||||
for txID, ts := range map[int]string{
|
||||
10: "2026-04-15T11:00:00Z",
|
||||
11: "2026-04-15T11:30:00Z",
|
||||
12: sameTS,
|
||||
} {
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (?, 'XX', ?, ?)`,
|
||||
txID, "hashX"+string(rune('0'+txID)), ts)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (?, NULL, '["aa", "cc"]', ?, ?)`,
|
||||
txID, epoch(ts), `["`+relay1PK+`", "`+targetPK+`"]`)
|
||||
}
|
||||
|
||||
// Path Y: 1 tx, at sameTS → lower count
|
||||
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (20, 'YY', 'hashY1', ?)`, sameTS)
|
||||
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
|
||||
VALUES (20, NULL, '["bb", "cc"]', ?, ?)`,
|
||||
epoch(sameTS), `["`+relay2PK+`", "`+targetPK+`"]`)
|
||||
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
store := NewPacketStore(db, nil)
|
||||
if err := store.Load(); err != nil {
|
||||
t.Fatalf("store.Load: %v", err)
|
||||
}
|
||||
srv.store = store
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+targetPK+"/paths", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /paths: code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp NodePathsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp.Paths) != 2 {
|
||||
t.Fatalf("expected 2 paths, got %d", len(resp.Paths))
|
||||
}
|
||||
// Path X (count=3) must sort before Path Y (count=1) when LastSeen is equal.
|
||||
if resp.Paths[0].Count != 3 {
|
||||
t.Errorf("Paths[0].Count = %d, want 3 (higher-count path must sort first when LastSeen equal)", resp.Paths[0].Count)
|
||||
}
|
||||
if resp.Paths[1].Count != 1 {
|
||||
t.Errorf("Paths[1].Count = %d, want 1", resp.Paths[1].Count)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// repeaterEnrichTTL bounds how stale the per-page bulk enrichment caches
|
||||
// for handleNodes may be. Same 15s budget as GetNodeHashSizeInfo — the
|
||||
// numbers feed an at-a-glance status column, not an alerting path, so
|
||||
// up-to-15s freshness is fine and keeps the request path O(page) instead
|
||||
// of O(page × byPathHop[pk] × parsed timestamps).
|
||||
const repeaterEnrichTTL = 15 * time.Second
|
||||
|
||||
// GetRepeaterRelayInfoMap returns a cached pubkey → RepeaterRelayInfo
|
||||
// map covering EVERY pubkey that currently appears as a path hop in any
|
||||
// non-advert StoreTx. This is the bulk equivalent of calling
|
||||
@@ -22,34 +29,28 @@ import (
|
||||
// The cached map is keyed by lowercase pubkey/hop key (same shape as
|
||||
// byPathHop). Lookups should use strings.ToLower(pk).
|
||||
//
|
||||
// The cache is refreshed by the background recomputer (every 5 min by
|
||||
// default). This function never rebuilds inline on a populated cache —
|
||||
// serving a slightly stale snapshot is always preferable to a 700ms
|
||||
// on-request rebuild. The only time an inline compute happens is when
|
||||
// the cache is nil (i.e. before the recomputer's synchronous prewarm
|
||||
// completes, which can occur in tests without a running recomputer).
|
||||
// The cache is invalidated by TTL only — never by ingest. With a 15s
|
||||
// budget that's acceptable for a status column; if a fresher signal is
|
||||
// ever needed for a non-status caller, expose a non-cached path.
|
||||
func (s *PacketStore) GetRepeaterRelayInfoMap(windowHours float64) map[string]RepeaterRelayInfo {
|
||||
s.repeaterEnrichMu.Lock()
|
||||
cached := s.repeaterRelayCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
if cached != nil {
|
||||
if s.repeaterRelayCache != nil &&
|
||||
time.Since(s.repeaterRelayAt) < repeaterEnrichTTL &&
|
||||
s.repeaterRelayCacheWin == windowHours {
|
||||
cached := s.repeaterRelayCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
return cached
|
||||
}
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
|
||||
// Cache is nil — recomputer hasn't prewarmed yet (edge case: tests
|
||||
// without a running recomputer, or a request racing the initial
|
||||
// synchronous prewarm). Build once inline; the recomputer takes over.
|
||||
result := s.computeRepeaterRelayInfoMap(windowHours)
|
||||
|
||||
s.repeaterEnrichMu.Lock()
|
||||
if s.repeaterRelayCache == nil {
|
||||
s.repeaterRelayCache = result
|
||||
s.repeaterRelayCacheWin = windowHours
|
||||
s.repeaterRelayAt = time.Now()
|
||||
}
|
||||
cached = s.repeaterRelayCache
|
||||
s.repeaterRelayCache = result
|
||||
s.repeaterRelayCacheWin = windowHours
|
||||
s.repeaterRelayAt = time.Now()
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
return cached
|
||||
return result
|
||||
}
|
||||
|
||||
// computeRepeaterRelayInfoMap walks byPathHop once under a single RLock,
|
||||
@@ -175,25 +176,23 @@ func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[strin
|
||||
// GetRepeaterUsefulnessScoreMap returns a cached pubkey → 0..1 score
|
||||
// for every pubkey appearing in byPathHop. Bulk equivalent of
|
||||
// GetRepeaterUsefulnessScore. See GetRepeaterRelayInfoMap for the
|
||||
// motivation (#1257) and the no-inline-rebuild rationale (#1272).
|
||||
// motivation (#1257).
|
||||
func (s *PacketStore) GetRepeaterUsefulnessScoreMap() map[string]float64 {
|
||||
s.repeaterEnrichMu.Lock()
|
||||
cached := s.repeaterUsefulCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
if cached != nil {
|
||||
if s.repeaterUsefulCache != nil && time.Since(s.repeaterUsefulAt) < repeaterEnrichTTL {
|
||||
cached := s.repeaterUsefulCache
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
return cached
|
||||
}
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
|
||||
result := s.computeRepeaterUsefulnessScoreMap()
|
||||
|
||||
s.repeaterEnrichMu.Lock()
|
||||
if s.repeaterUsefulCache == nil {
|
||||
s.repeaterUsefulCache = result
|
||||
s.repeaterUsefulAt = time.Now()
|
||||
}
|
||||
cached = s.repeaterUsefulCache
|
||||
s.repeaterUsefulCache = result
|
||||
s.repeaterUsefulAt = time.Now()
|
||||
s.repeaterEnrichMu.Unlock()
|
||||
return cached
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *PacketStore) computeRepeaterUsefulnessScoreMap() map[string]float64 {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGetRepeaterRelayInfoMap_ServesStaleOnTTLExpiry is a regression guard
|
||||
// for issue #1272.
|
||||
//
|
||||
// Background: GetRepeaterRelayInfoMap used to rebuild the cache inline
|
||||
// whenever the TTL expired, causing ~700ms latency spikes on /api/nodes.
|
||||
// The recomputer (StartRepeaterEnrichmentRecomputer) runs every 5 min and
|
||||
// already keeps the cache warm; there is no reason to rebuild on-request.
|
||||
//
|
||||
// This test verifies that a populated cache is ALWAYS returned as-is,
|
||||
// even when its timestamp is ancient (simulating TTL expiry under the old
|
||||
// code). The stale sentinel value proves no inline recompute occurred.
|
||||
func TestGetRepeaterRelayInfoMap_ServesStaleOnTTLExpiry(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
byPathHop: make(map[string][]*StoreTx),
|
||||
}
|
||||
|
||||
// Pre-populate the cache with a sentinel entry that would NOT be
|
||||
// produced by computeRepeaterRelayInfoMap on the empty byPathHop.
|
||||
stale := map[string]RepeaterRelayInfo{
|
||||
"sentinel": {RelayCount24h: 9999},
|
||||
}
|
||||
store.repeaterRelayAt = time.Now().Add(-24 * time.Hour) // well past any TTL
|
||||
store.repeaterRelayCache = stale
|
||||
store.repeaterRelayCacheWin = 24
|
||||
|
||||
got := store.GetRepeaterRelayInfoMap(24)
|
||||
|
||||
if got["sentinel"].RelayCount24h != 9999 {
|
||||
t.Fatalf("stale cache not served: sentinel missing or overwritten (RelayCount24h=%d)", got["sentinel"].RelayCount24h)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepeaterUsefulnessScoreMap_ServesStaleOnTTLExpiry mirrors
|
||||
// TestGetRepeaterRelayInfoMap_ServesStaleOnTTLExpiry for the usefulness
|
||||
// score map (same fix, same root cause).
|
||||
func TestGetRepeaterUsefulnessScoreMap_ServesStaleOnTTLExpiry(t *testing.T) {
|
||||
store := &PacketStore{
|
||||
byPathHop: make(map[string][]*StoreTx),
|
||||
byPayloadType: make(map[int][]*StoreTx),
|
||||
}
|
||||
|
||||
stale := map[string]float64{"sentinel": 0.42}
|
||||
store.repeaterUsefulAt = time.Now().Add(-24 * time.Hour)
|
||||
store.repeaterUsefulCache = stale
|
||||
|
||||
got := store.GetRepeaterUsefulnessScoreMap()
|
||||
|
||||
if got["sentinel"] != 0.42 {
|
||||
t.Fatalf("stale cache not served: sentinel missing or overwritten (score=%v)", got["sentinel"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRepeaterRelayInfoMap_BuildsWhenNil verifies that when the cache
|
||||
// is nil (before the recomputer's first prewarm), GetRepeaterRelayInfoMap
|
||||
// computes inline and caches the result for subsequent callers.
|
||||
func TestGetRepeaterRelayInfoMap_BuildsWhenNil(t *testing.T) {
|
||||
pt2 := 2
|
||||
now := time.Now().UTC()
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "abc",
|
||||
FirstSeen: now.Add(-10 * time.Minute).Format(time.RFC3339Nano),
|
||||
PayloadType: &pt2,
|
||||
}
|
||||
store := &PacketStore{
|
||||
byPathHop: map[string][]*StoreTx{"aabbcc": {tx}},
|
||||
byPayloadType: map[int][]*StoreTx{pt2: {tx}},
|
||||
mu: sync.RWMutex{},
|
||||
}
|
||||
|
||||
got := store.GetRepeaterRelayInfoMap(24)
|
||||
if _, ok := got["aabbcc"]; !ok {
|
||||
t.Fatal("inline compute did not produce entry for seeded hop key")
|
||||
}
|
||||
|
||||
// Second call must return the cached result, not a fresh recompute.
|
||||
got2 := store.GetRepeaterRelayInfoMap(24)
|
||||
if got2["aabbcc"].RelayCount24h != got["aabbcc"].RelayCount24h {
|
||||
t.Fatal("second call returned different map — cache not installed")
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
// repeaterEnrichmentRecomputerInterval is the default tick interval
|
||||
// for the steady-state recompute of the repeater enrichment bulk
|
||||
// caches. The on-request TTL fallback in repeater_enrich_bulk.go is
|
||||
// kept as a safety net — the recomputer just makes sure the cache is
|
||||
// populated before any request arrives.
|
||||
// caches. The on-request 15s-TTL fallback in repeater_enrich_bulk.go
|
||||
// is kept as a safety net — the recomputer just makes sure the cache
|
||||
// is populated before any request arrives.
|
||||
//
|
||||
// 5min mirrors the analytics_recomputer default from #1240 and is
|
||||
// plenty fresh for an at-a-glance status column.
|
||||
@@ -88,9 +88,9 @@ func (s *PacketStore) StartRepeaterEnrichmentRecomputer(windowHours float64, int
|
||||
// background goroutine (the previous snapshot remains valid).
|
||||
func recomputeRepeaterEnrichmentSafe(s *PacketStore, windowHours float64) {
|
||||
defer func() { _ = recover() }()
|
||||
// Write directly to the cache fields under mutex rather than going
|
||||
// through the public Get* helpers — those return the existing
|
||||
// non-nil cache immediately, so calling them here would be a no-op.
|
||||
// Bypass the 15s-TTL gate by forcing a fresh recompute and
|
||||
// installing the result. The public Get* helpers would return the
|
||||
// existing cache when within TTL; we want to refresh proactively.
|
||||
relay := s.computeRepeaterRelayInfoMap(windowHours)
|
||||
useful := s.computeRepeaterUsefulnessScoreMap()
|
||||
now := time.Now()
|
||||
|
||||
+11
-24
@@ -346,7 +346,6 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
||||
PropagationBufferMs: float64(s.cfg.PropagationBufferMs()),
|
||||
Timestamps: s.cfg.GetTimestampConfig(),
|
||||
DebugAffinity: s.cfg.DebugAffinity,
|
||||
MapDarkTileProvider: s.cfg.MapDarkTileProvider,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1224,15 +1223,7 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
node["relay_active"] = info.RelayActive
|
||||
node["relay_count_1h"] = info.RelayCount1h
|
||||
node["relay_count_24h"] = info.RelayCount24h
|
||||
// usefulness_score retained for API compat; new
|
||||
// consumers should read traffic_share_score
|
||||
// (issue #1456). When the #672 composite ships
|
||||
// usefulness_score will become the composite
|
||||
// and traffic_share_score will keep the
|
||||
// per-axis value.
|
||||
us := lookupUsefulnessScore(usefulMap, pk)
|
||||
node["usefulness_score"] = us
|
||||
node["traffic_share_score"] = us
|
||||
node["usefulness_score"] = lookupUsefulnessScore(usefulMap, pk)
|
||||
node["bridge_score"] = lookupUsefulnessScore(bridgeMap, pk)
|
||||
}
|
||||
}
|
||||
@@ -1379,11 +1370,7 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
node["relay_window_hours"] = info.WindowHours
|
||||
node["relay_count_1h"] = info.RelayCount1h
|
||||
node["relay_count_24h"] = info.RelayCount24h
|
||||
// usefulness_score retained for API compat; new
|
||||
// consumers should read traffic_share_score (#1456).
|
||||
us := s.store.GetRepeaterUsefulnessScore(pubkey)
|
||||
node["usefulness_score"] = us
|
||||
node["traffic_share_score"] = us
|
||||
node["usefulness_score"] = s.store.GetRepeaterUsefulnessScore(pubkey)
|
||||
node["bridge_score"] = s.store.GetBridgeScore(pubkey)
|
||||
}
|
||||
}
|
||||
@@ -1802,15 +1789,15 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
li := ""
|
||||
lj := ""
|
||||
if paths[i].LastSeen != nil {
|
||||
li = fmt.Sprintf("%v", paths[i].LastSeen)
|
||||
}
|
||||
if paths[j].LastSeen != nil {
|
||||
lj = fmt.Sprintf("%v", paths[j].LastSeen)
|
||||
}
|
||||
if li != lj {
|
||||
if paths[i].Count == paths[j].Count {
|
||||
li := ""
|
||||
lj := ""
|
||||
if paths[i].LastSeen != nil {
|
||||
li = fmt.Sprintf("%v", paths[i].LastSeen)
|
||||
}
|
||||
if paths[j].LastSeen != nil {
|
||||
lj = fmt.Sprintf("%v", paths[j].LastSeen)
|
||||
}
|
||||
return li > lj
|
||||
}
|
||||
return paths[i].Count > paths[j].Count
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestTrafficShareScore_HandleNodesSurface pins issue #1456: the
|
||||
// /api/nodes response carries a new `traffic_share_score` field
|
||||
// alongside the legacy `usefulness_score`, with the same numeric
|
||||
// value. The legacy field is kept for API backwards-compat (existing
|
||||
// consumers + stale frontends); the new field is the canonical name
|
||||
// for the Traffic-axis score.
|
||||
func TestTrafficShareScore_HandleNodesSurface(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pk := "aaaa000000000000000000000000000000000000000000000000000000000000"
|
||||
recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if _, err := db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'rpt', 'repeater', 37.5, -122.0, ?, ?, 10)`,
|
||||
pk, recent, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = store
|
||||
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes?limit=10", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("/api/nodes status: want 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
|
||||
}
|
||||
var got map[string]interface{}
|
||||
for _, n := range resp.Nodes {
|
||||
if k, _ := n["public_key"].(string); k == pk {
|
||||
got = n
|
||||
break
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("repeater node missing from /api/nodes response")
|
||||
}
|
||||
useful, hasU := got["usefulness_score"]
|
||||
share, hasS := got["traffic_share_score"]
|
||||
if !hasU {
|
||||
t.Errorf("usefulness_score absent (must remain for API compat)")
|
||||
}
|
||||
if !hasS {
|
||||
t.Errorf("traffic_share_score absent (new field per #1456)")
|
||||
}
|
||||
if hasU && hasS {
|
||||
uf, _ := useful.(float64)
|
||||
sf, _ := share.(float64)
|
||||
if uf != sf {
|
||||
t.Errorf("traffic_share_score (%v) must equal usefulness_score (%v)", sf, uf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrafficShareScore_NodeDetail pins the same dual-field shape on
|
||||
// the per-node detail endpoint /api/nodes/{pubkey}.
|
||||
func TestTrafficShareScore_NodeDetail(t *testing.T) {
|
||||
db := setupCapabilityTestDB(t)
|
||||
defer db.conn.Close()
|
||||
if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pk := "bbbb000000000000000000000000000000000000000000000000000000000000"
|
||||
recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if _, err := db.conn.Exec(`INSERT INTO nodes
|
||||
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (?, 'rpt', 'repeater', 37.5, -122.0, ?, ?, 10)`,
|
||||
pk, recent, recent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := NewPacketStore(db, nil)
|
||||
cfg := &Config{Port: 3000}
|
||||
hub := NewHub()
|
||||
srv := NewServer(db, cfg, hub)
|
||||
srv.store = store
|
||||
|
||||
router := mux.NewRouter()
|
||||
srv.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nodes/"+pk, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("/api/nodes/{pk} status: want 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Node map[string]interface{} `json:"node"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
|
||||
}
|
||||
if resp.Node == nil {
|
||||
t.Fatalf("node missing in response: %s", rr.Body.String())
|
||||
}
|
||||
if _, ok := resp.Node["usefulness_score"]; !ok {
|
||||
t.Errorf("usefulness_score absent on node detail (must remain for API compat)")
|
||||
}
|
||||
if _, ok := resp.Node["traffic_share_score"]; !ok {
|
||||
t.Errorf("traffic_share_score absent on node detail (new field per #1456)")
|
||||
}
|
||||
uf, _ := resp.Node["usefulness_score"].(float64)
|
||||
sf, _ := resp.Node["traffic_share_score"].(float64)
|
||||
if uf != sf {
|
||||
t.Errorf("traffic_share_score (%v) must equal usefulness_score (%v)", sf, uf)
|
||||
}
|
||||
}
|
||||
@@ -988,9 +988,6 @@ type ClientConfigResponse struct {
|
||||
PropagationBufferMs float64 `json:"propagationBufferMs"`
|
||||
Timestamps TimestampConfig `json:"timestamps"`
|
||||
DebugAffinity bool `json:"debugAffinity,omitempty"`
|
||||
// #1420 — server default for dark-tile provider picker. Client uses this
|
||||
// as the fallback when no localStorage override is set.
|
||||
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
|
||||
}
|
||||
|
||||
// ─── IATA Coords ───────────────────────────────────────────────────────────────
|
||||
|
||||
+2
-13
@@ -47,8 +47,6 @@
|
||||
"observer": "#8b5cf6",
|
||||
"_comment": "Marker/badge colors per node role. Used on map, nodes list, and live feed."
|
||||
},
|
||||
"mapDarkTileProvider": "carto-dark",
|
||||
"_comment_mapDarkTileProvider": "Default dark-mode basemap provider. Allowed: 'carto-dark' (Carto dark_all — default), 'esri-darkgray-labels' (Esri Dark Gray Canvas + reference labels), 'voyager-inverted' (Carto Voyager with CSS invert filter), 'positron-inverted' (Carto Positron with CSS invert filter). Light mode is unaffected. Users can override per-browser via the in-app customizer (persisted to localStorage). #1420.",
|
||||
"home": {
|
||||
"heroTitle": "CoreScope",
|
||||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||||
@@ -137,16 +135,6 @@
|
||||
],
|
||||
"region": "SJC",
|
||||
"connectTimeoutSec": 45
|
||||
},
|
||||
{
|
||||
"_comment": "WebSocket MQTT broker (e.g. meshcore-mqtt-broker). Use ws:// for plain WebSocket or wss:// for TLS. Username/password supported.",
|
||||
"name": "wsmqtt",
|
||||
"broker": "wss://wsmqtt.example.com/mqtt",
|
||||
"username": "corescope",
|
||||
"password": "your-password",
|
||||
"topics": [
|
||||
"meshcore/#"
|
||||
]
|
||||
}
|
||||
],
|
||||
"channelKeys": {
|
||||
@@ -274,7 +262,7 @@
|
||||
"criticalMv": 3000,
|
||||
"_comment": "Voltage cutoffs (millivolts) for the per-node battery trend chart on /node-analytics. Latest sample below lowMv shows the node as ⚠️ Low; below criticalMv shows 🪫 Critical. Both default to 3300 / 3000 if omitted. Source data: observer_metrics.battery_mv populated from observer status messages; only nodes that are themselves observers (matching pubkey ↔ observer id) yield a series. Issue #663."
|
||||
},
|
||||
"_comment_mqttSources": "Each source connects to an MQTT broker. Supported schemes: mqtt:// (plain TCP), mqtts:// (TLS), ws:// (WebSocket), wss:// (WebSocket TLS). topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field).",
|
||||
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field).",
|
||||
"compression": {
|
||||
"gzip": false,
|
||||
"websocket": false,
|
||||
@@ -291,6 +279,7 @@
|
||||
]
|
||||
},
|
||||
"_comment_compression": "Opt-in HTTP gzip middleware + WebSocket permessage-deflate. Both default to false — enable ONLY when your upstream reverse proxy is NOT already compressing. gzip: enables the gzipMiddleware wrapper around the HTTP handler. websocket: sets gorilla websocket Upgrader.EnableCompression. level: gzip compression level 1..9 (1=BestSpeed, 9=BestCompression, default 6). minSizeBytes: advisory minimum response size below which compression would not pay off. contentTypes: MIME allow-list — only responses with these Content-Type values are compressed. Already-compressed types (image/*, video/*, audio/*, application/zip, application/x-gzip, application/pdf, application/octet-stream) are always skipped, as are responses whose handler already set Content-Encoding. Omit contentTypes to use the built-in default allow-list.",
|
||||
"_comment_mqttSources": "Each source connects to an MQTT broker. topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional).",
|
||||
"_comment_channelKeys": "Hex keys for decrypting channel messages. Key name = channel display name. public channel key is well-known.",
|
||||
"_comment_hashChannels": "Channel names whose keys are derived via SHA256. Key = SHA256(name)[:16]. Listed here so the ingestor can auto-derive keys.",
|
||||
"hashRegions": [
|
||||
|
||||
+9
-57
@@ -1615,14 +1615,6 @@
|
||||
html += hashMatrixLegendHtml(legendLabels);
|
||||
el.innerHTML = html;
|
||||
initMatrixTooltip(el);
|
||||
// #1473 — Grey out cells whose first byte the MeshCore firmware keygen
|
||||
// routine avoids (pub_key[0] in {0x00, 0xFF}). This is a keygen
|
||||
// CONVENTION, not a protocol-level rejection — see firmware
|
||||
// examples/simple_repeater/main.cpp:83 (HEAD 8ede7641). Must run BEFORE
|
||||
// we wire click handlers so .hash-active is stripped first.
|
||||
if (typeof PrefixReserved !== 'undefined' && PrefixReserved && typeof PrefixReserved.markReservedCells === 'function') {
|
||||
PrefixReserved.markReservedCells(el);
|
||||
}
|
||||
el.querySelectorAll('.hash-active').forEach(td => {
|
||||
td.addEventListener('click', () => {
|
||||
clickHandlerFn(td);
|
||||
@@ -3000,12 +2992,6 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
<div class="analytics-card" id="ptGenerator">
|
||||
<h3 style="margin-top:0">Generate Available Prefix</h3>
|
||||
<p class="text-muted" style="margin-top:0;font-size:0.9em">Find a prefix with zero current collisions.</p>
|
||||
<p class="text-muted" style="margin:4px 0 10px;font-size:0.82em">
|
||||
<span aria-hidden="true">🚫</span>
|
||||
<strong>0x00 and 0xFF excluded</strong> as a first byte — the MeshCore firmware keygen routine re-rolls identities whose <code>pub_key[0]</code> is <code>00</code> or <code>FF</code>, so by convention you should not see those prefixes on real nodes (see
|
||||
<a href="https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83"
|
||||
target="_blank" rel="noopener noreferrer" style="color:var(--accent)">simple_repeater/main.cpp:83</a>).
|
||||
</p>
|
||||
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ptGenSize" value="1" ${initGenerate === '1' ? 'checked' : ''}> 1-byte
|
||||
@@ -3066,19 +3052,6 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
: [{ b: input.length / 2, prefix: input }];
|
||||
|
||||
let html = '';
|
||||
// #1473 — Warn when the user pastes a prefix or full pubkey whose
|
||||
// first byte is one the MeshCore firmware keygen routine avoids
|
||||
// (pub_key[0] in {0x00, 0xFF}). Firmware keygen CONVENTION, not a
|
||||
// protocol-level rejection — see simple_repeater/main.cpp:83.
|
||||
if (typeof PrefixReserved !== 'undefined' && PrefixReserved &&
|
||||
PrefixReserved.isReservedPrefix(input)) {
|
||||
html += `<div role="alert" style="margin-bottom:10px;padding:10px 14px;border:1px solid var(--status-yellow);border-radius:6px;background:var(--bg-secondary,var(--bg))">
|
||||
<strong style="color:var(--status-yellow)">⚠️ Firmware avoids this first byte</strong>
|
||||
<div class="text-muted" style="font-size:0.85em;margin-top:4px">
|
||||
<code class="mono">${input.slice(0,2)}</code> as the first byte of a node pubkey is avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose <code class="mono">pub_key[0]</code> is <code class="mono">00</code> or <code class="mono">FF</code>). You generally shouldn't see this on real nodes.
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (isFullKey) {
|
||||
const inNetwork = nodes.some(n => n.public_key.toUpperCase() === input);
|
||||
html += `<p class="text-muted" style="font-size:0.85em;margin:0 0 10px">Derived prefixes: <code class="mono">${input.slice(0,2)}</code> / <code class="mono">${input.slice(0,4)}</code> / <code class="mono">${input.slice(0,6)}</code>${!inNetwork ? ' — <em>this node is not yet in the network</em>' : ''}</p>`;
|
||||
@@ -3112,55 +3085,34 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
const b = sizeInput ? parseInt(sizeInput.value) : 2;
|
||||
const hexLen = b * 2;
|
||||
const totalSpace = spaceSizes[b];
|
||||
// #1473 — Reserved prefixes (first byte 0x00 / 0xFF) are dropped from
|
||||
// the candidate pool because the MeshCore firmware keygen routine
|
||||
// re-rolls identities whose pub_key[0] is 0x00 or 0xFF — a keygen
|
||||
// CONVENTION (not a protocol rejection). See firmware
|
||||
// examples/simple_repeater/main.cpp:83 (HEAD 8ede7641).
|
||||
// Available = space - used - reserved.
|
||||
const reservedTotal = (typeof PrefixReserved !== 'undefined' && PrefixReserved)
|
||||
? PrefixReserved.reservedCount(b)
|
||||
: 0;
|
||||
// Count reserved prefixes that are ALREADY used so we don't subtract them twice.
|
||||
let reservedUsed = 0;
|
||||
if (typeof PrefixReserved !== 'undefined' && PrefixReserved) {
|
||||
for (const p of idx[b].keys()) {
|
||||
if (PrefixReserved.isReservedPrefix(p)) reservedUsed++;
|
||||
}
|
||||
}
|
||||
const available = totalSpace - idx[b].size - (reservedTotal - reservedUsed);
|
||||
const available = totalSpace - idx[b].size;
|
||||
|
||||
if (available <= 0) {
|
||||
if (available === 0) {
|
||||
const next = b < 3 ? (b + 1) + '-byte' : 'a different size';
|
||||
genResultEl.innerHTML = `<p style="color:var(--status-red);margin:0">No collision-free ${b}-byte prefixes available. Try ${next}.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const isReserved = (p) =>
|
||||
(typeof PrefixReserved !== 'undefined' && PrefixReserved)
|
||||
? PrefixReserved.isReservedPrefix(p)
|
||||
: false;
|
||||
|
||||
let prefix;
|
||||
if (b === 1) {
|
||||
// Enumerate all 256 options, skipping used + reserved.
|
||||
// Enumerate all 256 options
|
||||
const free = [];
|
||||
for (let i = 0; i < totalSpace; i++) {
|
||||
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
if (!idx[b].has(p) && !isReserved(p)) free.push(p);
|
||||
if (!idx[b].has(p)) free.push(p);
|
||||
}
|
||||
prefix = free[Math.floor(Math.random() * free.length)];
|
||||
} else {
|
||||
// Random sampling — with 2K used / 65K space, hit rate >96%.
|
||||
// Random sampling — with 2K used / 65K space, hit rate >96%
|
||||
let attempts = 0;
|
||||
do {
|
||||
prefix = Math.floor(Math.random() * totalSpace).toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
} while ((idx[b].has(prefix) || isReserved(prefix)) && ++attempts < 500);
|
||||
// Fallback to enumeration if sampling kept hitting used/reserved prefixes.
|
||||
if (idx[b].has(prefix) || isReserved(prefix)) {
|
||||
} while (idx[b].has(prefix) && ++attempts < 500);
|
||||
// Fallback to enumeration if sampling kept hitting used prefixes
|
||||
if (idx[b].has(prefix)) {
|
||||
for (let i = 0; i < totalSpace; i++) {
|
||||
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
if (!idx[b].has(p) && !isReserved(p)) { prefix = p; break; }
|
||||
if (!idx[b].has(p)) { prefix = p; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1348,19 +1348,9 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
requestAnimationFrame(applyNavPriority);
|
||||
});
|
||||
|
||||
// #1406: position the fixed dropdown relative to the More button on each open.
|
||||
// Required because .nav-more-menu is position:fixed (so it escapes
|
||||
// .nav-more-wrap's layout box and doesn't inflate the parent flex line).
|
||||
function positionMoreMenu() {
|
||||
var wr = navMoreWrap.getBoundingClientRect();
|
||||
navMoreMenu.style.top = (wr.bottom + 4) + 'px';
|
||||
navMoreMenu.style.right = (window.innerWidth - wr.right) + 'px';
|
||||
navMoreMenu.style.left = 'auto';
|
||||
}
|
||||
navMoreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const opening = !navMoreMenu.classList.contains('open');
|
||||
if (opening) positionMoreMenu();
|
||||
navMoreMenu.classList.toggle('open');
|
||||
navMoreBtn.setAttribute('aria-expanded', String(opening));
|
||||
if (opening) {
|
||||
@@ -1368,10 +1358,6 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
if (firstLink) firstLink.focus();
|
||||
}
|
||||
});
|
||||
// Re-position on window resize while open.
|
||||
window.addEventListener('resize', () => {
|
||||
if (navMoreMenu.classList.contains('open')) positionMoreMenu();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
|
||||
+16
-108
@@ -37,20 +37,11 @@
|
||||
sensor: '#F0E442', // yellow
|
||||
observer: '#CC79A7' // reddish-purple
|
||||
},
|
||||
// #1407 — per-role text colors paired with each bg for WCAG 1.4.3 AA
|
||||
// (≥4.5:1). Wong defaults all pass with dark text; explicit so the
|
||||
// CSS-var pipeline is uniform across presets.
|
||||
roleText: {
|
||||
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#1a1a1a',
|
||||
sensor: '#1a1a1a', observer: '#1a1a1a'
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#56F0A0',
|
||||
suspected: '#FFD966',
|
||||
unknown: '#FF8888'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
|
||||
},
|
||||
{
|
||||
id: 'deut',
|
||||
@@ -64,19 +55,11 @@
|
||||
sensor: '#FFB000', // amber
|
||||
observer: '#DC267F' // magenta
|
||||
},
|
||||
// #1407 — IBM 5-class: room (#785EF0) and observer (#DC267F) fail AA
|
||||
// with #1a1a1a (3.86 / 3.83). Flip to white where needed.
|
||||
roleText: {
|
||||
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#ffffff',
|
||||
sensor: '#1a1a1a', observer: '#ffffff'
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#648FFF',
|
||||
suspected: '#FFB000',
|
||||
unknown: '#DC267F'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
|
||||
},
|
||||
{
|
||||
id: 'prot',
|
||||
@@ -89,18 +72,11 @@
|
||||
sensor: '#FE6100',
|
||||
observer: '#DC267F'
|
||||
},
|
||||
// Same as deut for room/observer.
|
||||
roleText: {
|
||||
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#ffffff',
|
||||
sensor: '#1a1a1a', observer: '#ffffff'
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#648FFF',
|
||||
suspected: '#FFB000',
|
||||
unknown: '#DC267F'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
|
||||
},
|
||||
{
|
||||
id: 'trit',
|
||||
@@ -114,30 +90,20 @@
|
||||
sensor: '#DDCC77', // sand (replaces pure yellow)
|
||||
observer: '#AA4499' // purple
|
||||
},
|
||||
// #1407 — Tol muted has 3 darker anchors that fail with dark text:
|
||||
// companion #117733 vs #1a1a1a = 3.71:1 → use white text
|
||||
// room #882255 vs #1a1a1a = 2.41:1 → use white text
|
||||
// observer #AA4499 vs #1a1a1a = 4.00:1 → use white text
|
||||
// The 2 lighter anchors (rose, sand) keep dark text.
|
||||
roleText: {
|
||||
repeater: '#1a1a1a', // #CC6677 vs #1a1a1a = 5.73:1 ✓
|
||||
companion: '#ffffff', // #117733 vs #fff = 5.66:1 ✓
|
||||
room: '#ffffff', // #882255 vs #fff = 8.71:1 ✓
|
||||
sensor: '#1a1a1a', // #DDCC77 vs #1a1a1a = 12.98:1 ✓
|
||||
observer: '#ffffff' // #AA4499 vs #fff = 5.25:1 ✓
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#117733',
|
||||
suspected: '#DDCC77',
|
||||
unknown: '#CC6677'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
|
||||
},
|
||||
{
|
||||
id: 'achromat',
|
||||
label: 'Achromatopsia (monochrome)',
|
||||
description: 'Pure luminance ramp — relies on shape/letter/glyph carriers from #1356/#1357.',
|
||||
// Luminance ramp at 90/70/50/35/20% per spec. Achromat users distinguish
|
||||
// by lightness; the shape/letter/glyph carriers from #1356/#1357 carry
|
||||
// role identity. Map markers also have the dark halo from #1356 so even
|
||||
// light-grey fills remain visible against Carto-positron.
|
||||
roleColors: {
|
||||
repeater: '#333333', // L=20%
|
||||
companion: '#595959', // L=35%
|
||||
@@ -145,29 +111,11 @@
|
||||
sensor: '#b3b3b3', // L=70%
|
||||
observer: '#e6e6e6' // L=90%
|
||||
},
|
||||
// #1407 — original bug: pill text locked to #1a1a1a → 3 of 5 fail AA.
|
||||
// Fix: white text on the 2 darkest grays, dark text on the 2 lightest,
|
||||
// pure black for L=50 mid-gray (neither #1a1a1a nor #fff clears 4.5
|
||||
// there — black yields 5.32:1).
|
||||
// repeater #333 vs #fff = 12.63:1 ✓
|
||||
// companion #595959 vs #fff = 7.00:1 ✓
|
||||
// room #808080 vs #000 = 5.32:1 ✓ (vs #1a1a1a = 4.41 ✗ / #fff = 3.95 ✗)
|
||||
// sensor #b3b3b3 vs #1a1a1a = 8.30:1 ✓
|
||||
// observer #e6e6e6 vs #1a1a1a = 13.94:1 ✓
|
||||
roleText: {
|
||||
repeater: '#ffffff',
|
||||
companion: '#ffffff',
|
||||
room: '#000000',
|
||||
sensor: '#1a1a1a',
|
||||
observer: '#1a1a1a'
|
||||
},
|
||||
mb: {
|
||||
confirmed: '#b3b3b3',
|
||||
suspected: '#808080',
|
||||
unknown: '#595959'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#222222', '#555555', '#888888', '#bbbbbb', '#eeeeee']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -242,26 +190,20 @@
|
||||
Object.keys(p.roleColors).forEach(function (role) {
|
||||
style.setProperty('--mc-role-' + role, p.roleColors[role]);
|
||||
});
|
||||
// #1407 — per-role text-color CSS vars so .mc-pill / badges can pick
|
||||
// a foreground that meets WCAG 1.4.3 AA against the role bg.
|
||||
var rt = p.roleText || {};
|
||||
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
||||
style.setProperty('--mc-role-' + role + '-text', rt[role] || '#1a1a1a');
|
||||
});
|
||||
Object.keys(p.mb).forEach(function (k) {
|
||||
style.setProperty('--mc-mb-' + k, p.mb[k]);
|
||||
});
|
||||
// #1418 — route-view sequence ramp (5 stops). route-view.js reads
|
||||
// --mc-rt-ramp-0..4 instead of hardcoded viridis/magma so a CB preset
|
||||
// changes the route edge colors live. Achromat uses a luminance ramp.
|
||||
var rr = p.routeRamp || ['#440154','#3b528b','#21918c','#5ec962','#fde725'];
|
||||
for (var ri = 0; ri < 5; ri++) {
|
||||
style.setProperty('--mc-rt-ramp-' + ri, rr[ri] || rr[rr.length - 1]);
|
||||
// Keep window.ROLE_COLORS in sync so legend/cluster JS picks up new hues.
|
||||
if (typeof window !== 'undefined' && window.ROLE_COLORS) {
|
||||
Object.keys(p.roleColors).forEach(function (role) {
|
||||
window.ROLE_COLORS[role] = p.roleColors[role];
|
||||
});
|
||||
if (window.ROLE_STYLE) {
|
||||
Object.keys(p.roleColors).forEach(function (role) {
|
||||
if (window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = p.roleColors[role];
|
||||
});
|
||||
}
|
||||
}
|
||||
// #1407 — ROLE_COLORS / ROLE_STYLE are now live getters in roles.js
|
||||
// that read --mc-role-* directly, so no explicit sync is needed. The
|
||||
// pre-#1407 code path kept them in sync as a workaround for the static
|
||||
// literal bug; with the getter it's a no-op and removed.
|
||||
}
|
||||
if (!opts.skipPersist) {
|
||||
try { if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, p.id); } catch (e) {}
|
||||
@@ -279,44 +221,11 @@
|
||||
if (v && _byId(v)) return v;
|
||||
}
|
||||
} catch (e) {}
|
||||
// #1446 — return null when no preset is stored. Previously this returned
|
||||
// 'default' unconditionally, which forced body[data-cb-preset="default"]
|
||||
// on every cold boot and trapped --mc-role-* in the Wong palette via the
|
||||
// matching style.css rule. The CB preset is now an end-user opt-in:
|
||||
// absent attribute = "no preset", role colors flow from server config.
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearPreset() {
|
||||
try { if (typeof localStorage !== 'undefined') localStorage.removeItem(STORAGE_KEY); } catch (e) {}
|
||||
if (typeof document !== 'undefined' && document.body && document.body.removeAttribute) {
|
||||
document.body.removeAttribute(DATA_ATTR);
|
||||
}
|
||||
// Strip preset-written CSS vars from documentElement so the cascade
|
||||
// re-falls through :root defaults (or server config, which the
|
||||
// customizer pipeline re-applies via the cb-preset-changed listener).
|
||||
if (typeof document !== 'undefined' && document.documentElement && document.documentElement.style) {
|
||||
var style = document.documentElement.style;
|
||||
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
||||
style.removeProperty('--mc-role-' + role);
|
||||
style.removeProperty('--mc-role-' + role + '-text');
|
||||
});
|
||||
['confirmed', 'suspected', 'unknown'].forEach(function (k) {
|
||||
style.removeProperty('--mc-mb-' + k);
|
||||
});
|
||||
for (var ri = 0; ri < 5; ri++) style.removeProperty('--mc-rt-ramp-' + ri);
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof window.CustomEvent === 'function') {
|
||||
try { window.dispatchEvent(new window.CustomEvent('cb-preset-changed', { detail: { id: null } })); } catch (e) {}
|
||||
}
|
||||
return true;
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function initFromStorage() {
|
||||
var id = currentPreset();
|
||||
// #1446 — only apply when a preset is actually stored. No stored preset
|
||||
// means "no preset active" (the new default), not "fall back to Wong".
|
||||
if (id) applyPreset(id, { skipPersist: true });
|
||||
applyPreset(currentPreset(), { skipPersist: true });
|
||||
}
|
||||
|
||||
// Cross-tab sync via storage event.
|
||||
@@ -338,7 +247,6 @@
|
||||
var api = {
|
||||
list: PRESETS,
|
||||
applyPreset: applyPreset,
|
||||
clearPreset: clearPreset,
|
||||
currentPreset: currentPreset,
|
||||
initFromStorage: initFromStorage,
|
||||
validatePreset: validatePreset,
|
||||
|
||||
+5
-19
@@ -779,11 +779,10 @@
|
||||
|
||||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||||
|
||||
// #1409: Do NOT force-enable encrypted-channel visibility on init. The
|
||||
// operator-facing toggle (read at the includeEncrypted gate in
|
||||
// loadChannels) drives whether the API returns the 246+ encrypted
|
||||
// placeholders. Default is OFF (hidden); a future user-facing toggle
|
||||
// writes the flag explicitly.
|
||||
// #1034 PR1: encrypted-channels visibility now driven by sectioned sidebar.
|
||||
// Always include encrypted channels in the API call; the renderer groups them.
|
||||
var showEncrypted = true;
|
||||
try { localStorage.setItem('channels-show-encrypted', 'true'); } catch (e) { /* quota */ }
|
||||
|
||||
regionChangeHandler = RegionFilter.onChange(function () {
|
||||
loadChannels(true).then(async function () {
|
||||
@@ -1079,13 +1078,6 @@
|
||||
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
|
||||
});
|
||||
|
||||
// #1454 — customizer flips the "show encrypted channels" toggle, which
|
||||
// writes localStorage and fires this event. Re-fetch the list live so
|
||||
// the operator sees the change without a page reload.
|
||||
window.addEventListener('mc-channels-show-encrypted-changed', function () {
|
||||
loadChannels(true);
|
||||
});
|
||||
|
||||
// #89: Sidebar resize handle
|
||||
(function () {
|
||||
var sidebar = app.querySelector('.ch-sidebar');
|
||||
@@ -1332,13 +1324,7 @@
|
||||
var payload = m.data?.decoded?.payload;
|
||||
if (!payload) continue;
|
||||
|
||||
// #1468: drop CHAN messages with no decoded channel name instead of
|
||||
// synthesizing a literal "unknown" row that renders as a fake channel
|
||||
// in the sidebar. Server-side (#1373/#1377) already filters these from
|
||||
// /api/channels; the live WebSocket router was the remaining offender.
|
||||
if (!payload.channel) continue;
|
||||
|
||||
var channelName = payload.channel;
|
||||
var channelName = payload.channel || 'unknown';
|
||||
// For live-decrypted user-added (PSK) channels, decryptLivePSKBatch
|
||||
// also stamps payload.channelKey ("user:<name>") so we route the
|
||||
// message to the correct sidebar row and to the open chat view.
|
||||
|
||||
+17
-171
@@ -74,10 +74,8 @@
|
||||
img.className = 'brand-logo';
|
||||
img.setAttribute('src', url);
|
||||
img.setAttribute('alt', alt || node.getAttribute('aria-label') || 'Brand');
|
||||
// #1450 — DO NOT set width/height attrs. CSS img.brand-logo handles
|
||||
// sizing (height:36px, width:auto, max-width cap) so the operator's
|
||||
// natural image aspect ratio is preserved instead of being squished
|
||||
// into the default SVG's 125x36 pill box.
|
||||
img.setAttribute('width', '125');
|
||||
img.setAttribute('height', '36');
|
||||
node.parentNode.replaceChild(img, node);
|
||||
} else {
|
||||
if (node.tagName.toLowerCase() !== 'img') {
|
||||
@@ -548,63 +546,13 @@
|
||||
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
|
||||
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
|
||||
|
||||
// Node colors → --node-X CSS var only (legacy compat).
|
||||
// #1412: do NOT push server-config nodeColors into window.ROLE_COLORS —
|
||||
// that defeats cb-presets propagation by trapping the legacy palette in
|
||||
// the _roleOverrides map (where the live getter prefers it over the
|
||||
// --mc-role-X CSS vars that presets actually write). User-chosen
|
||||
// overrides still flow through setRoleColorOverride() in customize.js.
|
||||
// Node colors → CSS vars + global objects
|
||||
var nc = effectiveConfig.nodeColors;
|
||||
if (nc) {
|
||||
// #1438 final: scope --mc-role-{role} writes to USER overrides only,
|
||||
// UNLESS no CB preset is active (#1446). When a preset is active the
|
||||
// server-config palette must stay out of --mc-role-* so the preset
|
||||
// wins (preserves #1412). When NO preset is active, the cascade is:
|
||||
// user override > server config > built-in :root default.
|
||||
// → server config gets to write --mc-role-{role} in that case.
|
||||
var userNc = (userOverrides && userOverrides.nodeColors) || {};
|
||||
var presetActive = false;
|
||||
try {
|
||||
var presetAttr = document.body && document.body.getAttribute && document.body.getAttribute('data-cb-preset');
|
||||
presetActive = !!(presetAttr && presetAttr !== 'none');
|
||||
} catch (e) {}
|
||||
for (var role in nc) {
|
||||
root.setProperty('--node-' + role, nc[role]);
|
||||
if (Object.prototype.hasOwnProperty.call(userNc, role)) {
|
||||
// Operator picked this color → drive --mc-role-{role} so marker
|
||||
// SVGs (fill="var(--mc-role-X)") and other CSS-var consumers
|
||||
// pick it up on every page load. Without this the user pick
|
||||
// sits in localStorage but --mc-role-{role} falls back to the
|
||||
// active preset on reload, reverting marker fills.
|
||||
root.setProperty('--mc-role-' + role, nc[role]);
|
||||
// #1446 — also write to body.style with !important so the user
|
||||
// pick beats the body[data-cb-preset="X"] selector cascade when
|
||||
// a CB preset is active. Without this, the root-level write is
|
||||
// shadowed by the preset's body-scoped CSS rule (root cause of
|
||||
// #1444). When no preset is active, the body write is harmless
|
||||
// and still wins inheritance.
|
||||
if (presetActive && document.body && document.body.style) {
|
||||
document.body.style.setProperty('--mc-role-' + role, nc[role], 'important');
|
||||
}
|
||||
} else if (!presetActive) {
|
||||
// #1446 — no preset is active; server config is the legitimate
|
||||
// source of role colors. Write --mc-role-{role} so marker SVGs
|
||||
// honor operator's config.json without forcing visitors to pick
|
||||
// a CB preset to "unlock" their server palette.
|
||||
root.setProperty('--mc-role-' + role, nc[role]);
|
||||
} else if (presetActive && document.body && document.body.style) {
|
||||
// Preset active AND this role has no user override:
|
||||
// ensure any prior body inline !important is removed so the
|
||||
// preset value (from body[data-cb-preset=X] CSS rule) takes over.
|
||||
// Also remove the root-level --mc-role-{role} that a PREVIOUS
|
||||
// setOverride call left behind (#1446 followup): without this,
|
||||
// :root.style.--mc-role-{role} stays stuck at the old user-pick
|
||||
// value even though body's cascaded preset rule now wins for
|
||||
// descendant elements. The visible UI is correct but introspection
|
||||
// (getComputedStyle on documentElement) reports stale color.
|
||||
document.body.style.removeProperty('--mc-role-' + role);
|
||||
root.removeProperty('--mc-role-' + role);
|
||||
}
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = nc[role];
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = nc[role];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1182,9 +1130,7 @@
|
||||
function _renderColorblindPresetSelector() {
|
||||
var MCP = (typeof window !== 'undefined') && window.MeshCorePresets;
|
||||
if (!MCP || !Array.isArray(MCP.list)) return '';
|
||||
// #1446 — currentPreset() now returns null when no preset is stored.
|
||||
var current = MCP.currentPreset ? MCP.currentPreset() : null;
|
||||
var clearOpt = _renderCbPresetClearOption(current);
|
||||
var current = MCP.currentPreset ? MCP.currentPreset() : 'default';
|
||||
var options = MCP.list.map(function (p) {
|
||||
var checked = p.id === current ? ' checked' : '';
|
||||
return '<label class="cust-cb-preset-row" style="display:flex;gap:8px;align-items:flex-start;margin:6px 0;cursor:pointer">' +
|
||||
@@ -1196,25 +1142,12 @@
|
||||
'</div>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
return '<p class="cust-section-title">Optional: Colorblind-Safe Preset</p>' +
|
||||
'<p class="cust-hint" style="margin-bottom:8px">A CB preset is an end-user opt-in that swaps the role/status palette for color-vision variants. ' +
|
||||
'Leave unset to use the operator\'s configured colors (or pick from above). ' +
|
||||
'Achromatopsia uses a luminance-only ramp and relies on the shape/letter/glyph carriers from #1356/#1357.</p>' +
|
||||
'<div class="cust-cb-presets" data-cv2-cb-preset-group>' + clearOpt + options + '</div>' +
|
||||
return '<p class="cust-section-title">Colorblind Preset</p>' +
|
||||
'<p class="cust-hint" style="margin-bottom:8px">Switch the role/status palette for color-vision variants. Achromatopsia uses a luminance-only ramp and relies on the shape/letter/glyph carriers from #1356/#1357.</p>' +
|
||||
'<div class="cust-cb-presets" data-cv2-cb-preset-group>' + options + '</div>' +
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">';
|
||||
}
|
||||
|
||||
function _renderCbPresetClearOption(current) {
|
||||
var checked = !current ? ' checked' : '';
|
||||
return '<label class="cust-cb-preset-row" style="display:flex;gap:8px;align-items:flex-start;margin:6px 0;cursor:pointer">' +
|
||||
'<input type="radio" name="cv2-cb-preset" data-cv2-cb-preset value="" data-cv2-cb-preset-none' + checked + ' style="margin-top:3px">' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-weight:600">No preset (use operator / custom colors)</div>' +
|
||||
'<div class="cust-hint" style="font-size:12px;color:var(--text-muted)">Default — server-configured colors apply, then any per-role overrides above.</div>' +
|
||||
'</div>' +
|
||||
'</label>';
|
||||
}
|
||||
|
||||
function _renderCbPresetWarning(id) {
|
||||
var MCP = window.MeshCorePresets;
|
||||
if (!MCP || typeof MCP.validatePreset !== 'function') return '';
|
||||
@@ -1263,11 +1196,9 @@
|
||||
var liveHeatPct = Math.round(liveHeatOpacity * 100);
|
||||
|
||||
return '<div class="cust-panel' + (_activeTab === 'nodes' ? ' active' : '') + '" data-panel="nodes">' +
|
||||
'<p class="cust-section-title">Node Role Colors</p>' +
|
||||
'<p class="cust-hint" style="margin-bottom:8px">These are the canonical role colors used across the app. They inherit from your server config (or built-in defaults), and can be optionally remapped by a colorblind-safe preset below.</p>' +
|
||||
rows +
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
|
||||
_renderColorblindPresetSelector() +
|
||||
'<p class="cust-section-title">Node Role Colors</p>' + rows +
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
|
||||
'<p class="cust-section-title">Packet Type Colors</p>' + typeRows +
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
|
||||
'<p class="cust-section-title">Heatmap Opacity</p>' +
|
||||
@@ -1322,50 +1253,9 @@
|
||||
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Gesture Hints</p>' +
|
||||
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).</p>' +
|
||||
'<button type="button" class="cust-dl-btn" data-cv2-reset-hints data-reset-gesture-hints>↺ Reset gesture hints</button>' +
|
||||
_renderChannelsShowEncryptedToggle() +
|
||||
_renderDarkTileProviderSelector() +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── #1454 Show-encrypted-channels toggle ──
|
||||
// Writes localStorage["channels-show-encrypted"]. Default OFF: key is
|
||||
// removed (not set to "false") so the read-gate in channels.js cleanly
|
||||
// returns false. Fires `mc-channels-show-encrypted-changed`; channels.js
|
||||
// re-fetches the list live without a page reload.
|
||||
function _renderChannelsShowEncryptedToggle() {
|
||||
var on = false;
|
||||
try { on = localStorage.getItem('channels-show-encrypted') === 'true'; } catch (_e) {}
|
||||
return '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Channels</p>' +
|
||||
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Encrypted channels appear as "Encrypted (0xAB)" with no name. Operators usually leave this off.</p>' +
|
||||
'<div class="cust-field" style="display:flex;align-items:center;gap:8px">' +
|
||||
'<input type="checkbox" id="cv2-channels-show-encrypted" data-cv2-channels-show-encrypted' +
|
||||
(on ? ' checked' : '') +
|
||||
' style="width:16px;height:16px;cursor:pointer">' +
|
||||
'<label for="cv2-channels-show-encrypted" style="cursor:pointer;margin:0">Show encrypted channels</label>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── #1420 Dark-tile provider selector ──
|
||||
// Persists per-browser via MC_setDarkTileProvider; map.js / live.js
|
||||
// listen for `mc-tile-provider-changed` and swap tiles live.
|
||||
function _renderDarkTileProviderSelector() {
|
||||
var reg = (typeof window !== 'undefined') && window.MC_TILE_PROVIDERS;
|
||||
if (!reg) return '';
|
||||
var active = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
|
||||
var ids = ['carto-dark', 'esri-darkgray-labels', 'voyager-inverted', 'positron-inverted'];
|
||||
var options = ids.filter(function (id) { return reg[id]; }).map(function (id) {
|
||||
var label = reg[id].label || id;
|
||||
var sel = id === active ? ' selected' : '';
|
||||
return '<option value="' + escAttr(id) + '"' + sel + '>' + esc(label) + '</option>';
|
||||
}).join('');
|
||||
return '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Dark Map Tiles</p>' +
|
||||
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Choose the dark-mode basemap. Light mode is unaffected. Inverted variants apply a CSS filter for higher contrast.</p>' +
|
||||
'<div class="cust-field"><label for="cv2-dark-tile-provider">Provider</label>' +
|
||||
'<select id="cv2-dark-tile-provider" data-cv2-dark-tile-provider style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
|
||||
options +
|
||||
'</select></div>';
|
||||
}
|
||||
|
||||
function _renderHome() {
|
||||
var eff = _getEffective();
|
||||
var h = eff.home || {};
|
||||
@@ -1904,46 +1794,14 @@
|
||||
if (_activeTab === 'geofilter') _initGeoFilterTab(container);
|
||||
|
||||
// #1361 Colorblind preset radio — switches preset via MeshCorePresets.applyPreset
|
||||
// #1446 — empty-value radio = "no preset" → clearPreset(), then re-run
|
||||
// the customizer pipeline so server-config colors take over.
|
||||
container.querySelectorAll('[data-cv2-cb-preset]').forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
if (!radio.checked) return;
|
||||
var id = radio.value;
|
||||
var MCP = window.MeshCorePresets;
|
||||
if (!MCP) return;
|
||||
if (!id) {
|
||||
if (typeof MCP.clearPreset === 'function') MCP.clearPreset();
|
||||
_runPipeline();
|
||||
} else if (typeof MCP.applyPreset === 'function') {
|
||||
MCP.applyPreset(id);
|
||||
if (window.MeshCorePresets && typeof window.MeshCorePresets.applyPreset === 'function') {
|
||||
window.MeshCorePresets.applyPreset(id);
|
||||
_refreshPanel();
|
||||
}
|
||||
_refreshPanel();
|
||||
});
|
||||
});
|
||||
|
||||
// #1420 Dark-tile provider dropdown — persists + fires mc-tile-provider-changed
|
||||
container.querySelectorAll('[data-cv2-dark-tile-provider]').forEach(function (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
var id = sel.value;
|
||||
if (typeof window.MC_setDarkTileProvider === 'function') {
|
||||
window.MC_setDarkTileProvider(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #1454 Show-encrypted-channels checkbox — persists + fires
|
||||
// mc-channels-show-encrypted-changed; channels.js re-fetches live.
|
||||
container.querySelectorAll('[data-cv2-channels-show-encrypted]').forEach(function (cb) {
|
||||
cb.addEventListener('change', function () {
|
||||
var on = !!cb.checked;
|
||||
try {
|
||||
if (on) localStorage.setItem('channels-show-encrypted', 'true');
|
||||
else localStorage.removeItem('channels-show-encrypted');
|
||||
} catch (_e) { /* private mode etc. */ }
|
||||
window.dispatchEvent(new CustomEvent('mc-channels-show-encrypted-changed', {
|
||||
detail: { value: on }
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2259,18 +2117,6 @@
|
||||
// 1. Migration check
|
||||
migrateOldKeys();
|
||||
|
||||
// #1446 — when a CB preset is cleared (or applied), re-run the customizer
|
||||
// pipeline so server-config nodeColors take over the --mc-role-{role}
|
||||
// CSS vars (the gating logic in applyCSS checks the body[data-cb-preset]
|
||||
// attribute to decide whether to write them).
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||||
window.addEventListener('cb-preset-changed', function () {
|
||||
if (_initDone) _runPipeline();
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 2. Read overrides and apply CSS immediately (before DOMContentLoaded)
|
||||
// Server defaults will be set later when /api/config/theme completes.
|
||||
// For now, apply whatever overrides exist on top of current SITE_CONFIG.
|
||||
@@ -2296,11 +2142,11 @@
|
||||
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
|
||||
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
|
||||
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
|
||||
// Apply node colors from overrides early — --node-X CSS var only.
|
||||
// #1412: do NOT write to window.ROLE_COLORS / ROLE_STYLE here.
|
||||
// Apply node/type colors from overrides early
|
||||
if (earlyOverrides.nodeColors) {
|
||||
for (var role in earlyOverrides.nodeColors) {
|
||||
root.setProperty('--node-' + role, earlyOverrides.nodeColors[role]);
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = earlyOverrides.nodeColors[role];
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = earlyOverrides.nodeColors[role];
|
||||
}
|
||||
}
|
||||
if (earlyOverrides.typeColors && window.TYPE_COLORS) {
|
||||
|
||||
+3
-11
@@ -1145,13 +1145,8 @@
|
||||
inp.addEventListener('input', function () {
|
||||
var key = inp.dataset.node;
|
||||
state.nodeColors[key] = inp.value;
|
||||
// #1412: route per-key user picks through setRoleColorOverride so
|
||||
// the explicit override map is the only place mutation happens.
|
||||
// (Direct subscript assignment would also work via the roles.js
|
||||
// proxy, but the explicit API is the documented contract.)
|
||||
if (typeof window.setRoleColorOverride === 'function') {
|
||||
window.setRoleColorOverride(key, inp.value);
|
||||
}
|
||||
// Sync to global role colors used by map/packets/etc
|
||||
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = inp.value;
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = inp.value;
|
||||
// Trigger re-render of current page
|
||||
window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
|
||||
@@ -1167,10 +1162,7 @@
|
||||
btn.addEventListener('click', function () {
|
||||
var key = btn.dataset.resetNode;
|
||||
state.nodeColors[key] = DEFAULTS.nodeColors[key];
|
||||
// #1412: clearing the override lets cb-preset CSS var win again.
|
||||
if (typeof window.setRoleColorOverride === 'function') {
|
||||
window.setRoleColorOverride(key, DEFAULTS.nodeColors[key]);
|
||||
}
|
||||
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = DEFAULTS.nodeColors[key];
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = DEFAULTS.nodeColors[key];
|
||||
render(container);
|
||||
});
|
||||
|
||||
+39
-24
@@ -8,9 +8,21 @@
|
||||
* - aria-live=polite, role=status, no focus stealing, pointer-events:none.
|
||||
* - prefers-reduced-motion: animation-name: none (style.css handles via media query).
|
||||
* - Singleton + cleanup: module-scoped guard; SPA re-mount must not re-show dismissed.
|
||||
* - Pull-to-refresh hint only when .pull-to-reconnect element exists in DOM.
|
||||
* - Edge-drawer hint only at viewport > 768px (where edge-swipe drawer applies).
|
||||
* - Row-swipe hint only on table pages: /#/packets, /#/nodes, etc.
|
||||
* - #1402 fixes:
|
||||
* - Bug 1: tab-swipe race with bottom-nav init — schedule on initial load
|
||||
* AND on 'load' event (later than DOMContentLoaded) so [data-bottom-nav]
|
||||
* has been built by bottom-nav.js. Also schedule on any hashchange.
|
||||
* - Bug 2: edge-drawer is a MOBILE feature (per #1064/#1184). Condition
|
||||
* flipped from innerWidth > 768 to innerWidth < 768.
|
||||
* - Bug 3: pull-refresh no longer gated on `.pull-to-reconnect` (which
|
||||
* only renders on WS-disconnect per #1068). Use touch-viewport probe.
|
||||
* - Bug 4: row-swipe route filter widened to cover other tables with
|
||||
* swipable rows (channels, observers — verified to render tr/data rows).
|
||||
* - Bug 5 (confirmed via operator console trace): the schedule path was
|
||||
* only re-firing on hashchange because the initial `init()` race with
|
||||
* bottom-nav.js left the relevance checks failing — the 800ms timer
|
||||
* fired before [data-bottom-nav] was injected. Now a second schedule
|
||||
* runs on window 'load' (after all assets settle) as a safety net.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -31,27 +43,18 @@
|
||||
var h = location.hash || '';
|
||||
return /^#\/live(\/|$|\?)/.test(h);
|
||||
}
|
||||
// #1065 follow-up: hints must only appear on touch-capable viewports.
|
||||
// Mouse-only desktops (e.g. analyzer.00id.net opened in Chrome on a
|
||||
// workstation) were getting "swipe a row left" tips that make no sense.
|
||||
// Three independent probes — any positive answer counts.
|
||||
function hasTouchCapability() {
|
||||
try {
|
||||
if ('ontouchstart' in window) return true;
|
||||
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) return true;
|
||||
if (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) return true;
|
||||
} catch (_e) {}
|
||||
return false;
|
||||
}
|
||||
var HINTS = {
|
||||
'row-swipe': {
|
||||
key: NS + 'row-swipe',
|
||||
text: 'Tip: swipe a row left for quick actions.',
|
||||
relevant: function () {
|
||||
if (!hasTouchCapability()) return false;
|
||||
if (onLiveRoute()) return false; // #1244
|
||||
var h = location.hash || '';
|
||||
return /^#\/(packets|nodes)/.test(h);
|
||||
// #1402 Bug 4: widen to other tables with swipable rows.
|
||||
// channels (.ch-item / .ch-row data-hash), observers (#obsTable tr) —
|
||||
// verified via grep before adding. /perf and /analytics omitted: no
|
||||
// swipable rows confirmed there.
|
||||
return /^#\/(packets|nodes|channels|observers)/.test(h);
|
||||
},
|
||||
position: 'bottom',
|
||||
},
|
||||
@@ -59,7 +62,6 @@
|
||||
key: NS + 'tab-swipe',
|
||||
text: 'Tip: swipe left or right to switch tabs.',
|
||||
relevant: function () {
|
||||
if (!hasTouchCapability()) return false;
|
||||
if (onLiveRoute()) return false; // #1244
|
||||
return !!document.querySelector('[data-bottom-nav]');
|
||||
},
|
||||
@@ -69,11 +71,11 @@
|
||||
key: NS + 'edge-drawer',
|
||||
text: 'Tip: swipe in from the left edge to open navigation.',
|
||||
relevant: function () {
|
||||
if (!hasTouchCapability()) return false;
|
||||
if (onLiveRoute()) return false; // #1244
|
||||
// nav-drawer.js: NARROW_MAX=768; edge-swipe drawer is the WIDE
|
||||
// (>768) layout's nav UI. Below 768, the bottom-nav owns navigation.
|
||||
return window.innerWidth > 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]');
|
||||
// #1402 Bug 2: edge-swipe drawer (#1064/#1184) is a MOBILE feature.
|
||||
// Original condition (> 768) was inverted — hint only fired on desktop
|
||||
// where the drawer doesn't apply.
|
||||
return window.innerWidth < 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]');
|
||||
},
|
||||
position: 'top-left',
|
||||
},
|
||||
@@ -81,9 +83,12 @@
|
||||
key: NS + 'pull-refresh',
|
||||
text: 'Tip: pull down to refresh the connection.',
|
||||
relevant: function () {
|
||||
if (!hasTouchCapability()) return false;
|
||||
if (onLiveRoute()) return false; // #1244
|
||||
return !!document.querySelector('.pull-to-reconnect');
|
||||
// #1402 Bug 3: was gated on `.pull-to-reconnect` which only renders
|
||||
// on WS-disconnect (#1068). First-visit healthy-connection operators
|
||||
// never saw the hint. Decoupled: any touch viewport gets the hint.
|
||||
var mm = window.matchMedia && window.matchMedia('(pointer: coarse)');
|
||||
return !!(mm && mm.matches);
|
||||
},
|
||||
position: 'top',
|
||||
},
|
||||
@@ -210,6 +215,16 @@
|
||||
if (!_routeChangeBound) {
|
||||
_routeChangeBound = true;
|
||||
window.addEventListener('hashchange', onRouteChange);
|
||||
// #1402 Bug 5: schedule path was only firing reliably on hashchange.
|
||||
// The initial scheduleHints() call below races bottom-nav.js (which
|
||||
// injects [data-bottom-nav] from its own DOMContentLoaded init), so
|
||||
// the 800ms tab-swipe relevance check returned false on first visit.
|
||||
// Re-schedule on 'load' (after all sync init has completed) as a
|
||||
// safety net. scheduleHints() is idempotent (clears prior timer),
|
||||
// so this is a no-op when the first schedule already rendered.
|
||||
if (document.readyState !== 'complete') {
|
||||
window.addEventListener('load', scheduleHints, { once: true });
|
||||
}
|
||||
}
|
||||
scheduleHints();
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="route-view.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="home.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="live.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="bottom-nav.css?v=__BUST__">
|
||||
@@ -123,7 +122,6 @@
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=__BUST__"></script>
|
||||
<script src="map-tile-providers.js?v=__BUST__"></script>
|
||||
<script src="cb-presets.js?v=__BUST__"></script>
|
||||
<script src="customize-v2.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=__BUST__"></script>
|
||||
@@ -141,7 +139,6 @@
|
||||
<script src="packet-filter.js?v=__BUST__"></script>
|
||||
<script src="filter-ux.js?v=__BUST__"></script>
|
||||
<script src="hash-color.js?v=__BUST__"></script>
|
||||
<script src="prefix-reserved.js?v=__BUST__"></script>
|
||||
<script src="packet-helpers.js?v=__BUST__"></script>
|
||||
<script src="vendor/aes-ecb.js?v=__BUST__"></script>
|
||||
<script src="vendor/sha256-hmac.js?v=__BUST__"></script>
|
||||
@@ -154,7 +151,6 @@
|
||||
<script src="geo-filter-overlay.js?v=__BUST__"></script>
|
||||
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="route-render.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="route-view.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="table-sort.js?v=__BUST__"></script>
|
||||
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
@@ -171,6 +167,5 @@
|
||||
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="mobile-page-actions.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+4
-53
@@ -1171,59 +1171,15 @@
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
// #1420 — multi-provider dark-tile picker. Light mode unchanged.
|
||||
let _liveDarkRefLayer = null;
|
||||
function _liveResolveTile(dark) {
|
||||
if (!dark) return { url: TILE_LIGHT, attribution: '© OpenStreetMap © CartoDB', refUrl: null };
|
||||
const reg = window.MC_TILE_PROVIDERS || {};
|
||||
const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
|
||||
const p = reg[id] || reg['carto-dark'] || {};
|
||||
return {
|
||||
url: p.url || p.baseUrl || TILE_DARK,
|
||||
attribution: p.attribution || '© OpenStreetMap © CartoDB',
|
||||
refUrl: p.refUrl || null
|
||||
};
|
||||
}
|
||||
function _liveSyncDarkTiles(dark) {
|
||||
const r = _liveResolveTile(dark);
|
||||
tileLayer.setUrl(r.url);
|
||||
if (tileLayer.options) tileLayer.options.attribution = r.attribution;
|
||||
if (dark && r.refUrl) {
|
||||
if (!_liveDarkRefLayer) {
|
||||
_liveDarkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(map);
|
||||
} else {
|
||||
_liveDarkRefLayer.setUrl(r.refUrl);
|
||||
}
|
||||
} else if (_liveDarkRefLayer) {
|
||||
map.removeLayer(_liveDarkRefLayer);
|
||||
_liveDarkRefLayer = null;
|
||||
}
|
||||
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
|
||||
// #1420 parity with map.js — refresh visible attribution credit after provider swap.
|
||||
if (map.attributionControl) {
|
||||
try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
const _liveInitTile = _liveResolveTile(isDark);
|
||||
let tileLayer = L.tileLayer(_liveInitTile.url, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(map);
|
||||
if (isDark && _liveInitTile.refUrl) {
|
||||
_liveDarkRefLayer = L.tileLayer(_liveInitTile.refUrl, { maxZoom: 19, attribution: _liveInitTile.attribution }).addTo(map);
|
||||
}
|
||||
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
|
||||
let tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, { maxZoom: 19 }).addTo(map);
|
||||
|
||||
// Swap tiles when theme changes
|
||||
const _themeObs = new MutationObserver(function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
_liveSyncDarkTiles(dark);
|
||||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||||
});
|
||||
_themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
// #1420 — re-render on customizer change.
|
||||
window.addEventListener('mc-tile-provider-changed', function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
_liveSyncDarkTiles(dark);
|
||||
});
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
|
||||
nodesLayer = L.layerGroup().addTo(map);
|
||||
@@ -2405,11 +2361,6 @@
|
||||
function addNodeMarker(n) {
|
||||
if (nodeMarkers[n.public_key]) return nodeMarkers[n.public_key];
|
||||
const color = ROLE_COLORS[n.role] || ROLE_COLORS.unknown;
|
||||
// #1438: SVG fill expression — use the live CSS var so existing
|
||||
// markers recolor when cb-preset switches or the operator overrides.
|
||||
// `color` (hex from ROLE_COLORS) is still tracked as `_baseColor`
|
||||
// for matrix mode / pulse animations that need an explicit value.
|
||||
const fillExpr = 'var(--mc-role-' + (n.role || 'companion') + ')';
|
||||
const isRepeater = n.role === 'repeater';
|
||||
const zoom = map ? map.getZoom() : 11;
|
||||
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
|
||||
@@ -2419,10 +2370,10 @@
|
||||
const sizePx = Math.max(10, Math.round((isRepeater ? 18 : 14) * zoomScale));
|
||||
|
||||
const svgHtml = (window.makeRoleMarkerSVG
|
||||
? window.makeRoleMarkerSVG(n.role, null, sizePx)
|
||||
? window.makeRoleMarkerSVG(n.role, color, sizePx)
|
||||
: '<svg width="' + sizePx + '" height="' + sizePx + '" viewBox="0 0 ' + sizePx + ' ' + sizePx +
|
||||
'"><circle cx="' + (sizePx/2) + '" cy="' + (sizePx/2) + '" r="' + (sizePx/2 - 2) +
|
||||
'" fill="' + fillExpr + '" stroke="#fff" stroke-width="1"/></svg>');
|
||||
'" fill="' + color + '" stroke="#fff" stroke-width="1"/></svg>');
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: svgHtml,
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/* map-tile-providers.js — Dark-tile provider registry & runtime switcher (#1420).
|
||||
*
|
||||
* Scope:
|
||||
* - 4 providers: carto-dark (default), esri-darkgray-labels (base+ref),
|
||||
* voyager-inverted, positron-inverted (CSS-filter variants).
|
||||
* - MC_setDarkTileProvider(id) persists per-browser to localStorage and
|
||||
* dispatches `mc-tile-provider-changed` so map.js / live.js can swap.
|
||||
* - MC_getDarkTileProvider() resolves localStorage → server default →
|
||||
* 'carto-dark'.
|
||||
* - MC_applyTileFilter() applies/clears the CSS filter on
|
||||
* `.leaflet-tile-pane` based on current theme + selected provider.
|
||||
*
|
||||
* No new deps — URL-only providers. Light mode is unchanged.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'mc-dark-tile-provider';
|
||||
var DEFAULT_ID = 'carto-dark';
|
||||
var EVENT_NAME = 'mc-tile-provider-changed';
|
||||
var INVERT_CSS = 'invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.05)';
|
||||
|
||||
// Per-browser server-injected default. roles.js writes this from
|
||||
// /api/config/client (cfg.mapDarkTileProvider) before any consumer reads.
|
||||
var _serverDefault = null;
|
||||
|
||||
// ── Registry ────────────────────────────────────────────────────────────
|
||||
var REGISTRY = {
|
||||
'carto-dark': {
|
||||
label: 'Carto Dark (default)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
invertFilter: null
|
||||
},
|
||||
'esri-darkgray-labels': {
|
||||
label: 'Esri Dark Gray + Labels',
|
||||
// Two-layer provider: base + reference (labels) overlay.
|
||||
baseUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}',
|
||||
refUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Reference/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ',
|
||||
invertFilter: null
|
||||
},
|
||||
'voyager-inverted': {
|
||||
label: 'Carto Voyager (inverted)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
invertFilter: INVERT_CSS
|
||||
},
|
||||
'positron-inverted': {
|
||||
label: 'Carto Positron (inverted)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
invertFilter: INVERT_CSS
|
||||
}
|
||||
};
|
||||
|
||||
function _hasId(id) {
|
||||
return typeof id === 'string' && Object.prototype.hasOwnProperty.call(REGISTRY, id);
|
||||
}
|
||||
|
||||
function _isDark() {
|
||||
try {
|
||||
var attr = document.documentElement.getAttribute('data-theme');
|
||||
if (attr === 'dark') return true;
|
||||
if (attr === 'light') return false;
|
||||
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function getActiveId() {
|
||||
try {
|
||||
var stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY);
|
||||
if (_hasId(stored)) return stored;
|
||||
} catch (_) { /* localStorage may be disabled */ }
|
||||
if (_hasId(_serverDefault)) return _serverDefault;
|
||||
return DEFAULT_ID;
|
||||
}
|
||||
|
||||
function setActive(id) {
|
||||
if (!_hasId(id)) return false;
|
||||
try {
|
||||
if (window.localStorage) window.localStorage.setItem(STORAGE_KEY, id);
|
||||
} catch (_) { /* swallow quota / disabled */ }
|
||||
var detail = { id: id, provider: REGISTRY[id] };
|
||||
try {
|
||||
var ev = (typeof CustomEvent === 'function')
|
||||
? new CustomEvent(EVENT_NAME, { detail: detail })
|
||||
: { type: EVENT_NAME, detail: detail };
|
||||
window.dispatchEvent(ev);
|
||||
} catch (_) { /* dispatch optional */ }
|
||||
// Re-apply filter immediately so callers without a listener still see it.
|
||||
applyTileFilter();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setServerDefault(id) {
|
||||
if (_hasId(id)) _serverDefault = id;
|
||||
}
|
||||
|
||||
function applyTileFilter() {
|
||||
var pane;
|
||||
try { pane = document.querySelector('.leaflet-tile-pane'); } catch (_) { pane = null; }
|
||||
if (!pane || !pane.style) return;
|
||||
if (!_isDark()) { pane.style.filter = ''; return; }
|
||||
var id = getActiveId();
|
||||
var p = REGISTRY[id];
|
||||
pane.style.filter = (p && p.invertFilter) ? p.invertFilter : '';
|
||||
}
|
||||
|
||||
// ── Public surface ──────────────────────────────────────────────────────
|
||||
window.MC_TILE_PROVIDERS = REGISTRY;
|
||||
window.MC_DARK_TILE_DEFAULT = DEFAULT_ID;
|
||||
window.MC_setDarkTileProvider = setActive;
|
||||
window.MC_getDarkTileProvider = getActiveId;
|
||||
window.MC_setServerDefaultTileProvider = setServerDefault;
|
||||
window.MC_applyTileFilter = applyTileFilter;
|
||||
|
||||
// ── Cross-tab sync ──────────────────────────────────────────────────────
|
||||
// If another tab in the same browser changes the provider, mirror the
|
||||
// dispatch + filter-apply here so live map.js / live.js swap tiles too.
|
||||
try {
|
||||
window.addEventListener('storage', function (e) {
|
||||
if (!e || e.key !== STORAGE_KEY) return;
|
||||
if (!_hasId(e.newValue)) return;
|
||||
var detail = { id: e.newValue, provider: REGISTRY[e.newValue], crossTab: true };
|
||||
try {
|
||||
var ev = (typeof CustomEvent === 'function')
|
||||
? new CustomEvent(EVENT_NAME, { detail: detail })
|
||||
: { type: EVENT_NAME, detail: detail };
|
||||
window.dispatchEvent(ev);
|
||||
} catch (_) { /* dispatch optional */ }
|
||||
applyTileFilter();
|
||||
});
|
||||
} catch (_) { /* addEventListener may not exist in some envs */ }
|
||||
})();
|
||||
+24
-588
@@ -57,11 +57,7 @@
|
||||
|
||||
function makeMarkerIcon(role, isStale, isAlsoObserver, colorOverride) {
|
||||
const s = ROLE_STYLE[role] || ROLE_STYLE.companion;
|
||||
// #1438: default fill resolves through the live CSS var so existing
|
||||
// mounted SVG markers recolor when cb-preset switches or the
|
||||
// operator picks a per-role override. colorOverride (MultiByte
|
||||
// status tint) wins when provided.
|
||||
const fillColor = colorOverride || ('var(--mc-role-' + (role || 'companion') + ')');
|
||||
const fillColor = colorOverride || s.color;
|
||||
const size = s.radius * 2 + 4;
|
||||
const c = size / 2;
|
||||
let path;
|
||||
@@ -116,7 +112,7 @@
|
||||
starPts += `${scx + so * Math.cos(aO)},${scy + so * Math.sin(aO)} `;
|
||||
starPts += `${scx + si * Math.cos(aI)},${scy + si * Math.sin(aI)} `;
|
||||
}
|
||||
obsOverlay = `<g transform="translate(${sx},${sy})"><polygon points="${starPts.trim()}" fill="var(--mc-role-observer)" stroke="#fff" stroke-width="0.8"/></g>`;
|
||||
obsOverlay = `<g transform="translate(${sx},${sy})"><polygon points="${starPts.trim()}" fill="${ROLE_COLORS.observer || '#f1c40f'}" stroke="#fff" stroke-width="0.8"/></g>`;
|
||||
}
|
||||
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">${path}${obsOverlay}</svg>`;
|
||||
return L.divIcon({
|
||||
@@ -260,60 +256,16 @@
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
// #1420 — multi-provider dark-tile picker. Light mode unchanged.
|
||||
let _darkRefLayer = null; // Esri-only: labels overlay
|
||||
function _resolveTileUrl(dark) {
|
||||
if (!dark) return { url: TILE_LIGHT, attribution: '© OpenStreetMap © CartoDB', refUrl: null };
|
||||
const reg = window.MC_TILE_PROVIDERS || {};
|
||||
const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark';
|
||||
const p = reg[id] || reg['carto-dark'] || {};
|
||||
return {
|
||||
url: p.url || p.baseUrl || TILE_DARK,
|
||||
attribution: p.attribution || '© OpenStreetMap © CartoDB',
|
||||
refUrl: p.refUrl || null
|
||||
};
|
||||
}
|
||||
function _syncDarkTiles(dark) {
|
||||
const r = _resolveTileUrl(dark);
|
||||
tileLayer.setUrl(r.url);
|
||||
if (tileLayer.options) tileLayer.options.attribution = r.attribution;
|
||||
// Esri reference (labels) overlay: add when needed, remove otherwise.
|
||||
if (dark && r.refUrl) {
|
||||
if (!_darkRefLayer) {
|
||||
_darkRefLayer = L.tileLayer(r.refUrl, { maxZoom: 19, attribution: r.attribution }).addTo(map);
|
||||
} else {
|
||||
_darkRefLayer.setUrl(r.refUrl);
|
||||
}
|
||||
} else if (_darkRefLayer) {
|
||||
map.removeLayer(_darkRefLayer);
|
||||
_darkRefLayer = null;
|
||||
}
|
||||
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
|
||||
if (map.attributionControl) {
|
||||
try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
const _initTile = _resolveTileUrl(isDark);
|
||||
const tileLayer = L.tileLayer(_initTile.url, {
|
||||
attribution: _initTile.attribution,
|
||||
const tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, {
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
if (isDark && _initTile.refUrl) {
|
||||
_darkRefLayer = L.tileLayer(_initTile.refUrl, { maxZoom: 19, attribution: _initTile.attribution }).addTo(map);
|
||||
}
|
||||
if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter();
|
||||
const _mapThemeObs = new MutationObserver(function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
_syncDarkTiles(dark);
|
||||
tileLayer.setUrl(dark ? TILE_DARK : TILE_LIGHT);
|
||||
});
|
||||
_mapThemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
// #1420 — re-render when the user picks a different dark provider in the customizer.
|
||||
window.addEventListener('mc-tile-provider-changed', function () {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
_syncDarkTiles(dark);
|
||||
});
|
||||
|
||||
// Save position on move
|
||||
map.on('moveend', () => {
|
||||
@@ -344,9 +296,6 @@
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__mc_map = map;
|
||||
window.__mc_routeLayer = routeLayer;
|
||||
// Expose nodes array for route-view's path-picker isolation
|
||||
// (needs to resolve hop prefixes that aren't in the canonical path).
|
||||
Object.defineProperty(window, '__mc_nodes', { get: function () { return nodes; }, configurable: true });
|
||||
window.deconflictLabels = deconflictLabels;
|
||||
}
|
||||
|
||||
@@ -553,29 +502,18 @@
|
||||
sessionStorage.removeItem('map-route-hops');
|
||||
try {
|
||||
const parsed = JSON.parse(routeHopsJson);
|
||||
// Support new format {origin, hops} and legacy plain array
|
||||
if (Array.isArray(parsed)) {
|
||||
drawPacketRoute(parsed, null);
|
||||
} else if (parsed.paths && parsed.paths.length > 0) {
|
||||
drawPacketRouteMulti(parsed.paths, parsed.origin || null, {
|
||||
packetHash: parsed.packetHash,
|
||||
canonicalPath: parsed.hops || null,
|
||||
destination: parsed.destination || null
|
||||
});
|
||||
} else {
|
||||
drawPacketRoute(parsed.hops || [], parsed.origin || null, { destination: parsed.destination || null });
|
||||
drawPacketRoute(parsed.hops || [], parsed.origin || null);
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
// #1418/#1419: deep-link via URL params — #/map?packet=<hash>&obs=<id>
|
||||
// (or just packet=<hash> for first observation). Fetch from API, build
|
||||
// the same payload as the sessionStorage flow, dispatch to renderer.
|
||||
// This makes routes shareable / bookmarkable without sessionStorage state.
|
||||
loadRouteFromDeepLink();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function drawPacketRoute(hopKeys, origin, opts) {
|
||||
function drawPacketRoute(hopKeys, origin, opts) {
|
||||
// Defensive: origin must be an object with pubkey/lat/lon/name. A bare
|
||||
// string slips through both branches at lines below and silently no-ops
|
||||
// the originator marker (caused PR #950's bug). Coerce string → object
|
||||
@@ -585,21 +523,6 @@
|
||||
origin = { pubkey: origin };
|
||||
}
|
||||
opts = opts || {};
|
||||
// #1422: use the backend's /api/resolve-hops for proper disambiguation
|
||||
// (unique_prefix vs multi-byte vs gps_preference vs affinity scoring).
|
||||
// Falls back to naive nodes.filter() scan if the API is unreachable.
|
||||
let serverResolved = null;
|
||||
try {
|
||||
const hopList = (hopKeys || []).join(',');
|
||||
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(hopList);
|
||||
const resp = await fetch(apiUrl, { cache: 'no-cache' });
|
||||
if (resp.ok) {
|
||||
const json = await resp.json();
|
||||
serverResolved = json && json.resolved ? json.resolved : null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('resolve-hops API call failed, falling back to local nodes scan', e);
|
||||
}
|
||||
// Hide default markers so only the route is visible
|
||||
if (markerLayer) map.removeLayer(markerLayer);
|
||||
if (clusterGroup) map.removeLayer(clusterGroup);
|
||||
@@ -632,42 +555,18 @@
|
||||
// Unresolvable hops (no matching node) become {resolved:false} sentinels
|
||||
// so the modern renderer (#1374) can render dashed-gray placeholders + a
|
||||
// "X of N hops resolved" badge instead of silently dropping them.
|
||||
//
|
||||
// #1418/#1422: PREFER serverResolved from /api/resolve-hops which does
|
||||
// - unique_prefix matching (uses multi-byte adverts to disambiguate 1-byte hops)
|
||||
// - gps_preference (skips nodes with lat=0 sentinel)
|
||||
// - affinity scoring (neighbor-graph aware)
|
||||
// Fall back to a naive local nodes.filter() scan when the API failed or
|
||||
// the hop wasn't in the server response. When a node MATCHES by
|
||||
// prefix/pubkey but has no GPS coords, we still want its name/role/pubkey
|
||||
// for the sidebar — flag it as {resolved:false, gpsless:true} so the
|
||||
// renderer can label it "📍 no GPS" instead of "unresolved prefix".
|
||||
const raw = hopKeys.map(hop => {
|
||||
const hopLower = String(hop).toLowerCase();
|
||||
// Try server resolution first
|
||||
const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]);
|
||||
if (srv && srv.pubkey) {
|
||||
const c = srv.candidates && srv.candidates[0];
|
||||
if (c && c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) {
|
||||
return { lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true };
|
||||
}
|
||||
// Server resolved but node has no usable GPS
|
||||
return { name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true };
|
||||
}
|
||||
// Fallback: naive local scan (kept for resilience when API is down).
|
||||
const allMatches = nodes.filter(n => {
|
||||
const candidates = nodes.filter(n => {
|
||||
const pk = n.public_key.toLowerCase();
|
||||
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk));
|
||||
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk)) &&
|
||||
n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0);
|
||||
});
|
||||
const withGps = allMatches.filter(n => n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0));
|
||||
if (withGps.length === 1) {
|
||||
const c = withGps[0];
|
||||
if (candidates.length === 1) {
|
||||
const c = candidates[0];
|
||||
return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true };
|
||||
} else if (withGps.length > 1) {
|
||||
return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps };
|
||||
} else if (allMatches.length >= 1) {
|
||||
const c = allMatches[0];
|
||||
return { name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true };
|
||||
} else if (candidates.length > 1) {
|
||||
return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates };
|
||||
}
|
||||
return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false };
|
||||
});
|
||||
@@ -695,59 +594,24 @@
|
||||
// Resolve and prepend origin node
|
||||
if (origin) {
|
||||
let originPos = null;
|
||||
const originHasRealGps = (lat, lon) => lat != null && lon != null && !(lat === 0 && lon === 0);
|
||||
if (originHasRealGps(origin.lat, origin.lon)) {
|
||||
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, role: origin.role || 'companion', resolved: true, isOrigin: true, _fromPayload: true };
|
||||
if (origin.lat != null && origin.lon != null) {
|
||||
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, role: origin.role || 'companion', resolved: true, isOrigin: true };
|
||||
} else if (origin.pubkey) {
|
||||
const pk = origin.pubkey.toLowerCase();
|
||||
const match = nodes.find(n => n.public_key.toLowerCase() === pk || n.public_key.toLowerCase().startsWith(pk));
|
||||
if (match) {
|
||||
if (originHasRealGps(match.lat, match.lon)) {
|
||||
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role || 'companion', resolved: true, isOrigin: true, _fromPayload: true };
|
||||
} else {
|
||||
originPos = { name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role || 'companion', resolved: false, gpsless: true, isOrigin: true, _fromPayload: true };
|
||||
}
|
||||
if (match && match.lat != null && match.lon != null) {
|
||||
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role || 'companion', resolved: true, isOrigin: true };
|
||||
}
|
||||
}
|
||||
if (originPos) positions.unshift(originPos);
|
||||
}
|
||||
|
||||
// #1418 Phase D: append destination (recipient from decoded.destHash) so
|
||||
// the route view shows: sender → [intermediate hops] → recipient.
|
||||
// GPS-sanity: a lat=0,lon=0 sentinel or null coords means "no real GPS"
|
||||
// — show the node in the sidebar as gpsless rather than drawing it at
|
||||
// (0,0) which would force fitBounds to span the visible route → Africa.
|
||||
// _fromPayload: true so the renderer can visually mark these as "from
|
||||
// payload" (different source-of-truth than path hops).
|
||||
if (opts.destination && opts.destination.pubkey) {
|
||||
const dpk = opts.destination.pubkey.toLowerCase();
|
||||
const dmatch = nodes.find(n => n.public_key.toLowerCase() === dpk || n.public_key.toLowerCase().startsWith(dpk));
|
||||
if (dmatch) {
|
||||
const hasGps = dmatch.lat != null && dmatch.lon != null && !(dmatch.lat === 0 && dmatch.lon === 0);
|
||||
if (hasGps) {
|
||||
positions.push({ lat: dmatch.lat, lon: dmatch.lon, name: opts.destination.name || dmatch.name || 'Recipient', pubkey: dmatch.public_key, role: dmatch.role || 'companion', resolved: true, _fromPayload: true });
|
||||
} else {
|
||||
positions.push({ name: opts.destination.name || dmatch.name || 'Recipient', pubkey: dmatch.public_key, role: dmatch.role || 'companion', resolved: false, gpsless: true, _fromPayload: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (positions.length < 1) return;
|
||||
// Mark final hop as destination so the renderer applies the dest glyph.
|
||||
positions[positions.length - 1].isDest = true;
|
||||
|
||||
// Hand off to sequence-primary sequence-primary renderer (#1418), falling
|
||||
// back to the legacy role-aware MeshRoute (#1374), then to the minimal
|
||||
// polyline (should never run in production).
|
||||
if (window.MeshRouteView && typeof window.MeshRouteView.render === 'function') {
|
||||
window.MeshRouteView.render(map, routeLayer, positions, {
|
||||
timestamp: opts.timestamp || Date.now(),
|
||||
packetHash: opts.packetHash || null,
|
||||
observationId: opts.observationId || null,
|
||||
packetContext: opts.packetContext || null
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Hand off to the modern role-aware renderer (#1374). Falls back to the
|
||||
// legacy minimal renderer only if MeshRoute hasn't loaded yet.
|
||||
if (window.MeshRoute && typeof window.MeshRoute.render === 'function') {
|
||||
window.MeshRoute.render(map, routeLayer, positions, {
|
||||
timestamp: opts.timestamp || Date.now()
|
||||
@@ -765,425 +629,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// #1418 Phase C — multi-path renderer. Accepts an array of paths (each =
|
||||
// {path: [hopKeys], observer, snr, rssi}), aggregates into canonical hops +
|
||||
// per-edge observer-count (for stroke-width weighting), and dispatches to
|
||||
// the sequence renderer with multi-path metadata. Falls back to single-path
|
||||
// drawPacketRoute when only one observation is provided.
|
||||
//
|
||||
// opts.canonicalPath (optional): use this exact hop sequence as the canonical
|
||||
// spine — i.e. the observation the operator selected. Without it, longest-path
|
||||
// wins, which can show a totally different route than the user clicked.
|
||||
async function drawPacketRouteMulti(paths, origin, opts) {
|
||||
opts = opts || {};
|
||||
if (typeof origin === 'string') origin = { pubkey: origin };
|
||||
if (!Array.isArray(paths) || paths.length === 0) return;
|
||||
if (paths.length === 1) {
|
||||
return drawPacketRoute(paths[0].path || [], origin, opts);
|
||||
}
|
||||
|
||||
// Pick canonical: prefer caller-supplied (operator's chosen observation),
|
||||
// else fall back to longest path as the spine.
|
||||
var canonicalPath;
|
||||
if (opts.canonicalPath && Array.isArray(opts.canonicalPath) && opts.canonicalPath.length) {
|
||||
canonicalPath = opts.canonicalPath;
|
||||
} else {
|
||||
const sortedByLen = paths.slice().sort((a, b) => (b.path || []).length - (a.path || []).length);
|
||||
canonicalPath = sortedByLen[0].path || [];
|
||||
}
|
||||
const totalObservers = paths.length;
|
||||
|
||||
// Count hop & edge occurrences across all paths.
|
||||
const hopCounts = {};
|
||||
const edgeCounts = {};
|
||||
paths.forEach(p => {
|
||||
const hops = p.path || [];
|
||||
hops.forEach(h => { hopCounts[h] = (hopCounts[h] || 0) + 1; });
|
||||
for (let i = 0; i < hops.length - 1; i++) {
|
||||
const key = hops[i] + '\u2192' + hops[i + 1];
|
||||
edgeCounts[key] = (edgeCounts[key] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve canonical hops via /api/resolve-hops + naive fallback.
|
||||
let serverResolved = null;
|
||||
try {
|
||||
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(canonicalPath.join(','));
|
||||
const resp = await fetch(apiUrl, { cache: 'no-cache' });
|
||||
if (resp.ok) {
|
||||
const json = await resp.json();
|
||||
serverResolved = json && json.resolved ? json.resolved : null;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const raw = canonicalPath.map(hop => {
|
||||
const hopLower = String(hop).toLowerCase();
|
||||
const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]);
|
||||
// hopCounts is keyed on the SHORT prefix from observation paths (e.g. "37"),
|
||||
// but canonicalPath may carry full pubkeys when the operator's selected
|
||||
// observation went through the resolver. Compute hop coverage by checking
|
||||
// EVERY path[]: a hop in canonicalPath is "covered" by a path if the path
|
||||
// contains an entry that is either an exact match OR a prefix of the hop's
|
||||
// full pubkey.
|
||||
let coverage = 0;
|
||||
const hopFullKey = (srv && srv.pubkey) ? srv.pubkey.toLowerCase() : hopLower;
|
||||
paths.forEach(pth => {
|
||||
const phops = (pth.path || []).map(h => String(h).toLowerCase());
|
||||
const matches = phops.some(ph => ph === hopFullKey || hopFullKey.startsWith(ph) || ph.startsWith(hopFullKey));
|
||||
if (matches) coverage++;
|
||||
});
|
||||
const obsInfo = { observerCount: coverage || (hopCounts[hop] || 1), observerTotal: totalObservers };
|
||||
if (srv && srv.pubkey) {
|
||||
const c = srv.candidates && srv.candidates[0];
|
||||
if (c && c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) {
|
||||
return Object.assign({ lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true }, obsInfo);
|
||||
}
|
||||
return Object.assign({ name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true }, obsInfo);
|
||||
}
|
||||
const allMatches = nodes.filter(n => {
|
||||
const pk = n.public_key.toLowerCase();
|
||||
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk));
|
||||
});
|
||||
const withGps = allMatches.filter(n => n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0));
|
||||
if (withGps.length >= 1) {
|
||||
const c = withGps[0];
|
||||
return Object.assign({ lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true }, obsInfo);
|
||||
}
|
||||
if (allMatches.length >= 1) {
|
||||
const c = allMatches[0];
|
||||
return Object.assign({ name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true }, obsInfo);
|
||||
}
|
||||
return Object.assign({ name: String(hop).slice(0,8), pubkey: hop, resolved: false }, obsInfo);
|
||||
});
|
||||
|
||||
if (raw.length < 1) return;
|
||||
if (origin && origin.pubkey) {
|
||||
const op = nodes.find(n => n.public_key.toLowerCase() === origin.pubkey.toLowerCase() ||
|
||||
n.public_key.toLowerCase().startsWith(origin.pubkey.toLowerCase()));
|
||||
if (op) {
|
||||
const hasGps = op.lat != null && op.lon != null && !(op.lat === 0 && op.lon === 0);
|
||||
if (hasGps) {
|
||||
raw.unshift({ lat: op.lat, lon: op.lon, name: origin.name || op.name || 'Sender', pubkey: op.public_key, role: op.role || 'companion', resolved: true, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
} else {
|
||||
raw.unshift({ name: origin.name || op.name || 'Sender', pubkey: op.public_key, role: op.role || 'companion', resolved: false, gpsless: true, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
}
|
||||
} else if (origin.name) {
|
||||
raw.unshift({ name: origin.name, pubkey: origin.pubkey, role: 'companion', resolved: false, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
}
|
||||
}
|
||||
// #1418 Phase D: append destination (recipient from decoded.destHash) so
|
||||
// the route view shows: sender → [intermediate hops] → recipient.
|
||||
// GPS-sanity: a lat=0,lon=0 sentinel or null coords means "no real GPS"
|
||||
// — show the node in the sidebar as gpsless rather than drawing it at
|
||||
// (0,0) which would force fitBounds to span SF→Africa.
|
||||
if (opts.destination && opts.destination.pubkey) {
|
||||
const dp = nodes.find(n => n.public_key.toLowerCase() === opts.destination.pubkey.toLowerCase() ||
|
||||
n.public_key.toLowerCase().startsWith(opts.destination.pubkey.toLowerCase()));
|
||||
if (dp) {
|
||||
const hasGps = dp.lat != null && dp.lon != null && !(dp.lat === 0 && dp.lon === 0);
|
||||
if (hasGps) {
|
||||
raw.push({ lat: dp.lat, lon: dp.lon, name: opts.destination.name || dp.name || 'Recipient', pubkey: dp.public_key, role: dp.role || 'companion', resolved: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
} else {
|
||||
raw.push({ name: opts.destination.name || dp.name || 'Recipient', pubkey: dp.public_key, role: dp.role || 'companion', resolved: false, gpsless: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
}
|
||||
}
|
||||
}
|
||||
raw[raw.length - 1].isDest = true;
|
||||
if (raw[0]) raw[0].isOrigin = true;
|
||||
|
||||
if (routeLayer) routeLayer.clearLayers();
|
||||
|
||||
if (window.MeshRouteView && typeof window.MeshRouteView.render === 'function') {
|
||||
window.MeshRouteView.render(map, routeLayer, raw, {
|
||||
timestamp: opts.timestamp || Date.now(),
|
||||
multiPath: true,
|
||||
totalObservers: totalObservers,
|
||||
edgeCounts: edgeCounts,
|
||||
packetHash: opts.packetHash || null,
|
||||
observationId: opts.observationId || null,
|
||||
allPaths: paths,
|
||||
packetContext: opts.packetContext || null
|
||||
});
|
||||
}
|
||||
}
|
||||
window.drawPacketRouteMulti = drawPacketRouteMulti;
|
||||
|
||||
// #1418/#1419: deep-link loader. Reads URL params
|
||||
// #/map?packet=<hash>&obs=<observation_id>
|
||||
// fetches the packet+observations from /api/packets/<hash>, picks the
|
||||
// selected observation as canonical, and dispatches the same payload that
|
||||
// the packets-page 'View on map' button would have set in sessionStorage.
|
||||
// Without an obs param, the first observation is used.
|
||||
async function loadRouteFromDeepLink() {
|
||||
try {
|
||||
const hash = location.hash || '';
|
||||
const qs = hash.split('?')[1];
|
||||
if (!qs) return;
|
||||
const params = new URLSearchParams(qs);
|
||||
const packetHash = params.get('packet');
|
||||
const obsId = params.get('obs');
|
||||
if (!packetHash) return;
|
||||
// Wait for nodes to load (drawPacketRoute / Multi rely on `nodes` array
|
||||
// for the local-fallback resolver).
|
||||
if (!nodes || !nodes.length) {
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
}
|
||||
const resp = await fetch('/api/packets/' + encodeURIComponent(packetHash));
|
||||
if (!resp.ok) {
|
||||
console.warn('[deep-link] /api/packets/' + packetHash + ' returned ' + resp.status);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const pkt = data.packet || data;
|
||||
const observations = data.observations || pkt.observations || [];
|
||||
if (!observations.length) return;
|
||||
// Pick the user-chosen observation by id, fall back to first
|
||||
let chosen = null;
|
||||
if (obsId) chosen = observations.find(o => String(o.id) === String(obsId));
|
||||
if (!chosen) chosen = observations[0];
|
||||
// Parse decoded for src/dst.
|
||||
// Try observation first, fall back to packet-level decoded_json (GRP_TXT
|
||||
// / TRACE packets often have channel + content at the packet level, not
|
||||
// per-observation).
|
||||
let decoded = {};
|
||||
try {
|
||||
const obsDec = JSON.parse(chosen.decoded_json || '{}');
|
||||
const pktDec = JSON.parse(pkt.decoded_json || '{}');
|
||||
decoded = Object.keys(obsDec).length ? obsDec : pktDec;
|
||||
// If observation has some fields but missing channel/text, merge from packet.
|
||||
if (decoded === obsDec && pktDec.channel) {
|
||||
decoded = Object.assign({}, pktDec, obsDec);
|
||||
}
|
||||
} catch (_) {}
|
||||
const origin = {};
|
||||
if (decoded.pubKey) origin.pubkey = decoded.pubKey;
|
||||
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
|
||||
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
|
||||
const destination = decoded.destHash ? { pubkey: decoded.destHash } : null;
|
||||
// Resolve the chosen observation's hops (canonical path).
|
||||
// Priority: 1) server-side `resolved_path` (authoritative, eyeball-
|
||||
// validated against packet detail), 2) client-side HopResolver (used
|
||||
// by packets.js — does observer-IATA-aware geographic disambiguation),
|
||||
// 3) raw prefixes (worst case, leaves naive lookup to drawPacketRoute).
|
||||
let chosenPath = [];
|
||||
let rawHops = [];
|
||||
try { rawHops = JSON.parse(chosen.path_json || '[]'); } catch (_) {}
|
||||
let resolvedHops = null;
|
||||
try {
|
||||
if (chosen.resolved_path) {
|
||||
resolvedHops = typeof chosen.resolved_path === 'string' ? JSON.parse(chosen.resolved_path) : chosen.resolved_path;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (Array.isArray(resolvedHops) && resolvedHops.length === rawHops.length) {
|
||||
chosenPath = rawHops.map((h, i) => resolvedHops[i] || h);
|
||||
} else if (window.HopResolver && typeof window.HopResolver.resolve === 'function' && rawHops.length) {
|
||||
// Use the SAME resolver the packets page uses, so route view and
|
||||
// packet detail agree on hop identities.
|
||||
try {
|
||||
// Sender + observer hints help disambiguation
|
||||
const senderLat = decoded.lat || decoded.latitude || null;
|
||||
const senderLon = decoded.lon || decoded.longitude || null;
|
||||
let obsLat = null, obsLon = null;
|
||||
// Try to find observer's coords for geographic affinity scoring
|
||||
if (chosen.observer_id && Array.isArray(nodes)) {
|
||||
const obs = nodes.find(n => (n.public_key || '').toLowerCase() === String(chosen.observer_id).toLowerCase());
|
||||
if (obs && obs.lat != null && obs.lon != null) { obsLat = obs.lat; obsLon = obs.lon; }
|
||||
}
|
||||
// HopResolver.init may already have been done by packets.js; if not,
|
||||
// do a minimal init from window data we have.
|
||||
if (!window.HopResolver.ready || !window.HopResolver.ready()) {
|
||||
try {
|
||||
window.HopResolver.init(nodes || [], { observers: [], iataCoords: {} });
|
||||
} catch (_) {}
|
||||
}
|
||||
const resolveResult = window.HopResolver.resolve(rawHops, senderLat, senderLon, obsLat, obsLon, chosen.observer_id);
|
||||
chosenPath = rawHops.map(h => {
|
||||
const r = resolveResult ? resolveResult[h] : null;
|
||||
return r && r.pubkey ? r.pubkey : h;
|
||||
});
|
||||
} catch (_) {
|
||||
chosenPath = rawHops;
|
||||
}
|
||||
} else {
|
||||
chosenPath = rawHops;
|
||||
}
|
||||
// All observation paths for multi-path stroke weighting
|
||||
const allPaths = observations.map(o => {
|
||||
let p = [];
|
||||
try { p = JSON.parse(o.path_json || '[]'); } catch (_) {}
|
||||
return { path: p, observer: o.observer_name, observer_id: o.observer_id, snr: o.snr, rssi: o.rssi };
|
||||
}).filter(p => p.path && p.path.length > 0);
|
||||
// #1418 Phase Y: derive packet context for the sidebar fact-list.
|
||||
// type comes from decoded.type or pkt.payload_type. Resolve src/dst
|
||||
// names from the nodes table when possible.
|
||||
function resolveNameByHash(h) {
|
||||
if (!h || !nodes || !nodes.length) return null;
|
||||
const hL = String(h).toLowerCase();
|
||||
const m = nodes.find(n => n.public_key.toLowerCase().startsWith(hL));
|
||||
return m ? m.name : null;
|
||||
}
|
||||
// Map payload_type byte → string. Source: cmd/ingestor/decoder.go.
|
||||
// 0=REQ, 1=RESPONSE, 2=TXT_MSG, 3=ACK, 4=ADVERT, 5=GRP_TXT,
|
||||
// 6=GRP_DATA, 7=ANON_REQ, 8=PATH, 9=TRACE, 10=MULTIPART,
|
||||
// 11=CONTROL, 12=RAW_CUSTOM.
|
||||
const PAYLOAD_TYPE_MAP = {
|
||||
0: 'REQ', 1: 'RESPONSE', 2: 'TXT_MSG', 3: 'ACK', 4: 'ADVERT',
|
||||
5: 'GRP_TXT', 6: 'GRP_DATA', 7: 'ANON_REQ', 8: 'PATH',
|
||||
9: 'TRACE', 10: 'MULTIPART', 11: 'CONTROL', 12: 'RAW_CUSTOM'
|
||||
};
|
||||
const inferredType = decoded.type || PAYLOAD_TYPE_MAP[pkt.payload_type] || 'OTHER';
|
||||
// Try to peek at raw_hex bytes to extract src/destHash when decoded is empty.
|
||||
// TXT_MSG/REQ/RESPONSE/ANON_REQ all have the same wire layout:
|
||||
// byte0=route+type, byte1=path_len, then path bytes, then destHash + srcHash + encrypted body.
|
||||
// ANON_REQ doesn't have a srcHash byte (sender is anonymous), only destHash.
|
||||
let inferredSrc = decoded.srcHash || null;
|
||||
let inferredDst = decoded.destHash || null;
|
||||
const TYPES_WITH_DST_SRC = [1, 2, 7, 8]; // RESPONSE=1, TXT_MSG=2, ANON_REQ=7, PATH=8 — all carry hashes
|
||||
if ((!inferredSrc || !inferredDst) && chosen.raw_hex && TYPES_WITH_DST_SRC.indexOf(pkt.payload_type) >= 0) {
|
||||
try {
|
||||
const hex = chosen.raw_hex;
|
||||
// MeshCore wire max for path length is 64 hops. Cap defensively:
|
||||
// a crafted ingest packet with pathLen=200 would slice random body
|
||||
// bytes into the srcHash/destHash UI fields. Guard before use.
|
||||
const pathLen = parseInt(hex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
|
||||
throw new Error('pathLen out of range: ' + pathLen);
|
||||
}
|
||||
const destOff = 4 + pathLen * 2;
|
||||
if (hex.length >= destOff + 2) {
|
||||
inferredDst = inferredDst || hex.slice(destOff, destOff + 2).toUpperCase();
|
||||
// ANON_REQ has no srcHash
|
||||
if (pkt.payload_type !== 7 && hex.length >= destOff + 4) {
|
||||
inferredSrc = inferredSrc || hex.slice(destOff + 2, destOff + 4).toUpperCase();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
// GRP_TXT: channel_hash byte sits right after path bytes in raw_hex.
|
||||
// Layout: byte0=route+type, byte1=path_len, path bytes, channel_hash, encrypted body.
|
||||
let inferredChannelHash = decoded.channelHashHex || null;
|
||||
if (!inferredChannelHash && chosen.raw_hex && pkt.payload_type === 5) {
|
||||
try {
|
||||
const hex = chosen.raw_hex;
|
||||
const pathLen = parseInt(hex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
|
||||
throw new Error('pathLen out of range: ' + pathLen);
|
||||
}
|
||||
const chOff = 4 + pathLen * 2;
|
||||
if (hex.length >= chOff + 2) {
|
||||
inferredChannelHash = hex.slice(chOff, chOff + 2).toUpperCase();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
const pktCtx = {
|
||||
type: inferredType,
|
||||
decoded: Object.assign({}, decoded, { srcHash: inferredSrc, destHash: inferredDst, channelHashHex: inferredChannelHash || decoded.channelHashHex }),
|
||||
payloadType: pkt.payload_type || null,
|
||||
srcResolvedName: inferredSrc ? resolveNameByHash(inferredSrc) : null,
|
||||
destResolvedName: inferredDst ? resolveNameByHash(inferredDst) : null,
|
||||
observedHops: chosenPath.length,
|
||||
observationCount: observations.length
|
||||
};
|
||||
// GRP_TXT: try to resolve the channel name from /api/channels.
|
||||
if (inferredType === 'GRP_TXT' && inferredChannelHash) {
|
||||
try {
|
||||
const chResp = await fetch('/api/channels?includeEncrypted=true');
|
||||
if (chResp.ok) {
|
||||
const chData = await chResp.json();
|
||||
const chList = chData.channels || [];
|
||||
const wantUp = inferredChannelHash.toUpperCase();
|
||||
// Match by:
|
||||
// 1) hash field starts with target (full hex hash)
|
||||
// 2) hash == "enc_<HEX>" (no-key fallback channels)
|
||||
// 3) name contains "0x<HEX>" (encrypted placeholder)
|
||||
// 4) for keyed channels: compute SHA256(name)[0] === target byte
|
||||
// (browser SubtleCrypto — async)
|
||||
let match = chList.find(c => {
|
||||
const ch = String(c.hash || '').toUpperCase();
|
||||
const nm = String(c.name || '').toUpperCase();
|
||||
return ch.startsWith(wantUp) ||
|
||||
ch === 'ENC_' + wantUp ||
|
||||
nm.includes('0X' + wantUp);
|
||||
});
|
||||
// If not matched and we have SubtleCrypto, try SHA256 lookup
|
||||
if (!match && window.crypto && window.crypto.subtle) {
|
||||
for (const c of chList) {
|
||||
if (c.encrypted) continue; // skip the enc_ placeholders
|
||||
const name = c.name || '';
|
||||
if (!name) continue;
|
||||
try {
|
||||
const buf = new TextEncoder().encode(name);
|
||||
const hashBuf = await window.crypto.subtle.digest('SHA-256', buf);
|
||||
const arr = new Uint8Array(hashBuf);
|
||||
const byteHex = arr[0].toString(16).padStart(2, '0').toUpperCase();
|
||||
if (byteHex === wantUp) { match = c; break; }
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
const isEnc = !!match.encrypted || /^enc_/i.test(match.hash || '');
|
||||
pktCtx.channelName = isEnc ? ('Encrypted (0x' + inferredChannelHash + ')') : (match.name || '#' + inferredChannelHash);
|
||||
pktCtx.channelEncrypted = isEnc;
|
||||
if (match.lastMessage && match.lastMessage !== 'Encrypted — click to decrypt') {
|
||||
pktCtx.channelLastMessage = match.lastMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!pktCtx.channelName) {
|
||||
pktCtx.channelName = 'channel 0x' + inferredChannelHash;
|
||||
}
|
||||
// Fetch decrypted text for this specific packet (only if not encrypted-only)
|
||||
if (!pktCtx.channelEncrypted) {
|
||||
try {
|
||||
const cleanName = (pktCtx.channelName || '').replace(/^#/, '');
|
||||
const msgResp = await fetch('/api/channels/' + encodeURIComponent(cleanName) + '/messages?limit=10');
|
||||
if (msgResp.ok) {
|
||||
const msgs = await msgResp.json();
|
||||
const m = (msgs.messages || []).find(mm => mm.packet_hash === packetHash || mm.hash === packetHash);
|
||||
if (m && (m.text || m.plainText)) pktCtx.decryptedText = m.text || m.plainText;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (allPaths.length === 0) {
|
||||
// Polish review (doshi #1423): operator clicked a ?packet=<hash> URL
|
||||
// but every observation had an empty path. Previously this bailed
|
||||
// silently (map stayed where it was, no message). Surface a console
|
||||
// breadcrumb + a brief toast so the operator knows why nothing
|
||||
// rendered.
|
||||
console.warn('[deep-link] packet ' + packetHash + ' has no observed route data (all observations had empty path)');
|
||||
try {
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'mc-rt-toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.style.cssText = 'position:fixed;top:80px;left:50%;transform:translateX(-50%);' +
|
||||
'background:var(--mc-bg-secondary,#1a1a1a);color:var(--mc-text-primary,#e5e5e5);' +
|
||||
'padding:10px 16px;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.4);' +
|
||||
'z-index:10000;font:13px/1.4 system-ui,sans-serif;max-width:80vw;text-align:center;';
|
||||
toast.textContent = 'Packet has no observed route data.';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function () { try { toast.remove(); } catch (_) {} }, 4000);
|
||||
} catch (e) { console.warn('[deep-link] toast failed:', e); }
|
||||
return;
|
||||
}
|
||||
if (allPaths.length === 1) {
|
||||
drawPacketRoute(chosenPath, origin, { destination: destination, packetHash: packetHash, observationId: obsId, packetContext: pktCtx });
|
||||
} else {
|
||||
drawPacketRouteMulti(allPaths, origin, {
|
||||
packetHash: packetHash,
|
||||
observationId: obsId,
|
||||
canonicalPath: chosenPath,
|
||||
destination: destination,
|
||||
packetContext: pktCtx
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[deep-link] route load failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodes() {
|
||||
try {
|
||||
// Load regions from config + observed IATAs
|
||||
@@ -2031,9 +1476,8 @@
|
||||
var roleOrder = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
// #1356 V2: pill background uses the --mc-role-* Wong palette (CSS var),
|
||||
// pill text is the role letter (primary, monochrome-safe carrier).
|
||||
// #1407: pill text COLOR now comes from --mc-role-X-text (set by
|
||||
// cb-presets.js + style.css :root defaults), paired per-bg to clear
|
||||
// WCAG 1.4.3 AA on every preset (achromat / trit needed the flip).
|
||||
// The audit's minimal patch keeps dark text on every Wong hue, so no
|
||||
// per-role text-color branching is needed.
|
||||
var ROLE_BG_VAR = {
|
||||
repeater: 'var(--mc-role-repeater)',
|
||||
companion: 'var(--mc-role-companion)',
|
||||
@@ -2041,13 +1485,6 @@
|
||||
sensor: 'var(--mc-role-sensor)',
|
||||
observer: 'var(--mc-role-observer)',
|
||||
};
|
||||
var ROLE_TEXT_VAR = {
|
||||
repeater: 'var(--mc-role-repeater-text, #1a1a1a)',
|
||||
companion: 'var(--mc-role-companion-text, #1a1a1a)',
|
||||
room: 'var(--mc-role-room-text, #1a1a1a)',
|
||||
sensor: 'var(--mc-role-sensor-text, #1a1a1a)',
|
||||
observer: 'var(--mc-role-observer-text, #1a1a1a)',
|
||||
};
|
||||
var pillsHtml = '';
|
||||
var tooltipParts = [];
|
||||
var pillsShown = 0;
|
||||
@@ -2058,14 +1495,13 @@
|
||||
tooltipParts.push(n + ' ' + role + (n === 1 ? '' : 's'));
|
||||
if (pillsShown < 4) {
|
||||
var bg = ROLE_BG_VAR[role] || 'var(--mc-role-companion)';
|
||||
var fg = ROLE_TEXT_VAR[role] || 'var(--mc-role-companion-text, #1a1a1a)';
|
||||
var letter = ROLE_LETTERS[role] || '?';
|
||||
// #1360 follow-up: cap 4+ digit counts as "999+" to bound pill width.
|
||||
// Defense-in-depth: .mc-pill CSS also enforces max-width + ellipsis.
|
||||
if (n > 999) n = '999+';
|
||||
pillsHtml += '<span class="mc-pill role-' + role + '" ' +
|
||||
'role="img" aria-label="' + n + ' ' + role + (n === 1 ? '' : 's') + '" ' +
|
||||
'style="background:' + bg + ';color:' + fg + '" ' +
|
||||
'style="background:' + bg + ';color:#1a1a1a" ' +
|
||||
'title="' + n + ' ' + role + (n === 1 ? '' : 's') + '">' +
|
||||
letter + n + '</span>';
|
||||
pillsShown += 1;
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
/* #1461 mobile page-actions: mirror per-page header buttons (pause, filter
|
||||
* toggle) into the top nav's .nav-right area on mobile so the page-header
|
||||
* row can be hidden entirely. On desktop this script is a no-op. */
|
||||
(function () {
|
||||
'use strict';
|
||||
const MOBILE_BP = 600;
|
||||
const SLOT_ID = 'navPageActions';
|
||||
|
||||
function isMobile() { return window.innerWidth <= MOBILE_BP; }
|
||||
|
||||
function ensureSlot() {
|
||||
let slot = document.getElementById(SLOT_ID);
|
||||
if (slot) return slot;
|
||||
// On mobile, .nav-right is display:none — use .nav-left so the slot is
|
||||
// visible. Append after the brand link.
|
||||
const navLeft = document.querySelector('.nav-left');
|
||||
if (!navLeft) return null;
|
||||
slot = document.createElement('div');
|
||||
slot.id = SLOT_ID;
|
||||
// Layout-only styles inline; visual tokens (border/colors) come from
|
||||
// the mobile @media block in style.css so the customizer can theme us.
|
||||
slot.style.cssText = 'display:inline-flex;gap:4px;align-items:center;margin-left:8px;';
|
||||
navLeft.appendChild(slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
function clearSlot() {
|
||||
const slot = document.getElementById(SLOT_ID);
|
||||
if (slot) slot.innerHTML = '';
|
||||
}
|
||||
|
||||
function makeBtn(label, title, onClick) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'nav-btn';
|
||||
b.type = 'button';
|
||||
b.title = title;
|
||||
b.textContent = label;
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
function syncForRoute() {
|
||||
// #1461 #6: close mobile detail sheet on route change away from packets
|
||||
try {
|
||||
const sheet = document.getElementById('mobileDetailSheet');
|
||||
if (sheet && !/^#\/packets/.test(location.hash || '')) {
|
||||
sheet.classList.remove('open');
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
if (!isMobile()) { clearSlot(); return; }
|
||||
const hash = location.hash || '';
|
||||
const slot = ensureSlot();
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
|
||||
if (/^#\/packets(\/|$|\?)/.test(hash)) {
|
||||
// Mirror pause button (icon only — small)
|
||||
const pause = makeBtn('⏸', 'Pause live updates', function () {
|
||||
const real = document.getElementById('pktPauseBtn');
|
||||
if (real) real.click();
|
||||
});
|
||||
pause.classList.add('mpa-btn-icon');
|
||||
slot.appendChild(pause);
|
||||
// Mirror filter toggle as a labeled "Filters ▾" pill (matches inline style)
|
||||
const filt = makeBtn('Filters ▾', 'Toggle filters', function () {
|
||||
const real = document.querySelector('.filter-bar .filter-toggle-btn, #filterToggleBtn');
|
||||
if (real) real.click();
|
||||
});
|
||||
filt.className = 'nav-btn filter-toggle-btn-mirror mpa-btn-pill';
|
||||
slot.appendChild(filt);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', syncForRoute);
|
||||
window.addEventListener('resize', syncForRoute);
|
||||
|
||||
/* #1461 #7: on mobile, packets-list group-header expand is a UX dead-end
|
||||
* (we hid the chevron so there's no way to collapse). Intercept those
|
||||
* clicks and force them to the single-select code path instead — the
|
||||
* detail pane has all the obs info anyway. */
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!isMobile()) return;
|
||||
const row = e.target.closest && e.target.closest('#pktTable tr[data-action="toggle-select"]');
|
||||
if (!row) return;
|
||||
// Convert to a select-hash event by re-dispatching synthetically — simpler
|
||||
// to mutate the attribute briefly so the existing delegated handler
|
||||
// routes it correctly.
|
||||
row.setAttribute('data-action', 'select-hash');
|
||||
}, true);
|
||||
|
||||
/* #1461 #8: traffic_share_score / bridge_score tooltips use title= which
|
||||
* doesn't fire on touch. Show a click-to-toast popover on mobile when
|
||||
* operator taps a TD whose title mentions traffic/bridge/centrality. */
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!isMobile()) return;
|
||||
const el = e.target.closest('[title]');
|
||||
if (!el) return;
|
||||
const text = el.getAttribute('title');
|
||||
if (!text) return;
|
||||
// Limit to score / metric explanations to avoid spamming on every titled element
|
||||
if (!/traffic share|bridge|centrality|score|usefulness/i.test(text + ' ' + el.textContent)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showToast(text);
|
||||
}, true);
|
||||
function showToast(msg) {
|
||||
let t = document.getElementById('mcMobileToast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.id = 'mcMobileToast';
|
||||
t.className = 'mpa-toast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.style.opacity = '1';
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(() => { t.style.opacity = '0'; }, 4000);
|
||||
}
|
||||
|
||||
/* #1467: mirror missing top-nav controls (Favorites, Search, Customize)
|
||||
* into the bottom-nav More sheet. bottom-nav.js only wired Dark mode;
|
||||
* the others have no mobile surface today. Insert above the existing
|
||||
* dark-mode separator so the new items group with the other route items. */
|
||||
function addMissingMoreSheetItems(retryCount) {
|
||||
retryCount = retryCount || 0;
|
||||
const sheet = document.querySelector('[data-bottom-nav-sheet]');
|
||||
if (!sheet) {
|
||||
// Bounded retry — bottom-nav.js builds the sheet asynchronously, but
|
||||
// give up after ~5s so we don't poll forever on pages that don't have
|
||||
// bottom-nav (e.g. embedded views, headless tests).
|
||||
if (retryCount < 10) setTimeout(() => addMissingMoreSheetItems(retryCount + 1), 500);
|
||||
return;
|
||||
}
|
||||
if (sheet.querySelector('[data-mpa-mirror]')) return; // already injected
|
||||
|
||||
const mirrors = [
|
||||
{ id: 'favToggle', icon: '⭐', label: 'Favorites' },
|
||||
{ id: 'searchToggle', icon: '🔍', label: 'Search' },
|
||||
{ id: 'customizeToggle', icon: '🎨', label: 'Customize' },
|
||||
];
|
||||
|
||||
const sep = sheet.querySelector('.bottom-nav-sheet-sep');
|
||||
mirrors.forEach((m) => {
|
||||
const real = document.getElementById(m.id);
|
||||
if (!real) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'bottom-nav-sheet-item';
|
||||
btn.setAttribute('role', 'menuitem');
|
||||
btn.setAttribute('data-mpa-mirror', m.id);
|
||||
|
||||
const ic = document.createElement('span');
|
||||
ic.className = 'bottom-nav-sheet-icon';
|
||||
ic.setAttribute('aria-hidden', 'true');
|
||||
ic.textContent = m.icon;
|
||||
|
||||
const lb = document.createElement('span');
|
||||
lb.className = 'bottom-nav-sheet-label';
|
||||
lb.textContent = m.label;
|
||||
|
||||
btn.appendChild(ic);
|
||||
btn.appendChild(lb);
|
||||
btn.addEventListener('click', function () {
|
||||
real.click();
|
||||
// close the sheet after delegating
|
||||
try { sheet.classList.remove('open'); } catch (_e) {}
|
||||
});
|
||||
|
||||
if (sep) sheet.insertBefore(btn, sep);
|
||||
else sheet.appendChild(btn);
|
||||
});
|
||||
}
|
||||
// Also re-run when sheet is opened (bottom-nav rebuilds it on open)
|
||||
document.addEventListener('click', function (e) {
|
||||
const target = e.target.closest && e.target.closest('[data-bottom-nav-more]');
|
||||
if (target) setTimeout(addMissingMoreSheetItems, 50);
|
||||
}, true);
|
||||
|
||||
// Run after page-header is rendered (packets.js builds it async); retry briefly
|
||||
let tries = 0;
|
||||
function init() {
|
||||
syncForRoute();
|
||||
addMissingMoreSheetItems();
|
||||
if (tries++ < 20 && /^#\/packets/.test(location.hash) && !document.getElementById('pktPauseBtn')) {
|
||||
setTimeout(init, 250);
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
+10
-40
@@ -74,29 +74,6 @@
|
||||
let wsHandler = null;
|
||||
let detailMap = null;
|
||||
|
||||
// #1461 followup: node-detail inset map tile layer that honors the
|
||||
// customizer dark-tile-provider pick (#1420/#1430). Falls back to
|
||||
// window.getTileUrl() output if the registry isn't loaded. Also applies
|
||||
// the provider's invert CSS filter to the tile pane when needed.
|
||||
function _applyTilesToNodeMap(map) {
|
||||
if (!map) return;
|
||||
var tileUrl = (window.getTileUrl && window.getTileUrl()) || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
var provider = window.getActiveTileProvider && window.getActiveTileProvider();
|
||||
var attribution = (provider && provider.attribution) || '© OpenStreetMap contributors';
|
||||
var layer = L.tileLayer(tileUrl, { maxZoom: 18, attribution: attribution }).addTo(map);
|
||||
// Esri 2-layer provider: add the labels reference overlay too
|
||||
if (provider && provider.refUrl) {
|
||||
try { L.tileLayer(provider.refUrl, { maxZoom: 18 }).addTo(map); } catch (_e) {}
|
||||
}
|
||||
// Apply invert CSS filter to the tile pane if the provider needs it
|
||||
try {
|
||||
var pane = map.getPane && map.getPane('tilePane');
|
||||
if (pane) pane.style.filter = (provider && provider.invertFilter) ? provider.invertFilter : '';
|
||||
} catch (_e) {}
|
||||
return layer;
|
||||
}
|
||||
|
||||
|
||||
// ROLE_COLORS loaded from shared roles.js
|
||||
const TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
@@ -570,14 +547,8 @@
|
||||
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
|
||||
<tr><td>Last Heard</td><td>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</td></tr>
|
||||
${(n.role === 'repeater' || n.role === 'room') ? `<tr><td title="Last time this repeater appeared as a relay hop in a non-advert packet observed by the network. Distinct from 'Last Heard' (which counts the repeater's own adverts). See issue #662.">Last Relayed</td><td>${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? '<span style="color:var(--status-green);font-size:11px">🟢 actively relaying</span>' : '<span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>') : '<span style="color:var(--text-muted)">never observed as relay hop</span> <span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` <span style="color:var(--text-muted);font-size:11px;margin-left:4px">(${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)</span>` : ''}</td></tr>` : ''}
|
||||
${(n.role === 'repeater' || n.role === 'room') && (n.traffic_share_score != null || n.usefulness_score != null) ? (() => {
|
||||
// #1456: prefer the new traffic_share_score field; fall back
|
||||
// to legacy usefulness_score for graceful degradation
|
||||
// against stale servers. The visible label is now "Traffic
|
||||
// share" (the old "Usefulness" implied a composite that
|
||||
// doesn't exist yet — see #672).
|
||||
const raw = (n.traffic_share_score != null) ? n.traffic_share_score : n.usefulness_score;
|
||||
const s = Number(raw) || 0;
|
||||
${(n.role === 'repeater' || n.role === 'room') && n.usefulness_score != null ? (() => {
|
||||
const s = Number(n.usefulness_score) || 0;
|
||||
const pct = (s * 100).toFixed(1);
|
||||
// Visual indicator: width % bar with green→yellow→red color by score.
|
||||
// Per issue #672 classification table: 0.8+ Critical, 0.6+ Valuable,
|
||||
@@ -589,15 +560,15 @@
|
||||
else if (s >= 0.1) { label = 'Marginal'; color = 'var(--status-orange, #e67e22)'; }
|
||||
else { label = 'Redundant'; color = 'var(--status-red, #e74c3c)'; }
|
||||
const barWidth = Math.max(2, Math.round(s * 100));
|
||||
const tooltip = "Fraction of all non-advert mesh traffic in the analyzer's memory that transited through this repeater as a relay hop. High = lots of packets pass through; low = quieter (may still be structurally important — see Bridge score). One of 4 planned scoring axes (#672); others pending.";
|
||||
return `<tr id="row-usefulness-score" data-usefulness-score="${s.toFixed(4)}" data-traffic-share-score="${s.toFixed(4)}"><td title="${tooltip}">Traffic share <span style="color:var(--text-muted);cursor:help" aria-label="help">ⓘ</span></td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${barWidth}%;height:100%;background:${color}"></span></span><span style="color:${color};font-weight:600">${pct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${label}</span></td></tr>`;
|
||||
return `<tr id="row-usefulness-score" data-usefulness-score="${s.toFixed(4)}"><td title="Fraction of non-advert traffic in the network observed by CoreScope that this repeater carries as a relay hop (Traffic axis of issue #672). Range 0–1; higher = forwards more of the mesh's actual traffic.">Usefulness</td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${barWidth}%;height:100%;background:${color}"></span></span><span style="color:${color};font-weight:600">${pct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${label}</span></td></tr>`;
|
||||
})() : ''}
|
||||
${(n.role === 'repeater' || n.role === 'room') && n.bridge_score != null ? (() => {
|
||||
// Bridge axis (issue #672 axis 2 of 4): normalized betweenness
|
||||
// centrality from the neighbor-edges graph. Distinct from the
|
||||
// Traffic-share score above — bridge measures STRUCTURAL
|
||||
// importance (how many shortest paths between other node
|
||||
// pairs go through this one) regardless of current traffic.
|
||||
// Traffic-based Usefulness score above — bridge measures
|
||||
// STRUCTURAL importance (how many shortest paths between
|
||||
// other node pairs go through this one) regardless of
|
||||
// current traffic.
|
||||
const b = Number(n.bridge_score) || 0;
|
||||
const bpct = (b * 100).toFixed(1);
|
||||
let blabel, bcolor;
|
||||
@@ -607,8 +578,7 @@
|
||||
else if (b > 0) { blabel = 'Marginal'; bcolor = 'var(--status-orange, #e67e22)'; }
|
||||
else { blabel = 'No bridge role'; bcolor = 'var(--text-muted)'; }
|
||||
const bbarWidth = Math.max(2, Math.round(b * 100));
|
||||
const btooltip = "Normalized betweenness centrality (0..1). How often this node sits on the shortest path between other pairs of nodes in the affinity graph. 1.0 = the most structurally critical node on the mesh. High Bridge + low Traffic share = a quiet but irreplaceable chokepoint.";
|
||||
return `<tr id="row-bridge-score" data-bridge-score="${b.toFixed(4)}"><td title="${btooltip}">Bridge score <span style="color:var(--text-muted);cursor:help" aria-label="help">ⓘ</span></td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${bbarWidth}%;height:100%;background:${bcolor}"></span></span><span style="color:${bcolor};font-weight:600">${bpct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${blabel}</span></td></tr>`;
|
||||
return `<tr id="row-bridge-score" data-bridge-score="${b.toFixed(4)}"><td title="Structural importance of this repeater as a path between other nodes — normalized betweenness centrality on the neighbor-edges graph (Bridge axis of issue #672, axis 2 of 4). Higher = more pairs of nodes route shortest paths through this one. Independent of current traffic.">Bridge</td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${bbarWidth}%;height:100%;background:${bcolor}"></span></span><span style="color:${bcolor};font-weight:600">${bpct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${blabel}</span></td></tr>`;
|
||||
})() : ''}
|
||||
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
|
||||
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
|
||||
@@ -699,7 +669,7 @@
|
||||
try {
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
detailMap = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13);
|
||||
_applyTilesToNodeMap(detailMap);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap);
|
||||
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12));
|
||||
setTimeout(() => detailMap.invalidateSize(), 100);
|
||||
} catch {}
|
||||
@@ -1547,7 +1517,7 @@
|
||||
try {
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
detailMap = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13);
|
||||
_applyTilesToNodeMap(detailMap);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap);
|
||||
L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12));
|
||||
setTimeout(() => detailMap.invalidateSize(), 100);
|
||||
} catch {}
|
||||
|
||||
+18
-69
@@ -1331,7 +1331,7 @@
|
||||
<button class="btn-icon" data-action="pkt-byop" title="Bring Your Own Packet" aria-label="Bring Your Own Packet - paste raw packet hex for analysis" aria-haspopup="dialog">📦 BYOP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group pkt-filter-expr" style="flex:1;margin-bottom:8px;position:relative">
|
||||
<div class="filter-group" style="flex:1;margin-bottom:8px;position:relative">
|
||||
<input type="text" id="packetFilterInput" class="packet-filter-input"
|
||||
placeholder='Filter: type == Advert && snr > 5 · payload.name contains "Gilroy"'
|
||||
aria-label="Packet filter expression"
|
||||
@@ -1406,9 +1406,9 @@
|
||||
</div>
|
||||
<div class="table-fluid-wrap"><table class="data-table" id="pktTable">
|
||||
<thead><tr>
|
||||
<th scope="col" class="col-expand" data-priority="1"></th><th scope="col" class="col-region" data-sort-key="region" data-priority="3">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date" data-priority="1">Time</th><th scope="col" class="col-hash" data-sort-key="hash" data-priority="3">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric" data-priority="4">Size</th>
|
||||
<th scope="col" data-priority="1"></th><th scope="col" class="col-region" data-sort-key="region" data-priority="3">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date" data-priority="1">Time</th><th scope="col" class="col-hash" data-sort-key="hash" data-priority="1">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric" data-priority="4">Size</th>
|
||||
<th scope="col" class="col-hashsize" data-sort-key="hb" data-type="numeric" data-priority="5">HB</th>
|
||||
<th scope="col" class="col-type" data-sort-key="type" data-priority="1">Type</th><th scope="col" class="col-observer" data-sort-key="observer" data-priority="3">Observer</th><th scope="col" class="col-path" data-sort-key="path" data-priority="5">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric" data-priority="3">Rpt</th><th scope="col" class="col-details" data-priority="1">Details</th>
|
||||
<th scope="col" class="col-type" data-sort-key="type" data-priority="1">Type</th><th scope="col" class="col-observer" data-sort-key="observer" data-priority="1">Observer</th><th scope="col" class="col-path" data-sort-key="path" data-priority="2">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric" data-priority="4">Rpt</th><th scope="col" class="col-details" data-priority="2">Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="pktBody"></tbody>
|
||||
</table></div>
|
||||
@@ -2017,7 +2017,7 @@
|
||||
const _grpHashStripe = _hashStripeStyle(p.hash);
|
||||
const _grpStyle = _grpHashStripe + _grpChanStyle;
|
||||
let html = `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_grpStyle ? ' style="' + _grpStyle + '"' : ''}>
|
||||
<td class="col-expand" style="text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || '—', 8)}</td>
|
||||
@@ -2044,7 +2044,7 @@
|
||||
const childPathStr = renderPath(childPath, c.observer_id);
|
||||
const _childHashStripe = _hashStripeStyle(c.hash || p.hash);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" data-entry-idx="${entryIdx}" tabindex="0" role="row"${_childHashStripe ? ' style="' + _childHashStripe + '"' : ''}>
|
||||
<td class="col-expand"></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(c.hash || '')}">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
@@ -2076,7 +2076,7 @@
|
||||
const _flatHashStripe = _hashStripeStyle(p.hash);
|
||||
const _flatStyle = _flatHashStripe + _chanStyle;
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" data-entry-idx="${entryIdx}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}"${_flatStyle ? ' style="' + _flatStyle + '"' : ''}>
|
||||
<td class="col-expand"></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash" data-filter-field="hash" data-filter-value="${escapeHtml(p.hash || '')}">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
@@ -2920,37 +2920,22 @@
|
||||
rawCustomRow = `<dt>Raw Custom</dt><dd class="raw-custom-detail">Length: <code>${escapeHtml(rl)}</code> · First byte tag: <code>${escapeHtml(ft)}</code></dd>`;
|
||||
}
|
||||
|
||||
// #1458 P0-A — semantic identity header (type badge + decoded summary +
|
||||
// src→dst). Replaces the prior byte-count title that buried packet
|
||||
// identity behind a byte counter (#1458 P0-A).
|
||||
const semanticSummary = getDetailPreview(decoded);
|
||||
const srcLabel = decoded.sender || decoded.name || (decoded.srcHash ? decoded.srcHash.slice(0,8) : null) || (decoded.pubKey ? decoded.pubKey.slice(0,8) + '…' : null);
|
||||
const dstLabel = decoded.recipient || (decoded.destHash ? decoded.destHash.slice(0,8) : null);
|
||||
const srcDstHtml = (srcLabel || dstLabel)
|
||||
? `<div class="detail-srcdst">${escapeHtml(srcLabel || '?')} <span class="arrow">→</span> ${escapeHtml(dstLabel || (decoded.channel ? '#' + decoded.channel : '?'))}</div>`
|
||||
: '';
|
||||
|
||||
panel.innerHTML = `
|
||||
${anomalyBanner}
|
||||
<div class="detail-title">
|
||||
<span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span>
|
||||
${semanticSummary ? `<span class="detail-summary">${semanticSummary}</span>` : ''}
|
||||
${displayHopCount > 0 ? `<span class="badge badge-info">${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''}</span>` : ''}
|
||||
</div>
|
||||
${srcDstHtml}
|
||||
<div class="detail-title">${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}</div>
|
||||
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}${obsIndicator}</div>
|
||||
${messageHtml}
|
||||
<dl class="detail-meta">
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
<dt>Path</dt><dd>${displayHopCount > 0 ? `<span class="badge badge-info">${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''}</span> ` + renderPath(pathHops, effectivePkt.observer_id) : '— (direct)'}</dd>
|
||||
<dt>Timestamp</dt><dd>${renderTimestampCell(effectivePkt.timestamp)}</dd>
|
||||
<dt>Observer</dt><dd>${obsNameOnly(effectivePkt.observer_id)}${obsIataBadge(effectivePkt)}</dd>
|
||||
${locationHtml ? `<dt>Location</dt><dd>${locationHtml}</dd>` : ''}
|
||||
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
|
||||
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
|
||||
${pkt.scope_name != null ? `<dt>Scope</dt><dd>${pkt.scope_name !== '' ? escapeHtml(pkt.scope_name) : '<span style="color:var(--text-muted)">unknown scope</span>'}</dd>` : ''}
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
|
||||
<dt>Timestamp</dt><dd>${renderTimestampCell(effectivePkt.timestamp)}</dd>
|
||||
<dt>Propagation</dt><dd>${propagationHtml}</dd>
|
||||
<dt>Path</dt><dd>${displayHopCount > 0 ? `<span class="badge badge-info">${displayHopCount} hop${displayHopCount !== 1 ? 's' : ''}</span> ` + renderPath(pathHops, effectivePkt.observer_id) : '— (direct)'}</dd>
|
||||
${transportCodesRow}
|
||||
${rawCustomRow}
|
||||
${effectivePkt.direction ? `<dt>Direction</dt><dd>${escapeHtml(effectivePkt.direction)}</dd>` : ''}
|
||||
@@ -2962,13 +2947,10 @@
|
||||
<button class="replay-live-btn" title="Replay this packet on the live map">▶ Replay</button>
|
||||
</div>
|
||||
|
||||
${(hasRawHex || Object.keys(decoded).length) ? `<details class="detail-technical"${(typeof window !== 'undefined' && window.innerWidth > 480) ? ' open' : ''}>
|
||||
<summary>Show raw bytes</summary>
|
||||
${hasRawHex ? `<div class="hex-legend">${buildHexLegend(ranges)}</div>
|
||||
<div class="hex-dump">${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}</div>` : ''}
|
||||
${hasRawHex ? `<div class="hex-legend">${buildHexLegend(ranges)}</div>
|
||||
<div class="hex-dump">${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}</div>` : ''}
|
||||
|
||||
${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
|
||||
</details>` : ''}
|
||||
${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
|
||||
|
||||
${observations.length > 1 ? `
|
||||
<div class="detail-observations" style="margin-top:16px">
|
||||
@@ -3094,44 +3076,11 @@
|
||||
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
|
||||
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
|
||||
if (senderLat != null && senderLon != null) { origin.lat = senderLat; origin.lon = senderLon; }
|
||||
// #1418 Phase D: also include the recipient (destHash) so the route
|
||||
// displays as: sender → [intermediate hops] → recipient. Without
|
||||
// this the destination node is invisible — operator only sees the
|
||||
// last intermediate repeater.
|
||||
const destination = {};
|
||||
if (decoded.destHash) destination.pubkey = decoded.destHash;
|
||||
// #1418 Phase C: include ALL observations as alternate paths so the
|
||||
// route view can render union-of-edges with stroke-width weighting.
|
||||
// Each observation contributes its own path_json array.
|
||||
const allPaths = (observations || []).map(o => {
|
||||
let path = [];
|
||||
try { path = JSON.parse(o.path_json || '[]'); } catch (_) {}
|
||||
return { path: path, observer: o.observer_name, observer_id: o.observer_id, snr: o.snr, rssi: o.rssi };
|
||||
}).filter(p => p.path && p.path.length > 0);
|
||||
// #1418/#1419: navigate via deep-link URL only. The map page's
|
||||
// loadRouteFromDeepLink() re-fetches the packet from the API and
|
||||
// builds the full payload (incl. packetContext) consistently.
|
||||
// SessionStorage was unreliable — the deep-link path includes
|
||||
// packetContext but the sessionStorage payload didn't, leading
|
||||
// to missing chip + facts when entered from the packets page.
|
||||
const obsId = currentObs ? currentObs.id : (observations[0] && observations[0].id);
|
||||
const pkHash = pkt.hash || pkt.packet_hash;
|
||||
const obsPart = obsId ? '&obs=' + encodeURIComponent(obsId) : '';
|
||||
// Tufte audit fix: close ALL mobile packet panels so operator lands
|
||||
// on the route view, not behind a still-visible detail sheet.
|
||||
// Three different panels exist depending on viewport + flow:
|
||||
// - #pktRight (desktop split-pane)
|
||||
// - .slide-over-panel (mid-width SlideOver)
|
||||
// - #mobileDetailSheet (small-mobile bottom sheet)
|
||||
if (window.innerWidth <= 767) {
|
||||
try { closeDetailPanel(); } catch (_) {}
|
||||
try { if (window.SlideOver && window.SlideOver.close) window.SlideOver.close(); } catch (_) {}
|
||||
try {
|
||||
const sheet = document.getElementById('mobileDetailSheet');
|
||||
if (sheet) sheet.classList.remove('open');
|
||||
} catch (_) {}
|
||||
}
|
||||
window.location.hash = '#/map?packet=' + encodeURIComponent(pkHash) + obsPart;
|
||||
sessionStorage.setItem('map-route-hops', JSON.stringify({
|
||||
origin: origin,
|
||||
hops: resolvedKeys
|
||||
}));
|
||||
window.location.hash = '#/map?route=1';
|
||||
} catch {
|
||||
window.location.hash = '#/map';
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/* === CoreScope — prefix-reserved.js =====================================
|
||||
*
|
||||
* Issue #1473 — Flag prefixes that the MeshCore firmware keygen routine
|
||||
* avoids by convention.
|
||||
*
|
||||
* Scope (narrow, per meshcore-protocol-expert review):
|
||||
* - This is a FIRMWARE KEYGEN CONVENTION, not a protocol-level rule.
|
||||
* The standard repeater example re-rolls any new identity whose
|
||||
* public-key FIRST BYTE is 0x00 or 0xFF, so in practice you should
|
||||
* never see a node prefix of 00 or FF in the wild.
|
||||
* - We only check the FIRST byte. Other bytes 00/FF inside a pubkey are
|
||||
* perfectly normal (~96% of pubkeys contain a 00 or FF byte somewhere).
|
||||
* - There is NO protocol rejection of such pubkeys and NO routing-level
|
||||
* wildcard semantics tied to dest_hash == 0xFF.
|
||||
*
|
||||
* Firmware citation (HEAD 8ede7641, examples/simple_repeater/main.cpp:83):
|
||||
*
|
||||
* while (count < 10 && (the_mesh.self_id.pub_key[0] == 0x00
|
||||
* || the_mesh.self_id.pub_key[0] == 0xFF)) {
|
||||
* // reserved id hashes
|
||||
* the_mesh.self_id = radio_new_identity(); count++;
|
||||
* }
|
||||
*
|
||||
* https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83
|
||||
*
|
||||
* Surfaces that consume this helper:
|
||||
* - Prefix matrix (analytics.js → renderHashMatrixFromServer, 1-byte view):
|
||||
* grey 00 / FF cells and disable click, tooltip explains the convention.
|
||||
* - Prefix generator (analytics.js → renderPrefixTool.doGenerate):
|
||||
* never suggest a prefix whose first byte is 00 / FF; visible note.
|
||||
*
|
||||
* Reporter: @halo779 (community).
|
||||
* ========================================================================= */
|
||||
'use strict';
|
||||
|
||||
(function (root) {
|
||||
// First-byte reservations as uppercase 2-char hex strings.
|
||||
var RESERVED_FIRST_BYTES = ['00', 'FF'];
|
||||
var RESERVED_CLASS = 'prefix-reserved';
|
||||
var RESERVED_NOTE = '0x00 and 0xFF excluded — the MeshCore firmware keygen routine avoids these as the first byte of a node pubkey.';
|
||||
var RESERVED_TITLE =
|
||||
'0x00 and 0xFF as a first byte are avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose pub_key[0] is 0x00 or 0xFF), so you should not pick them as a node prefix.';
|
||||
|
||||
function isReservedPrefix(prefix) {
|
||||
if (prefix == null) return false;
|
||||
var s = String(prefix);
|
||||
if (s.length < 2) return false;
|
||||
var head = s.slice(0, 2).toUpperCase();
|
||||
for (var i = 0; i < RESERVED_FIRST_BYTES.length; i++) {
|
||||
if (head === RESERVED_FIRST_BYTES[i]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterReserved(prefixes) {
|
||||
var out = [];
|
||||
for (var i = 0; i < prefixes.length; i++) {
|
||||
if (!isReservedPrefix(prefixes[i])) out.push(prefixes[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// How many prefixes of `bytes` length the reservation removes from the
|
||||
// total space of 256^bytes. (For each reserved first byte the entire
|
||||
// 256^(bytes-1) tail is reserved.)
|
||||
function reservedCount(bytes) {
|
||||
var b = Number(bytes) || 1;
|
||||
if (b < 1) return 0;
|
||||
return RESERVED_FIRST_BYTES.length * Math.pow(256, b - 1);
|
||||
}
|
||||
|
||||
// Given a DOM root (or any object exposing querySelectorAll), find
|
||||
// hash-matrix cells whose data-hex first byte is reserved, mark them
|
||||
// .prefix-reserved + aria-disabled, strip .hash-active so the matrix's
|
||||
// click wiring skips them, and set a tooltip explaining why.
|
||||
// Returns the count of cells marked.
|
||||
function markReservedCells(root) {
|
||||
if (!root || typeof root.querySelectorAll !== 'function') return 0;
|
||||
var cells = root.querySelectorAll('[data-hex]');
|
||||
var n = 0;
|
||||
for (var i = 0; i < cells.length; i++) {
|
||||
var td = cells[i];
|
||||
var hex = (typeof td.getAttribute === 'function')
|
||||
? td.getAttribute('data-hex')
|
||||
: (td.dataset && td.dataset.hex);
|
||||
if (!isReservedPrefix(hex)) continue;
|
||||
if (td.classList && typeof td.classList.add === 'function') {
|
||||
td.classList.add(RESERVED_CLASS);
|
||||
td.classList.remove('hash-active');
|
||||
}
|
||||
if (typeof td.setAttribute === 'function') {
|
||||
td.setAttribute('aria-disabled', 'true');
|
||||
td.setAttribute('title', RESERVED_TITLE);
|
||||
}
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
var api = {
|
||||
RESERVED_FIRST_BYTES: RESERVED_FIRST_BYTES.slice(),
|
||||
RESERVED_CLASS: RESERVED_CLASS,
|
||||
RESERVED_NOTE: RESERVED_NOTE,
|
||||
RESERVED_TITLE: RESERVED_TITLE,
|
||||
isReservedPrefix: isReservedPrefix,
|
||||
filterReserved: filterReserved,
|
||||
reservedCount: reservedCount,
|
||||
markReservedCells: markReservedCells,
|
||||
};
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (root) root.PrefixReserved = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+16
-234
@@ -9,159 +9,10 @@
|
||||
|
||||
(function () {
|
||||
// ─── Role definitions ───
|
||||
// #1407 — Wong palette defaults that match the unscoped --mc-role-* CSS
|
||||
// vars in :root of style.css. These are FALLBACKS only — the live getter
|
||||
// below reads --mc-role-* from documentElement on every access, so any
|
||||
// preset switch (cb-presets.js) is reflected immediately without per-page
|
||||
// listener wiring. The legacy April palette (#dc2626 etc.) was the bug.
|
||||
var WONG_ROLE_DEFAULTS = {
|
||||
repeater: '#D55E00',
|
||||
companion: '#56B4E9',
|
||||
room: '#009E73',
|
||||
sensor: '#F0E442',
|
||||
observer: '#CC79A7',
|
||||
unknown: '#6b7280'
|
||||
window.ROLE_COLORS = {
|
||||
repeater: '#dc2626', companion: '#2563eb', room: '#16a34a',
|
||||
sensor: '#d97706', observer: '#8b5cf6', unknown: '#6b7280'
|
||||
};
|
||||
var WONG_ROLE_TEXT_DEFAULTS = {
|
||||
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#1a1a1a',
|
||||
sensor: '#1a1a1a', observer: '#1a1a1a', unknown: '#1a1a1a'
|
||||
};
|
||||
|
||||
function _readCssVar(name, fallback) {
|
||||
try {
|
||||
if (typeof document === 'undefined' || !document.documentElement) return fallback;
|
||||
var v = '';
|
||||
if (typeof getComputedStyle === 'function') {
|
||||
v = getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
}
|
||||
if (!v && document.documentElement.style && typeof document.documentElement.style.getPropertyValue === 'function') {
|
||||
v = document.documentElement.style.getPropertyValue(name);
|
||||
}
|
||||
v = (v || '').trim();
|
||||
return v || fallback;
|
||||
} catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
// Server-config overrides go into this object; the getter prefers them
|
||||
// when present so backend-pushed role colors still win over CSS vars.
|
||||
var _roleOverrides = {};
|
||||
|
||||
function _liveRoleColors() {
|
||||
var base = {};
|
||||
var roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||||
for (var i = 0; i < roles.length; i++) {
|
||||
var k = roles[i];
|
||||
base[k] = _roleOverrides[k] || _readCssVar('--mc-role-' + k, WONG_ROLE_DEFAULTS[k]);
|
||||
}
|
||||
base.unknown = _roleOverrides.unknown || WONG_ROLE_DEFAULTS.unknown;
|
||||
// Wrap in a Proxy so per-key assignment by legacy callers (customizer:
|
||||
// `window.ROLE_COLORS[key] = inp.value`) lands in _roleOverrides and
|
||||
// is visible on the NEXT read. Without this, the mutation would be
|
||||
// thrown away when the snapshot is GC'd. Falls back to a plain object
|
||||
// in environments without Proxy (none we ship to, but cheap).
|
||||
if (typeof Proxy === 'function') {
|
||||
return new Proxy(base, {
|
||||
set: function (t, prop, value) {
|
||||
_roleOverrides[prop] = value;
|
||||
t[prop] = value;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'ROLE_COLORS', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function () { return _liveRoleColors(); },
|
||||
// Setter accepts per-key writes — older callers do
|
||||
// `ROLE_COLORS.repeater = '#xxx'`
|
||||
// which on a getter-only object would silently no-op in strict mode.
|
||||
// We treat any whole-object assignment as an override merge so the
|
||||
// legacy customizer code path still works.
|
||||
set: function (v) {
|
||||
if (v && typeof v === 'object') {
|
||||
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) _roleOverrides[k] = v[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
// Per-key writes via Proxy not portable enough — expose helper for callers
|
||||
// that want to override at runtime (customizer "node colors" path).
|
||||
// #1438: snapshot of the cb-preset (or initial) CSS-var value per role
|
||||
// so that clearing an override restores the preset, not nothing.
|
||||
// We write the override to BOTH documentElement and body inline styles
|
||||
// because cb-presets ships stylesheet rules of the form
|
||||
// body[data-cb-preset="deut"] { --mc-role-X: #...; }
|
||||
// which beats inheritance from :root. Body inline beats both.
|
||||
var _presetCssSnapshot = {};
|
||||
function _styleTargets() {
|
||||
var t = [];
|
||||
try { if (document.documentElement && document.documentElement.style) t.push(document.documentElement.style); } catch (e) {}
|
||||
try { if (document.body && document.body.style) t.push(document.body.style); } catch (e) {}
|
||||
return t.filter(function (s) { return s && typeof s.setProperty === 'function'; });
|
||||
}
|
||||
window.setRoleColorOverride = function (role, hex) {
|
||||
if (!role) return;
|
||||
var targets = _styleTargets();
|
||||
var varName = '--mc-role-' + role;
|
||||
|
||||
if (hex == null || hex === '') {
|
||||
// Clear override → restore prior CSS var values captured at
|
||||
// first-override time, so CSS-var consumers see the preset color
|
||||
// again (matches JS getter behavior, preserves #1412 contract).
|
||||
delete _roleOverrides[role];
|
||||
if (Object.prototype.hasOwnProperty.call(_presetCssSnapshot, role)) {
|
||||
var snap = _presetCssSnapshot[role] || {};
|
||||
targets.forEach(function (s, i) {
|
||||
var prior = snap[i];
|
||||
if (prior && prior.length) s.setProperty(varName, prior);
|
||||
else s.removeProperty(varName);
|
||||
});
|
||||
delete _presetCssSnapshot[role];
|
||||
} else {
|
||||
targets.forEach(function (s) { s.removeProperty(varName); });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Capture the current per-target CSS var values before overwriting,
|
||||
// but only on the first override for this role so repeated picks
|
||||
// don't lose the original preset value.
|
||||
if (!Object.prototype.hasOwnProperty.call(_presetCssSnapshot, role)) {
|
||||
_presetCssSnapshot[role] = targets.map(function (s) {
|
||||
return s.getPropertyValue ? (s.getPropertyValue(varName) || '').trim() : '';
|
||||
});
|
||||
}
|
||||
_roleOverrides[role] = hex;
|
||||
// #1438: drive the CSS var so CSS-var consumers (cluster pills,
|
||||
// route lines, all marker SVGs that use fill="var(--mc-role-X)")
|
||||
// pick up the operator's hex without a page reload. Writing to
|
||||
// body inline style is necessary because body[data-cb-preset="..."]
|
||||
// selectors beat :root inheritance.
|
||||
//
|
||||
// #1446: write with !important so the inline body declaration also
|
||||
// beats the body[data-cb-preset="X"] CSS rule on equal specificity.
|
||||
// Without !important, the cascade order picks the later-defined
|
||||
// stylesheet rule in some browser versions even though specificity
|
||||
// (1,0,1) matches the inline body style — operator pick visibly
|
||||
// loses to active preset (root cause of #1444).
|
||||
targets.forEach(function (s) {
|
||||
// documentElement gets the value without !important (used as the
|
||||
// canonical readout for the JS getter); body gets !important so it
|
||||
// wins the CSS cascade against body[data-cb-preset="X"].
|
||||
if (s === (document.body && document.body.style)) {
|
||||
s.setProperty(varName, hex, 'important');
|
||||
} else {
|
||||
s.setProperty(varName, hex);
|
||||
}
|
||||
});
|
||||
};
|
||||
// Back-compat: also export the writable override map so customize.js's
|
||||
// `window.ROLE_COLORS[key] = inp.value` style mutation works.
|
||||
// We intercept by replacing the getter target with a Proxy on access.
|
||||
Object.defineProperty(window, 'ROLE_COLORS_OVERRIDES', {
|
||||
value: _roleOverrides, writable: false, enumerable: false, configurable: false
|
||||
});
|
||||
|
||||
window.TYPE_COLORS = {
|
||||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', GRP_DATA: '#8b5cf6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
||||
@@ -214,41 +65,13 @@
|
||||
observer: 'diamond'
|
||||
};
|
||||
|
||||
// #1407 — ROLE_STYLE.color reads live (matches ROLE_COLORS getter).
|
||||
// The shape/radius/weight stay static. Stored overrides survive across
|
||||
// reads via the closure above.
|
||||
var _styleShapes = {
|
||||
repeater: { shape: 'circle', radius: 8, weight: 2 },
|
||||
companion: { shape: 'square', radius: 8, weight: 2 },
|
||||
room: { shape: 'hexagon', radius: 9, weight: 2 },
|
||||
sensor: { shape: 'triangle', radius: 8, weight: 2 },
|
||||
observer: { shape: 'diamond', radius: 9, weight: 2 }
|
||||
window.ROLE_STYLE = {
|
||||
repeater: { color: '#dc2626', shape: 'circle', radius: 8, weight: 2 },
|
||||
companion: { color: '#2563eb', shape: 'square', radius: 8, weight: 2 },
|
||||
room: { color: '#16a34a', shape: 'hexagon', radius: 9, weight: 2 },
|
||||
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
|
||||
observer: { color: '#8b5cf6', shape: 'diamond', radius: 9, weight: 2 }
|
||||
};
|
||||
function _buildRoleStyle() {
|
||||
var out = {};
|
||||
var live = _liveRoleColors();
|
||||
for (var role in _styleShapes) {
|
||||
var s = _styleShapes[role];
|
||||
out[role] = {
|
||||
color: _roleOverrides[role] || live[role],
|
||||
shape: s.shape,
|
||||
radius: s.radius,
|
||||
weight: s.weight
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Object.defineProperty(window, 'ROLE_STYLE', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function () { return _buildRoleStyle(); },
|
||||
set: function (v) {
|
||||
// Legacy whole-object assignment: copy color overrides only.
|
||||
if (v && typeof v === 'object') {
|
||||
for (var k in v) if (v[k] && v[k].color) _roleOverrides[k] = v[k].color;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Glyphs mirror the ROLE_SHAPES (used in tooltips, legends, lists).
|
||||
window.ROLE_EMOJI = {
|
||||
@@ -272,12 +95,7 @@
|
||||
var shape = (window.ROLE_SHAPES && window.ROLE_SHAPES[role]) || 'circle';
|
||||
size = size || 16;
|
||||
var c = size / 2;
|
||||
// #1438: default fill resolves through the live CSS var so existing
|
||||
// mounted SVG markers recolor when cb-preset switches or the
|
||||
// operator picks a per-role override via the customizer. Callers
|
||||
// that need a fixed tint (matrix mode, stale dim) keep passing
|
||||
// their explicit colour.
|
||||
var fill = color || ('var(--mc-role-' + (role || 'companion') + ')');
|
||||
var fill = color || (window.ROLE_COLORS && window.ROLE_COLORS[role]) || '#6b7280';
|
||||
var path;
|
||||
switch (shape) {
|
||||
case 'square':
|
||||
@@ -363,35 +181,7 @@
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (!isDark) return TILE_LIGHT;
|
||||
// #1461 followup: honor customizer's dark-tile-provider pick (#1420 / #1430)
|
||||
// when the registry is loaded. Falls back to TILE_DARK if absent.
|
||||
try {
|
||||
if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) {
|
||||
var id = window.MC_getDarkTileProvider();
|
||||
var p = window.MC_TILE_PROVIDERS[id];
|
||||
if (p && (p.url || p.baseUrl)) {
|
||||
return p.url || p.baseUrl;
|
||||
}
|
||||
}
|
||||
} catch (_e) {}
|
||||
return TILE_DARK;
|
||||
};
|
||||
/* Helper: get the full provider object (for callers that also need the
|
||||
* invertFilter or refUrl/attribution). Returns null when no customizer
|
||||
* provider applies (light mode, or registry not loaded). */
|
||||
window.getActiveTileProvider = function () {
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (!isDark) return null;
|
||||
try {
|
||||
if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) {
|
||||
var id = window.MC_getDarkTileProvider();
|
||||
return window.MC_TILE_PROVIDERS[id] || null;
|
||||
}
|
||||
} catch (_e) {}
|
||||
return null;
|
||||
return isDark ? TILE_DARK : TILE_LIGHT;
|
||||
};
|
||||
|
||||
// ─── SNR thresholds ───
|
||||
@@ -434,16 +224,10 @@
|
||||
// ─── Fetch server overrides ───
|
||||
window.MeshConfigReady = fetch('/api/config/client').then(function (r) { return r.json(); }).then(function (cfg) {
|
||||
if (cfg.roles) {
|
||||
if (cfg.roles.colors) {
|
||||
// #1407 — ROLE_COLORS is now a live getter; merge into the override map.
|
||||
for (var rk in cfg.roles.colors) _roleOverrides[rk] = cfg.roles.colors[rk];
|
||||
}
|
||||
if (cfg.roles.colors) Object.assign(ROLE_COLORS, cfg.roles.colors);
|
||||
if (cfg.roles.labels) Object.assign(ROLE_LABELS, cfg.roles.labels);
|
||||
if (cfg.roles.style) {
|
||||
// Same: merge color overrides only; shape/radius/weight come from _styleShapes.
|
||||
for (var sk in cfg.roles.style) {
|
||||
if (cfg.roles.style[sk] && cfg.roles.style[sk].color) _roleOverrides[sk] = cfg.roles.style[sk].color;
|
||||
}
|
||||
for (var k in cfg.roles.style) ROLE_STYLE[k] = Object.assign(ROLE_STYLE[k] || {}, cfg.roles.style[k]);
|
||||
}
|
||||
if (cfg.roles.emoji) Object.assign(ROLE_EMOJI, cfg.roles.emoji);
|
||||
if (cfg.roles.sort) window.ROLE_SORT = cfg.roles.sort;
|
||||
@@ -453,10 +237,6 @@
|
||||
if (cfg.tiles.dark) window.TILE_DARK = cfg.tiles.dark;
|
||||
if (cfg.tiles.light) window.TILE_LIGHT = cfg.tiles.light;
|
||||
}
|
||||
// #1420 — server default for dark-tile provider picker.
|
||||
if (typeof cfg.mapDarkTileProvider === 'string' && typeof window.MC_setServerDefaultTileProvider === 'function') {
|
||||
window.MC_setServerDefaultTileProvider(cfg.mapDarkTileProvider);
|
||||
}
|
||||
if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds);
|
||||
if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds);
|
||||
if (cfg.maxHopDist != null) window.MAX_HOP_DIST = cfg.maxHopDist;
|
||||
@@ -467,7 +247,9 @@
|
||||
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
|
||||
if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
|
||||
// Sync ROLE_STYLE colors with ROLE_COLORS
|
||||
// #1407 — both are now live getters; no manual sync needed. Kept as no-op for clarity.
|
||||
for (var role in ROLE_STYLE) {
|
||||
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
|
||||
}
|
||||
}).catch(function () { /* use defaults */ });
|
||||
|
||||
// ─── Built-in IATA airport code → city name mapping ───
|
||||
|
||||
@@ -1,727 +0,0 @@
|
||||
/* route-view.css — minimal route view layout + styling. */
|
||||
|
||||
body.mc-route-active #leaflet-map { left: 320px !important; width: calc(100% - 320px) !important; }
|
||||
|
||||
/* Auto-collapse Map Controls panel when route view opens. The toggle button
|
||||
(.map-controls-toggle) stays visible — clicking it expands the panel.
|
||||
Map controls JS uses the `.collapsed` class for its own toggle state. */
|
||||
body.mc-route-active .map-controls.collapsed { display: none !important; }
|
||||
body.mc-route-active #pktRight,
|
||||
body.mc-route-active .slide-over-panel,
|
||||
body.mc-route-active .slide-over-backdrop,
|
||||
body.mc-route-active .mobile-detail-sheet { display: none !important; }
|
||||
|
||||
/* Hide regular node clusters and topology markers during route view so the
|
||||
route polyline + its own markers aren't lost in a 600-node mesh. The route
|
||||
layer's own markers use .mc-rt-marker-icon and are NOT hidden. */
|
||||
body.mc-route-active .leaflet-marker-pane .meshcore-marker { display: none !important; }
|
||||
body.mc-route-active .leaflet-marker-pane .meshcore-label-marker { display: none !important; }
|
||||
body.mc-route-active .leaflet-marker-pane .marker-cluster { display: none !important; }
|
||||
/* CoreScope custom cluster bubble wrappers (not the leaflet.markercluster ones) */
|
||||
body.mc-route-active .leaflet-marker-pane .mc-cluster-wrap { display: none !important; }
|
||||
/* Hide overlay-pane SVG paths that aren't part of the route. The route's
|
||||
own polylines have class="mc-rt-edge". */
|
||||
body.mc-route-active .leaflet-overlay-pane svg path:not(.mc-rt-edge) { display: none !important; }
|
||||
|
||||
.mc-rt-sidebar {
|
||||
position: fixed;
|
||||
top: 52px; /* below top-nav */
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 320px;
|
||||
background: var(--surface, #1a1a1a);
|
||||
border-right: 1px solid var(--border, #333);
|
||||
color: var(--text, #e7e7e7);
|
||||
font: 13px/1.4 system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 500;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.mc-rt-header {
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
position: relative;
|
||||
}
|
||||
.mc-rt-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-right: 36px; /* leave room for close button */
|
||||
}
|
||||
.mc-rt-back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--accent, #06b6d4);
|
||||
text-decoration: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mc-rt-back-link:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
text-decoration: underline;
|
||||
}
|
||||
.mc-rt-back-link:focus {
|
||||
outline: 2px solid var(--accent, #06b6d4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.mc-rt-title { font-size: 14px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-muted, #94a3b8); }
|
||||
.mc-rt-meta { font-size: 11px; color: var(--text-muted, #94a3b8); margin-top: 2px; }
|
||||
.mc-rt-multipath-chip {
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text, #cbd5e1);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.mc-rt-multipath-chip b { color: var(--text, #fff); }
|
||||
.mc-rt-multipath-key {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-style: italic;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Multi-path picker — Click a path to isolate it on the map. */
|
||||
.mc-rt-paths {
|
||||
margin-top: 6px;
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
max-height: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mc-rt-paths[open] { overflow: auto; max-height: 180px; }
|
||||
.mc-rt-paths-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
list-style: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mc-rt-paths-header::-webkit-details-marker { display: none; }
|
||||
.mc-rt-paths-header::before {
|
||||
content: '▾';
|
||||
margin-right: 4px;
|
||||
font-size: 9px;
|
||||
transition: transform 120ms;
|
||||
}
|
||||
.mc-rt-paths:not([open]) .mc-rt-paths-header::before { transform: rotate(-90deg); }
|
||||
.mc-rt-path-clear {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, #444);
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mc-rt-path-clear:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
color: var(--text, #fff);
|
||||
}
|
||||
.mc-rt-path-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
}
|
||||
.mc-rt-path-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
column-gap: 6px;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.mc-rt-path-row:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.08));
|
||||
}
|
||||
.mc-rt-path-row.mc-rt-path-active {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.18));
|
||||
border-left: 3px solid var(--accent, #06b6d4);
|
||||
padding-left: 5px;
|
||||
}
|
||||
.mc-rt-path-row:focus {
|
||||
outline: 2px solid var(--accent, #06b6d4);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.mc-rt-path-count {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-align: right;
|
||||
}
|
||||
.mc-rt-path-hops {
|
||||
font-size: 10px;
|
||||
color: var(--text, #cbd5e1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-path-obs {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 80px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-spark-wrap {
|
||||
margin: 8px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mc-rt-spark-title {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.mc-rt-spark-title b { color: var(--text, #e7e7e7); font-weight: 600; }
|
||||
.mc-rt-spark { display: block; cursor: pointer; }
|
||||
.mc-rt-spark-dot { cursor: pointer; }
|
||||
.mc-rt-spark-dot:hover { r: 3; }
|
||||
.mc-rt-spark-tooltip {
|
||||
position: absolute;
|
||||
background: var(--surface-2, #2a2a2a);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font: 11px ui-monospace, Menlo, monospace;
|
||||
color: var(--text, #e7e7e7);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
}
|
||||
.mc-rt-close {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
background: transparent; border: 1px solid var(--border, #333);
|
||||
color: var(--text, #e7e7e7); border-radius: 4px;
|
||||
width: 26px; height: 26px; cursor: pointer; font-size: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mc-rt-close:hover { background: var(--bg-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.mc-rt-list { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex: 1; }
|
||||
.mc-rt-pinned { padding: 2px 0; }
|
||||
.mc-rt-pinned-top { border-bottom: 1px solid var(--border, #333); }
|
||||
.mc-rt-pinned-bottom { border-top: 1px solid var(--border, #333); }
|
||||
.mc-rt-pinned .mc-rt-row { background: var(--surface-2, #232323); font-weight: 600; }
|
||||
.mc-rt-pinned .mc-rt-row::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
font-size: 9px; letter-spacing: 1px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mc-rt-pinned-top .mc-rt-row::before { content: 'SRC'; padding-right: 4px; }
|
||||
.mc-rt-pinned-bottom .mc-rt-row::before { content: 'DST'; padding-right: 4px; }
|
||||
|
||||
.mc-rt-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 4px 22px 18px 1fr auto;
|
||||
grid-template-rows: auto 4px;
|
||||
column-gap: 6px;
|
||||
align-items: center;
|
||||
padding: 4px 12px 4px 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.02);
|
||||
}
|
||||
.mc-rt-row:hover,
|
||||
.mc-rt-row:focus,
|
||||
.mc-rt-row.mc-rt-row-active {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.08));
|
||||
outline: none;
|
||||
}
|
||||
.mc-rt-stripe {
|
||||
grid-column: 1; grid-row: 1 / -1;
|
||||
width: 4px; height: 100%;
|
||||
background: var(--mc-rt-row-color, transparent);
|
||||
}
|
||||
.mc-rt-seq {
|
||||
grid-column: 2; grid-row: 1;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-align: right;
|
||||
}
|
||||
.mc-rt-glyph {
|
||||
grid-column: 3; grid-row: 1;
|
||||
text-align: center; font-size: 12px;
|
||||
}
|
||||
.mc-rt-name {
|
||||
grid-column: 4; grid-row: 1;
|
||||
font-size: 12px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-obs-chip {
|
||||
display: inline-block;
|
||||
font-size: 9px; padding: 0 4px;
|
||||
background: var(--surface-2, #2a2a2a);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.mc-rt-status-chip {
|
||||
display: inline-block;
|
||||
font-size: 9px; padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mc-rt-status-nogps {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
.mc-rt-status-unknown {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
.mc-rt-status-payload {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: #67e8f9;
|
||||
border: 1px solid rgba(6, 182, 212, 0.35);
|
||||
font-style: italic;
|
||||
}
|
||||
.mc-rt-distlabel {
|
||||
grid-column: 5; grid-row: 1;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.mc-rt-distbar-wrap {
|
||||
grid-column: 2 / -1; grid-row: 2;
|
||||
height: 3px;
|
||||
background: transparent;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mc-rt-distbar {
|
||||
height: 100%;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
.mc-rt-unresolved .mc-rt-name { color: var(--text-muted, #94a3b8); font-style: italic; }
|
||||
|
||||
/* Drill-in expanding panel (hop detail) */
|
||||
.mc-rt-row.mc-rt-row-expanded {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
}
|
||||
.mc-rt-detail-panel {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--surface-2, #1d1d1d);
|
||||
border-top: 1px solid var(--border, #333);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text, #e7e7e7);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mc-rt-row { grid-template-rows: auto 4px auto; }
|
||||
.mc-rt-detail-loading,
|
||||
.mc-rt-detail-na { color: var(--text-muted, #94a3b8); font-style: italic; font-size: 10px; }
|
||||
.mc-rt-detail-row1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; margin-bottom: 4px; }
|
||||
.mc-rt-detail-name { font-weight: 700; color: var(--text, #fff); font-size: 12px; }
|
||||
.mc-rt-detail-warn {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mc-rt-detail-meta {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.mc-rt-detail-label {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.mc-rt-detail-snr,
|
||||
.mc-rt-detail-relay,
|
||||
.mc-rt-detail-also { margin: 2px 0; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.mc-rt-detail-spark { vertical-align: middle; color: var(--text, #cbd5e1); }
|
||||
.mc-rt-detail-spark-meta { font-size: 9px; color: var(--text-muted, #94a3b8); font-family: ui-monospace, Menlo, monospace; }
|
||||
.mc-rt-detail-link { color: var(--accent, #06b6d4); text-decoration: none; }
|
||||
.mc-rt-detail-link:hover { text-decoration: underline; }
|
||||
.mc-rt-detail-link:focus {
|
||||
outline: 2px solid var(--accent, #06b6d4);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mc-rt-detail-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.mc-rt-detail-action:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
border-color: var(--accent, #06b6d4);
|
||||
text-decoration: none;
|
||||
}
|
||||
.mc-rt-route-badge {
|
||||
background: var(--surface, #1a1a1a);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 8px;
|
||||
padding: 1px 5px;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Marker styles — no chips, just shape. */
|
||||
.mc-rt-marker-icon { background: transparent !important; border: none !important; }
|
||||
.mc-rt-marker { position: relative; line-height: 0; cursor: pointer; transition: transform 120ms ease-out; }
|
||||
.mc-rt-marker:hover,
|
||||
.mc-rt-marker.mc-rt-hover { transform: scale(1.5); z-index: 1000 !important; }
|
||||
.mc-rt-marker:focus { outline: 2px solid #06b6d4; outline-offset: 2px; border-radius: 50%; }
|
||||
|
||||
/* packet-context block (type chip + 3-5 facts). Above multi-path. */
|
||||
.mc-rt-ctx {
|
||||
margin: 6px 0 4px;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface-2, #1f1f1f);
|
||||
border-left: 3px solid var(--accent, #06b6d4);
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.mc-rt-ctx-chip {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mc-rt-ctx-glyph {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mc-rt-ctx-facts { display: flex; flex-direction: column; gap: 2px; }
|
||||
.mc-rt-ctx-line { color: var(--text, #cbd5e1); }
|
||||
.mc-rt-ctx-line b { color: var(--text, #fff); font-weight: 600; }
|
||||
.mc-rt-ctx-arrow { color: var(--text-muted, #94a3b8); margin: 0 4px; }
|
||||
.mc-rt-ctx-meta { color: var(--text-muted, #94a3b8); font-size: 10px; }
|
||||
.mc-rt-ctx-mono { font-family: ui-monospace, Menlo, monospace; font-size: 10px; color: var(--text-muted, #94a3b8); }
|
||||
.mc-rt-ctx-quote {
|
||||
font-style: italic;
|
||||
color: var(--text, #fff);
|
||||
padding-left: 6px;
|
||||
border-left: 2px solid var(--border, #444);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
1,2,3… on the map without scrubbing the sidebar. Origin (square) + dest
|
||||
(triangle) are shape-differentiated and don't need numeric labels. */
|
||||
.mc-rt-marker-seq {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 14px; /* to the right of the marker dot, no overlap */
|
||||
background: var(--surface, #1a1a1a);
|
||||
color: var(--text, #fff);
|
||||
border: 1px solid var(--border, #666);
|
||||
border-radius: 5px;
|
||||
min-width: 13px;
|
||||
height: 11px;
|
||||
padding: 0 2px;
|
||||
font: 700 8px/11px ui-monospace, Menlo, monospace;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
z-index: 2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Hover/focus on a marker pops its seq label so it's never hidden by
|
||||
neighbors at high density. */
|
||||
.mc-rt-marker:hover .mc-rt-marker-seq,
|
||||
.mc-rt-marker:focus .mc-rt-marker-seq,
|
||||
.mc-rt-marker.mc-rt-hover .mc-rt-marker-seq {
|
||||
z-index: 1000;
|
||||
transform: scale(1.4);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
/* Mobile: map dominates (75vh), sidebar is a compact bottom strip (25vh)
|
||||
showing just packet type + hop count + distance + summary. Operator's job:
|
||||
see the route on the map first, scroll the strip for hop list. */
|
||||
/* Mobile bottom-sheet handle (visible only on mobile) — hidden by default */
|
||||
.mc-rt-mobile-handle { display: none; }
|
||||
.mc-rt-collapsed-label { display: none; }
|
||||
|
||||
/* Desktop resize handle on the right edge of the sidebar */
|
||||
.mc-rt-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.mc-rt-resize-handle:hover,
|
||||
.mc-rt-resize-handle:focus {
|
||||
background: var(--accent, #06b6d4);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Desktop collapse button — sits on the RIGHT edge of the sidebar,
|
||||
vertically centered. Standard Material/Drive-style affordance: chevron
|
||||
pointing into the panel = collapse, out of the panel = expand. */
|
||||
.mc-rt-collapse-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -14px;
|
||||
transform: translateY(-50%);
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
box-shadow: 1px 0 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.mc-rt-collapse-btn:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
color: var(--text, #fff);
|
||||
border-color: var(--accent, #06b6d4);
|
||||
}
|
||||
|
||||
/* Vertical "ROUTE" label shown only when collapsed */
|
||||
.mc-rt-collapsed-label {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-90deg);
|
||||
transform-origin: center;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Collapsed state on desktop */
|
||||
.mc-rt-sidebar.mc-rt-collapsed {
|
||||
width: 36px !important;
|
||||
min-width: 36px;
|
||||
}
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-header,
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-list,
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-pinned,
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-resize-handle { display: none; }
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-collapsed-label { display: block; }
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-collapse-btn {
|
||||
top: 50%;
|
||||
right: -14px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* When sidebar is collapsed, expand map to fill */
|
||||
body.mc-route-active:has(.mc-rt-sidebar.mc-rt-collapsed) #leaflet-map {
|
||||
left: 36px !important;
|
||||
width: calc(100% - 36px) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Mobile: hide desktop collapse + resize affordances (mobile uses bottom-sheet) */
|
||||
body.mc-route-active .mc-rt-collapse-btn,
|
||||
body.mc-route-active .mc-rt-resize-handle,
|
||||
body.mc-route-active .mc-rt-collapsed-label { display: none !important; }
|
||||
body.mc-route-active #leaflet-map {
|
||||
position: fixed !important;
|
||||
top: 52px !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100% !important;
|
||||
/* iOS Safari/Edge: use dvh (dynamic viewport) so URL bar collapse
|
||||
doesn't leave a stale layout. Fall back to vh for browsers that
|
||||
don't support dvh yet. */
|
||||
bottom: calc(116px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
height: auto !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
body.mc-route-active.mc-rt-mobile-sheet-expanded #leaflet-map {
|
||||
bottom: calc(75vh + 56px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
}
|
||||
body.mc-route-active .map-controls-toggle {
|
||||
position: fixed !important;
|
||||
top: 60px !important;
|
||||
right: 8px !important;
|
||||
z-index: 1100 !important;
|
||||
/* Force overlay — no normal-flow row consumption */
|
||||
margin: 0 !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
body.mc-route-active .map-controls {
|
||||
position: fixed !important;
|
||||
top: 100px !important;
|
||||
right: 8px !important;
|
||||
left: 8px !important;
|
||||
width: auto !important;
|
||||
max-height: 60vh !important;
|
||||
z-index: 1090 !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mc-rt-sidebar {
|
||||
position: fixed;
|
||||
top: auto !important;
|
||||
left: 0 !important;
|
||||
right: 0;
|
||||
/* Sit ABOVE the bottom-nav (56px) + iOS safe-area inset */
|
||||
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
max-height: 60px;
|
||||
transition: height 240ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
z-index: 1190; /* below bottom-nav (1200) but above content */
|
||||
}
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded {
|
||||
height: 75vh;
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Bigger touch target — full sheet header tappable, large chevron */
|
||||
.mc-rt-mobile-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 14px 8px;
|
||||
cursor: pointer;
|
||||
background: var(--surface, #1a1a1a);
|
||||
user-select: none;
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
/* Prevent the page from scrolling when swiping on this area */
|
||||
touch-action: none;
|
||||
}
|
||||
.mc-rt-mobile-grip {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: var(--text-muted, #94a3b8);
|
||||
border-radius: 3px;
|
||||
opacity: 0.6;
|
||||
/* Large tap target around the grip */
|
||||
cursor: grab;
|
||||
}
|
||||
.mc-rt-mobile-grip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: -20px;
|
||||
right: -20px;
|
||||
bottom: -12px;
|
||||
}
|
||||
.mc-rt-mobile-chevron {
|
||||
font-size: 22px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
transition: transform 240ms;
|
||||
/* Make the chevron itself a generous tap target */
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.mc-rt-mobile-summary {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text, #cbd5e1);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
margin-top: 10px;
|
||||
line-height: 1.3;
|
||||
max-height: 36px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mc-rt-mobile-hex {
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 9px;
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-mobile-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
/* Hide full content when collapsed; show when expanded */
|
||||
.mc-rt-sidebar .mc-rt-header,
|
||||
.mc-rt-sidebar .mc-rt-list,
|
||||
.mc-rt-sidebar .mc-rt-pinned { display: none; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-header { display: block; padding: 8px 12px 6px; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-list { display: block; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-pinned { display: block; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-ctx { margin: 4px 0 2px; padding: 4px 6px; font-size: 11px; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-row { padding: 3px 10px 3px 0; font-size: 11px; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+31
-347
@@ -289,16 +289,8 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
all interactive controls. Targets are achieved with min-height/min-width
|
||||
plus inline-flex centering so existing visual styling (font-size, padding,
|
||||
icon size) is preserved on desktop while the *hit area* grows for touch.
|
||||
Issue #1060.
|
||||
|
||||
Issue #1400: the global `min-height: 48px` on `.nav-link` was the root
|
||||
cause of ~20 recurring nav-vanishing bugs (#1391, #1396, and ~18 earlier
|
||||
symptoms). 48px links + padding inflated `.nav-links` to 56px tall inside
|
||||
a 52px `.top-nav` with `overflow:hidden`; Firefox flex-centered the
|
||||
over-tall item to a negative y, clipping the entire link strip ABOVE the
|
||||
viewport. Touch-target sizing is preserved for mobile via the
|
||||
`@media (max-width: 767px)` override added near the hamburger block. */
|
||||
.nav-link { display: inline-flex; align-items: center; }
|
||||
Issue #1060. */
|
||||
.nav-link { min-height: 48px; display: inline-flex; align-items: center; }
|
||||
|
||||
/* Generic button surfaces — filter bar, modal buttons, inline .btn usages.
|
||||
inline-flex keeps text/icons centered without changing visible padding much. */
|
||||
@@ -505,30 +497,18 @@ input[type="week"] {
|
||||
position: sticky; top: 0; z-index: 1100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||
flex-wrap: nowrap; overflow: hidden; min-width: 0;
|
||||
/* #1413: enforce a minimum horizontal gap between flex children of
|
||||
.top-nav (.nav-left and .nav-right). Without this the only thing
|
||||
pushing nav-right away from nav-left was justify-content: space-
|
||||
between, which collapses to zero whenever nav-left's intrinsic
|
||||
content fills the row — including the historic vw~1101..1599 band
|
||||
where .nav-stats reappears (display:flex) and shoves nav-right
|
||||
leftward to overlap the .nav-more-btn. */
|
||||
column-gap: 16px;
|
||||
}
|
||||
/* #1403: removed overflow:hidden — was clipping flex children offscreen (vertical) AND clipping the More dropdown contents. The original purpose was to prevent horizontal overflow during Priority+ measurement (#1066) — that purpose is served by .top-nav itself which still has overflow:hidden. */
|
||||
.nav-left { display: flex; align-items: center; gap: var(--space-lg); min-width: 0; flex-shrink: 1; }
|
||||
.nav-left { display: flex; align-items: center; gap: var(--space-lg); min-width: 0; flex-shrink: 1; overflow: hidden; }
|
||||
.nav-brand { display: flex; align-items: center; gap: var(--space-sm); text-decoration: none; color: var(--nav-text); font-weight: 700; font-size: var(--fs-md); }
|
||||
.brand-icon { font-size: 20px; }
|
||||
/* #1450 — split .brand-logo by element type:
|
||||
- Inline default wordmark (<svg>) keeps the 125x36 pin tuned to its
|
||||
viewBox (#1141 widened it to fit CORE/SCOPE; intrinsic content
|
||||
ratio ~3.08:1, so width:125px @ height:36px is the natural box).
|
||||
- Operator custom logo via branding.logoUrl is an <img>, whose
|
||||
natural aspect ratio MUST be preserved — pinning width:125px on
|
||||
a 1:1 or portrait image squishes it into a pill. Use width:auto
|
||||
with a max-width cap so wide logos don't blow the nav layout. */
|
||||
svg.brand-logo {
|
||||
.brand-logo {
|
||||
display: block;
|
||||
height: 36px;
|
||||
/* Width matches the inline SVG's effective content aspect (~3.08:1)
|
||||
after cropping to the wordmark+nodes region in the navbar viewBox.
|
||||
/* Pinned to match the SVG intrinsic width (#1141 followup: viewBox
|
||||
widened to 150 10 970 280 to fit CORE/SCOPE wordmark; intrinsic
|
||||
width 125 keeps the text legible at the same ~36px height). */
|
||||
width: 125px;
|
||||
max-height: 36px;
|
||||
/* The logo is inlined into the DOM (PR #1137) so it inherits page
|
||||
@@ -537,14 +517,6 @@ svg.brand-logo {
|
||||
--nav-text so the wordmark stays readable on the dark navbar even
|
||||
when the page theme is Light. */
|
||||
}
|
||||
img.brand-logo {
|
||||
display: block;
|
||||
height: 36px;
|
||||
width: auto; /* preserve natural aspect of operator image */
|
||||
max-width: 200px; /* cap wide logos so they can't push nav-right off */
|
||||
max-height: 36px;
|
||||
object-fit: contain; /* defensive — never crop or stretch the image */
|
||||
}
|
||||
.top-nav {
|
||||
/* Navbar lives on --nav-bg (typically dark), so the brand wordmark
|
||||
must use --nav-text rather than the page --text default. */
|
||||
@@ -613,7 +585,7 @@ img.brand-logo {
|
||||
}
|
||||
}
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: var(--space-xs); flex: 1 1 auto; min-width: 0; }
|
||||
.nav-links { display: flex; align-items: center; gap: var(--space-xs); }
|
||||
.nav-link {
|
||||
color: var(--nav-text-muted); text-decoration: none;
|
||||
padding: 14px clamp(8px, 0.6vw + 4px, 14px); font-size: var(--fs-sm);
|
||||
@@ -870,174 +842,7 @@ img.brand-logo {
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
max-width: 0; /* forces td to respect table width instead of expanding to content */
|
||||
}
|
||||
.data-table td.col-details { white-space: normal; word-break: break-word; max-width: 480px; }
|
||||
/* #1415: pin the expand-chevron column narrow at every viewport.
|
||||
* Chevron glyph is ~16px wide; legacy column auto-sized to 50-180px and shoved every
|
||||
* downstream data column right. 32px is enough for the ▶/▼ + 8px padding. */
|
||||
.data-table th.col-expand,
|
||||
.data-table td.col-expand {
|
||||
width: 32px; min-width: 32px; max-width: 32px;
|
||||
padding: 3px 4px; text-align: center;
|
||||
}
|
||||
|
||||
/* #1415 / #1458 / #1461 — mobile packets UX overhaul.
|
||||
* NOTE: hiding the first dt/dd pair below assumes Payload Type is the
|
||||
* first <dt> in .detail-meta (per packets.js renderDetail). Keep that
|
||||
* invariant — test-issue-1415-packets-layout.js asserts it. */
|
||||
@media (max-width: 600px) {
|
||||
/* #1: kill empty chevron rail */
|
||||
#pktTable th.col-expand,
|
||||
#pktTable td.col-expand { display: none !important; }
|
||||
|
||||
/* #1b: slim THEAD ~24px sticky */
|
||||
#pktTable thead th {
|
||||
padding: 3px 6px !important;
|
||||
font-size: 9px !important;
|
||||
line-height: 1.2 !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
text-transform: uppercase;
|
||||
height: 24px !important;
|
||||
}
|
||||
#pktTable thead { position: sticky; top: 0; z-index: 5; background: var(--surface-1, #1a1a1a); }
|
||||
|
||||
/* #2: hide page-header row + filter-expr.
|
||||
* pause + Filters pill are mirrored into .nav-left by mobile-page-actions.js.
|
||||
* NOTE: do NOT display:none the entire .filter-bar — the original
|
||||
* .filter-toggle-btn lives inside it and must remain clickable for the
|
||||
* #534 E2E test (and for any user whose navbar mirror failed to inject). */
|
||||
#pktLeft .page-header { display: none !important; }
|
||||
#pktLeft .pkt-filter-expr { display: none !important; }
|
||||
|
||||
/* Navbar-mirror buttons injected by mobile-page-actions.js. Visual tokens
|
||||
* (border, colors) live here so the customizer can theme them and so we
|
||||
* don't ship them as inline cssText strings. */
|
||||
.mpa-btn-icon {
|
||||
padding: 4px 6px;
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 6px;
|
||||
color: var(--nav-text, #fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mpa-btn-pill {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 999px;
|
||||
color: var(--nav-text, #fff);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mpa-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 90px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface-2, #222);
|
||||
color: var(--text, #fff);
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
max-width: 80vw;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
transition: opacity .3s;
|
||||
}
|
||||
|
||||
.filter-bar.filters-expanded {
|
||||
padding: 6px 8px !important;
|
||||
margin-bottom: 4px !important;
|
||||
background: var(--surface-2, rgba(0,0,0,0.05)) !important;
|
||||
border: 1px solid var(--border, #333) !important;
|
||||
border-radius: 6px !important;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* #3: detail panel cleanup */
|
||||
/* Strip redundant src→dst line (identity in sticky header) */
|
||||
.mobile-detail-sheet .detail-srcdst { display: none !important; }
|
||||
/* Hide boxed "decoded message" card on mobile — content already in
|
||||
* the sticky header's semantic summary */
|
||||
.mobile-detail-sheet .detail-message { display: none !important; }
|
||||
/* Hide PAYLOAD TYPE row (first dt+dd pair — already in header badge) */
|
||||
.mobile-detail-sheet dl.detail-meta > dt:first-child,
|
||||
.mobile-detail-sheet dl.detail-meta > dt:first-child + dd { display: none !important; }
|
||||
/* 2-col grid for label/value rows */
|
||||
.mobile-detail-sheet dl.detail-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 30%) 1fr;
|
||||
gap: 4px 10px;
|
||||
margin: 8px 0 !important;
|
||||
}
|
||||
.mobile-detail-sheet dl.detail-meta dt {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
align-self: center;
|
||||
}
|
||||
.mobile-detail-sheet dl.detail-meta dd {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* #4: sheet structure — kill handle, sticky header inside sheet, clear
|
||||
* bottom-nav (58px tall) so last row doesn't get cut off, taller max */
|
||||
/* Restructure: outer sheet has fixed height + overflow:hidden; the
|
||||
* .mobile-sheet-content is the ONLY scroll region. ✕ close stays sticky
|
||||
* pinned at top of the sheet. Title also stays sticky within the
|
||||
* scroll region. */
|
||||
.mobile-detail-sheet {
|
||||
max-height: calc(100vh - 110px) !important; /* leave room for bottom nav */
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mobile-detail-sheet.open { display: flex !important; }
|
||||
.mobile-sheet-handle { display: none !important; } /* conflicts with browser pull-to-refresh */
|
||||
.mobile-sheet-close {
|
||||
position: absolute !important;
|
||||
top: 6px !important;
|
||||
right: 10px !important;
|
||||
background: var(--surface-2, rgba(0,0,0,0.6)) !important;
|
||||
border-radius: 999px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px !important;
|
||||
z-index: 20 !important;
|
||||
border: 1px solid var(--border, #444) !important;
|
||||
}
|
||||
.mobile-sheet-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 12px 60px !important;
|
||||
/* visible scrollbar so operator sees there's more */
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.mobile-sheet-content::-webkit-scrollbar { width: 6px; }
|
||||
.mobile-sheet-content::-webkit-scrollbar-thumb { background: var(--border, #555); border-radius: 3px; }
|
||||
/* Sticky in-sheet header for packet identity */
|
||||
.mobile-detail-sheet .detail-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--detail-bg);
|
||||
z-index: 5;
|
||||
padding: 6px 36px 8px 0;
|
||||
margin: 0 0 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
.data-table td.col-details { white-space: normal; word-break: break-word; }
|
||||
.data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; }
|
||||
.data-table .col-time { min-width: clamp(72px, 8vw, 108px); white-space: nowrap; }
|
||||
|
||||
@@ -1170,14 +975,7 @@ body.scroll-locked { overflow: hidden; }
|
||||
.detail-title {
|
||||
font-size: 15px; font-weight: 700; margin-bottom: 12px;
|
||||
padding-bottom: 8px; border-bottom: 2px solid var(--border);
|
||||
/* #1458 P0-A: semantic-first header — type badge + summary + hops share a row. */
|
||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||
}
|
||||
.detail-title .detail-summary { color: var(--text-muted); font-weight: 400; }
|
||||
.detail-srcdst {
|
||||
font-size: 14px; margin: -4px 0 12px; color: var(--text);
|
||||
}
|
||||
.detail-srcdst .arrow { color: var(--text-muted); margin: 0 4px; }
|
||||
.detail-hash {
|
||||
font-family: var(--mono); font-size: 12px; color: var(--text-muted);
|
||||
margin-bottom: 12px; word-break: break-all;
|
||||
@@ -1884,14 +1682,8 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
/* "More" button (hidden on desktop) */
|
||||
.nav-more-wrap { display: none; position: relative; }
|
||||
.nav-more-btn { display: inline-flex; }
|
||||
/* #1406: position:fixed (not absolute) so the dropdown escapes .nav-more-wrap's
|
||||
layout box. When absolute, the dropdown's content extents inflated
|
||||
.nav-more-wrap.scrollHeight → bubbled into .nav-left flex line-height calc
|
||||
→ centered a 279px content line in 52px container → entire nav strip clipped
|
||||
above viewport. position:fixed removes the dropdown from flow entirely; JS in
|
||||
app.js positions top/right dynamically relative to the More button. */
|
||||
.nav-more-menu {
|
||||
display: none; position: fixed; right: 0; top: 0;
|
||||
display: none; position: absolute; top: calc(var(--top-nav-h, 52px) - 4px); right: 0;
|
||||
background: var(--nav-bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15); flex-direction: column;
|
||||
min-width: 160px; padding: 4px 0; z-index: 1200;
|
||||
@@ -1911,9 +1703,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
@media (max-width: 900px) {
|
||||
.panel-right { width: 320px; min-width: 320px; }
|
||||
.nav-stats { display: none; }
|
||||
/* #1450 — same svg/img split at tablet width */
|
||||
svg.brand-logo { height: 32px; width: 112px; }
|
||||
img.brand-logo { height: 32px; width: auto; max-width: 180px; }
|
||||
.brand-logo { height: 32px; width: 112px; }
|
||||
.nav-link { padding: 14px 8px; font-size: 13px; }
|
||||
.map-controls { width: 180px; font-size: 12px; }
|
||||
}
|
||||
@@ -1923,10 +1713,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
clips when squeezed into the 99px tablet pin (#1137 → SCOPE→SCOF).
|
||||
Swap to the dedicated .brand-mark-only inline SVG (arcs+dots only). */
|
||||
@media (max-width: 400px) {
|
||||
/* #1450 — must match the svg/img specificity used at top-level
|
||||
(svg.brand-logo / img.brand-logo, 0,1,1) or the new rules win
|
||||
and the wordmark fails to hide on narrow mobile widths. */
|
||||
svg.brand-logo, img.brand-logo { display: none; }
|
||||
.brand-logo { display: none; }
|
||||
.brand-mark-only { display: block; }
|
||||
}
|
||||
|
||||
@@ -2010,9 +1797,6 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
.nav-links a:not([data-priority="high"]) { display: flex; }
|
||||
.nav-links.open { display: flex; }
|
||||
.nav-link { padding: 12px 20px; border-bottom: none; }
|
||||
/* Issue #1400: restore 48px touch-target on mobile only. Global rule
|
||||
removed because it overflowed the 52px desktop top-nav. */
|
||||
.nav-link { min-height: 48px; }
|
||||
.nav-link.active { background: var(--nav-active-bg); border-radius: 0; margin: 0; padding: 12px 20px; }
|
||||
.nav-left { gap: 12px; }
|
||||
body.nav-open { overflow: hidden; }
|
||||
@@ -2407,26 +2191,6 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.hash-cell-taken { background: var(--status-green); color: #fff; }
|
||||
.hash-cell-possible { background: var(--status-yellow); color: #fff; }
|
||||
.hash-cell-collision { color: #fff; }
|
||||
/* #1473 — First-byte 0x00 / 0xFF pubkeys are avoided by the MeshCore
|
||||
firmware keygen convention (NOT a protocol-level rejection):
|
||||
https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83
|
||||
Visually grey + strike, kill pointer events so the matrix click handler
|
||||
skips them. Applied by public/prefix-reserved.js → markReservedCells(). */
|
||||
.hash-cell.prefix-reserved,
|
||||
.prefix-reserved {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--card-bg),
|
||||
var(--card-bg) 4px,
|
||||
var(--border) 4px,
|
||||
var(--border) 8px
|
||||
) !important;
|
||||
color: var(--text-muted) !important;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hash-matrix-tooltip {
|
||||
position: fixed; z-index: var(--z-tooltip); background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); padding: 8px 12px;
|
||||
@@ -2669,36 +2433,8 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
@media (max-width: 480px) {
|
||||
.subpath-detail { min-width: 100%; width: 100%; max-width: 100%; }
|
||||
.subpath-layout { flex-direction: column; }
|
||||
|
||||
/* #1415 mobile chrome compaction — keep packets pre-table chrome to ≤2 rows.
|
||||
* Hide BYOP (operator request) to reclaim ~70px from the page-header row,
|
||||
* shrink the page-header h2, and zero its bottom margin. The filter bar
|
||||
* already collapses behind a "Filters ▾" toggle (see existing block above). */
|
||||
.page-header [data-action="pkt-byop"] { display: none; }
|
||||
.page-header { margin-bottom: 6px; }
|
||||
.page-header h2 { font-size: 15px; }
|
||||
.data-table td, .data-table th { padding: 6px 4px; }
|
||||
|
||||
/* #1458 P0-C — collapse the always-on filter expression input on small mobile.
|
||||
* Reveals when the sibling .filter-bar has .filters-expanded (set by the
|
||||
* existing "Filters ▾" toggle, so one tap reveals BOTH chrome rows). */
|
||||
.pkt-filter-expr { display: none; }
|
||||
#pktLeft:has(.filter-bar.filters-expanded) .pkt-filter-expr { display: block; }
|
||||
|
||||
/* #1458 P0-A — semantic-first detail title: keep it dense, single-row when
|
||||
* possible, badges + summary share a line. */
|
||||
.detail-title { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 15px; }
|
||||
.detail-srcdst { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
||||
}
|
||||
|
||||
/* #1458 P0-B — raw-bytes disclosure styling (applies everywhere). */
|
||||
.detail-technical { margin-top: 12px; }
|
||||
.detail-technical > summary {
|
||||
cursor: pointer; padding: 6px 0; font-weight: 600; color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
.detail-technical[open] > summary { color: var(--text); }
|
||||
|
||||
/* Legend swatches */
|
||||
.legend-swatch { display: inline-block; width: 12px; height: 12px; border: 1px solid var(--border); vertical-align: middle; }
|
||||
|
||||
@@ -3685,16 +3421,6 @@ th.sort-active { color: var(--accent, #60a5fa); }
|
||||
--mc-role-sensor: #F0E442; /* yellow */
|
||||
--mc-role-observer: #CC79A7; /* reddish-purple */
|
||||
|
||||
/* #1407 — per-role text colors paired with each --mc-role-X bg so
|
||||
* .mc-pill (and any badge using the var) hits WCAG 1.4.3 AA against
|
||||
* the active preset. Wong defaults all clear 4.5:1 with dark text;
|
||||
* preset overrides live in body[data-cb-preset="..."] blocks below. */
|
||||
--mc-role-repeater-text: #1a1a1a;
|
||||
--mc-role-companion-text: #1a1a1a;
|
||||
--mc-role-room-text: #1a1a1a;
|
||||
--mc-role-sensor-text: #1a1a1a;
|
||||
--mc-role-observer-text: #1a1a1a;
|
||||
|
||||
/* V3 — multi-byte hash labels (neutral fill + high-luminance accent stripes) */
|
||||
--mc-mb-fill: rgba(33, 41, 54, 0.92);
|
||||
--mc-mb-text: #ffffff;
|
||||
@@ -3717,12 +3443,16 @@ th.sort-active { color: var(--accent, #60a5fa); }
|
||||
* truth so a regression that drops one is still caught by the other
|
||||
* (mirrors the #1356 "pill color: defense-in-depth via CSS + inline" pattern).
|
||||
* ─────────────────────────────────────────────────────────────────────── */
|
||||
/* #1446 — the body data-cb-preset rule for the default (Wong) palette is
|
||||
* intentionally OMITTED. Wong is the :root baseline; selecting "default"
|
||||
* from the customizer is identical to "no preset". Writing the Wong
|
||||
* palette here would mask the server config's nodeColors (see #1446
|
||||
* cascade). Wong values remain available via :root above.
|
||||
*/
|
||||
body[data-cb-preset="default"] {
|
||||
--mc-role-repeater: #D55E00;
|
||||
--mc-role-companion: #56B4E9;
|
||||
--mc-role-room: #009E73;
|
||||
--mc-role-sensor: #F0E442;
|
||||
--mc-role-observer: #CC79A7;
|
||||
--mc-mb-confirmed: #56F0A0;
|
||||
--mc-mb-suspected: #FFD966;
|
||||
--mc-mb-unknown: #FF8888;
|
||||
}
|
||||
body[data-cb-preset="deut"] {
|
||||
/* IBM 5-class deut variant — anchors shifted out of red/green collision. */
|
||||
--mc-role-repeater: #FE6100;
|
||||
@@ -3730,12 +3460,6 @@ body[data-cb-preset="deut"] {
|
||||
--mc-role-room: #785EF0;
|
||||
--mc-role-sensor: #FFB000;
|
||||
--mc-role-observer: #DC267F;
|
||||
/* #1407 — room/observer fail with dark text (3.86/3.83); flip to white. */
|
||||
--mc-role-repeater-text: #1a1a1a;
|
||||
--mc-role-companion-text: #1a1a1a;
|
||||
--mc-role-room-text: #ffffff;
|
||||
--mc-role-sensor-text: #1a1a1a;
|
||||
--mc-role-observer-text: #ffffff;
|
||||
--mc-mb-confirmed: #648FFF;
|
||||
--mc-mb-suspected: #FFB000;
|
||||
--mc-mb-unknown: #DC267F;
|
||||
@@ -3747,12 +3471,6 @@ body[data-cb-preset="prot"] {
|
||||
--mc-role-room: #785EF0;
|
||||
--mc-role-sensor: #FE6100;
|
||||
--mc-role-observer: #DC267F;
|
||||
/* #1407 — same room/observer flip as deut. */
|
||||
--mc-role-repeater-text: #1a1a1a;
|
||||
--mc-role-companion-text: #1a1a1a;
|
||||
--mc-role-room-text: #ffffff;
|
||||
--mc-role-sensor-text: #1a1a1a;
|
||||
--mc-role-observer-text: #ffffff;
|
||||
--mc-mb-confirmed: #648FFF;
|
||||
--mc-mb-suspected: #FFB000;
|
||||
--mc-mb-unknown: #DC267F;
|
||||
@@ -3764,12 +3482,6 @@ body[data-cb-preset="trit"] {
|
||||
--mc-role-room: #882255;
|
||||
--mc-role-sensor: #DDCC77;
|
||||
--mc-role-observer: #AA4499;
|
||||
/* #1407 — companion / room / observer fail AA with dark text; flip to white. */
|
||||
--mc-role-repeater-text: #1a1a1a; /* 5.73:1 */
|
||||
--mc-role-companion-text: #ffffff; /* 5.66:1 */
|
||||
--mc-role-room-text: #ffffff; /* 8.71:1 */
|
||||
--mc-role-sensor-text: #1a1a1a; /* 12.98:1 */
|
||||
--mc-role-observer-text: #ffffff; /* 5.25:1 */
|
||||
--mc-mb-confirmed: #117733;
|
||||
--mc-mb-suspected: #DDCC77;
|
||||
--mc-mb-unknown: #CC6677;
|
||||
@@ -3781,15 +3493,6 @@ body[data-cb-preset="achromat"] {
|
||||
--mc-role-room: #808080;
|
||||
--mc-role-sensor: #b3b3b3;
|
||||
--mc-role-observer: #e6e6e6;
|
||||
/* #1407 — original bug: dark text on all 5 grays failed AA on 3 of 5.
|
||||
* Pair each gray with the text color that wins ≥4.5:1; L=50% mid-gray
|
||||
* needs pure black (#1a1a1a yields 4.41, #fff yields 3.95 — neither
|
||||
* passes; black yields 5.32). */
|
||||
--mc-role-repeater-text: #ffffff; /* 12.63:1 */
|
||||
--mc-role-companion-text: #ffffff; /* 7.00:1 */
|
||||
--mc-role-room-text: #000000; /* 5.32:1 */
|
||||
--mc-role-sensor-text: #1a1a1a; /* 8.30:1 */
|
||||
--mc-role-observer-text: #1a1a1a; /* 13.94:1 */
|
||||
--mc-mb-confirmed: #b3b3b3;
|
||||
--mc-mb-suspected: #808080;
|
||||
--mc-mb-unknown: #595959;
|
||||
@@ -3828,32 +3531,19 @@ body[data-cb-preset="achromat"] {
|
||||
`text-overflow:ellipsis` stay as belt-only graceful-degrade if the
|
||||
JS cap is ever bypassed. */
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
/* #1407 — pill text color now reads --mc-pill-text (set per-pill via the
|
||||
role-* selectors below), with a #1a1a1a fallback for any caller that
|
||||
forgets to set the role class. This replaces the hardcoded `#1a1a1a`
|
||||
that broke WCAG 1.4.3 AA on the achromat preset for 3 of 5 grays.
|
||||
The Wong (#1357) audit conclusion still holds for the default preset
|
||||
because --mc-role-X-text defaults to #1a1a1a in :root.
|
||||
/* Audit: bump 9px → 10px, monospace, dark text on every Wong hue.
|
||||
#1a1a1a on all 5 Wong hues passes SC 1.4.3 small-text (≥4.5:1).
|
||||
Sized in rem (0.625rem = 10px @ default 16px root) so user
|
||||
font-size preferences scale the pill (SC 1.4.4 Resize Text 200%). */
|
||||
font: 700 0.625rem/1.1 ui-monospace, "SF Mono", Consolas, monospace;
|
||||
letter-spacing: 0;
|
||||
color: var(--mc-pill-text, #1a1a1a); text-align: center; text-shadow: none;
|
||||
color: #1a1a1a; text-align: center; text-shadow: none;
|
||||
border: 1px solid rgba(0,0,0,0.25);
|
||||
/* #1360: overflow:hidden + text-overflow:ellipsis above bound the pill
|
||||
when counts approach the 4-char cap ("999+"). Acceptable tradeoff vs.
|
||||
SC 1.4.12 letter-spacing clipping: text content is the role letter +
|
||||
<=4 digits, far short of needing aggressive letter-spacing overrides. */
|
||||
}
|
||||
/* #1407 — per-role pill text color, sourced from --mc-role-*-text vars set
|
||||
* by cb-presets.js applyPreset() (and the body[data-cb-preset=...] CSS
|
||||
* blocks above). The role-<r> class is emitted by makeClusterIcon in
|
||||
* public/map.js, so each pill picks the correct foreground for its bg. */
|
||||
.mc-cluster .mc-pill.role-repeater { color: var(--mc-role-repeater-text, #1a1a1a); }
|
||||
.mc-cluster .mc-pill.role-companion { color: var(--mc-role-companion-text, #1a1a1a); }
|
||||
.mc-cluster .mc-pill.role-room { color: var(--mc-role-room-text, #1a1a1a); }
|
||||
.mc-cluster .mc-pill.role-sensor { color: var(--mc-role-sensor-text, #1a1a1a); }
|
||||
.mc-cluster .mc-pill.role-observer { color: var(--mc-role-observer-text, #1a1a1a); }
|
||||
|
||||
/* V3 — multi-byte hash labels: neutral fill + colored 3px left border */
|
||||
.mc-mb-label {
|
||||
@@ -4243,22 +3933,16 @@ body { touch-action: pan-y; }
|
||||
.row-swiping,
|
||||
.row-action-overlay { transition: none !important; }
|
||||
}
|
||||
/* === end #1062 ====================================================== */
|
||||
/* === end #1062 ====================================================== */ (feat(#1062): green — implement gesture system)
|
||||
|
||||
/* === Issue #1065 — Gesture discoverability hints =================== */
|
||||
/* width:fit-content makes the pill shrink to its content; without it
|
||||
* the wrap defaults to block (100% of body width). Combined with
|
||||
* .gesture-hint-bottom { left:50%; transform:translateX(-50%) } that
|
||||
* pushed the wrap off-screen-left on narrow viewports (#1065 follow-up).
|
||||
* Comments live ABOVE the rule body, not interleaved, because a stray
|
||||
* em-dash or sequence inside the body has historically caused some CSS
|
||||
* parsers to drop the entire rule from the CSSOM. */
|
||||
.gesture-hint {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
max-width: 360px;
|
||||
/* !important guards against any layered cascade enabling pointer-events
|
||||
* (e.g., a future overlay class with higher specificity). The hint
|
||||
* wrapper MUST never capture clicks — only its inner button does. */
|
||||
pointer-events: none !important;
|
||||
opacity: 1;
|
||||
animation-name: gesture-hint-slide-in;
|
||||
|
||||
-23
@@ -12,7 +12,6 @@ echo "── Unit Tests ──"
|
||||
node test-packet-filter.js
|
||||
node test-packet-filter-ux.js
|
||||
node test-aging.js
|
||||
node test-issue-1065-gesture-hints-gates.js
|
||||
node test-frontend-helpers.js
|
||||
node test-url-state.js
|
||||
node test-perf-go-runtime.js
|
||||
@@ -24,33 +23,11 @@ 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-marker-outline-weight.js
|
||||
node test-traces.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-1438-marker-css-vars.js
|
||||
node test-issue-1438-customizer-mcrole.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-node-tile-helper.js
|
||||
node test-issue-1473-reserved-prefixes.js
|
||||
node test-issue-1473-prefix-generator.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " All tests passed"
|
||||
|
||||
@@ -162,54 +162,27 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
});
|
||||
|
||||
await step('outside click closes popover', async () => {
|
||||
// De-flake history: #1317 (62a81776) tried `mouse.click(700,500)` + a
|
||||
// `rect.width > 0` "listener installed" proxy. That proxy is FALSE — it
|
||||
// only proves the popover is visible, not that showPopover's
|
||||
// `setTimeout(0)` document-level click listener has actually run. Under
|
||||
// CI load the macrotask can be deferred past Playwright's polling
|
||||
// resolution, so the synthetic click fires BEFORE the listener exists,
|
||||
// is dropped, and the popover never hides → 8s default-timeout failure
|
||||
// (see run 26574358472 / d24246395 master push).
|
||||
//
|
||||
// Real fix: (1) install a one-shot probe of our own via
|
||||
// `requestAnimationFrame + setTimeout(0)` and `await` it from
|
||||
// node-side, guaranteeing showPopover's setTimeout(0) drained;
|
||||
// (2) retry the click in a small loop, since even with the probe
|
||||
// there's no synchronous handle on Playwright's internal event-loop
|
||||
// ordering. Each click is cheap (~ms); the popover hides on the first
|
||||
// one that reaches the installed listener.
|
||||
await page.evaluate(() =>
|
||||
window.ChannelColorPicker.show('#outsidechan', 100, 100));
|
||||
await page.waitForSelector('.cc-picker-popover', { state: 'visible', timeout: 5000 });
|
||||
// Drain pending macrotasks (showPopover's setTimeout(0) installs the
|
||||
// outside-click listener). Wait two animation frames + a setTimeout(0)
|
||||
// so the same scheduler tier the listener uses has definitely run.
|
||||
await page.evaluate(() => new Promise((r) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() =>
|
||||
setTimeout(r, 0)));
|
||||
}));
|
||||
// Click outside in a retry loop — if the very first synthetic click
|
||||
// still races the listener install, subsequent clicks land cleanly.
|
||||
// Popover anchored at (100,100); click at (700,500) is unambiguously
|
||||
// outside its bounding rect (popover is ~300×80).
|
||||
const closed = await (async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.mouse.click(700, 500);
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display === 'none';
|
||||
}, { timeout: 1000 });
|
||||
return true;
|
||||
} catch (_) {
|
||||
// Re-check listener install by waiting another rAF and retrying.
|
||||
await page.evaluate(() => new Promise((r) =>
|
||||
requestAnimationFrame(() => setTimeout(r, 0))));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
assert(closed, 'popover did not close after 10 outside-click attempts');
|
||||
await page.waitForSelector('.cc-picker-popover');
|
||||
// Wait for the deferred (setTimeout 0) document-level click listener
|
||||
// to be installed before dispatching the outside click. Otherwise the
|
||||
// click races the listener registration and the popover stays open.
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
const rect = el && el.getBoundingClientRect();
|
||||
return rect && rect.width > 0 && rect.height > 0;
|
||||
}, { timeout: 5000 });
|
||||
// Real mouse click at a viewport coordinate that is clearly outside
|
||||
// the popover (popover anchored at 100,100; click at 700,500).
|
||||
// page.mouse.click dispatches PointerEvent + MouseEvent with real
|
||||
// coords, more representative than HTMLElement.click() and reliably
|
||||
// reaches the document-level capture-phase listener.
|
||||
await page.mouse.click(700, 500);
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.cc-picker-popover');
|
||||
return el && el.style.display === 'none';
|
||||
}, { timeout: 15000 });
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
|
||||
@@ -2071,89 +2071,6 @@ async function run() {
|
||||
|
||||
// ─── End mobile filter tests ──────────────────────────────────────────────
|
||||
|
||||
// ─── #1468 — drop client-side "unknown" channel synthesis ────────────────
|
||||
|
||||
await test('#1468: live WS CHAN message with no payload.channel is dropped (no "unknown" bucket)', async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
|
||||
// Wait for the channels init() to mount and expose the test hook.
|
||||
await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 });
|
||||
|
||||
// Snapshot starting state so we can compare deltas.
|
||||
const before = await page.evaluate(() => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') };
|
||||
});
|
||||
|
||||
// Feed a CHAN-like message with NO payload.channel field (but valid hash).
|
||||
await page.evaluate(() => {
|
||||
window._channelsProcessWSBatchForTest([
|
||||
{
|
||||
type: 'packet',
|
||||
data: {
|
||||
hash: 'test1468drophash' + Date.now(),
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'GRP_TXT' },
|
||||
payload: { /* no `channel` */ text: 'orphan: hello' },
|
||||
},
|
||||
},
|
||||
},
|
||||
], null);
|
||||
});
|
||||
|
||||
const after = await page.evaluate(() => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') };
|
||||
});
|
||||
|
||||
// No "unknown" channel materialized.
|
||||
assert(!after.names.includes('unknown'),
|
||||
'channels list does not contain a synthesized "unknown" entry — got ' + JSON.stringify(after.names));
|
||||
// And the channel-count delta is 0 — the orphan message was dropped, not bucketed.
|
||||
assert(after.count === before.count,
|
||||
`channel count unchanged after orphan WS msg — before=${before.count}, after=${after.count}`);
|
||||
});
|
||||
|
||||
await test('#1468 control: same WS message WITH payload.channel is still routed', async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 });
|
||||
|
||||
const sentinel = '__test_chan_1468_' + Date.now();
|
||||
const before = await page.evaluate((name) => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return { hasSentinel: s.channels.some(c => (c.name || c.channel) === name) };
|
||||
}, sentinel);
|
||||
assert(!before.hasSentinel, 'pre: sentinel channel does not pre-exist');
|
||||
|
||||
await page.evaluate((name) => {
|
||||
window._channelsProcessWSBatchForTest([
|
||||
{
|
||||
type: 'packet',
|
||||
data: {
|
||||
hash: 'test1468hash' + Date.now(),
|
||||
decoded: {
|
||||
header: { payloadTypeName: 'GRP_TXT' },
|
||||
payload: { channel: name, text: 'alice: hi', sender: 'alice' },
|
||||
},
|
||||
},
|
||||
},
|
||||
], null);
|
||||
}, sentinel);
|
||||
|
||||
const after = await page.evaluate((name) => {
|
||||
const s = window._channelsGetStateForTest();
|
||||
return {
|
||||
hasSentinel: s.channels.some(c => (c.name || c.channel) === name),
|
||||
names: s.channels.map(c => c.name || c.channel || ''),
|
||||
};
|
||||
}, sentinel);
|
||||
assert(after.hasSentinel,
|
||||
'control: channel WITH payload.channel IS routed into the registry — got ' + JSON.stringify(after.names));
|
||||
});
|
||||
|
||||
// ─── End #1468 tests ──────────────────────────────────────────────────────
|
||||
|
||||
// Extract frontend coverage if instrumented server is running
|
||||
try {
|
||||
const coverage = await page.evaluate(() => window.__coverage__);
|
||||
|
||||
@@ -208,11 +208,11 @@ async function main() {
|
||||
|
||||
await ctx.close();
|
||||
|
||||
// ── (e) at 1024x800 with touch, edge-swipe hint visible on first visit ──
|
||||
// #1065 follow-up: edge-swipe is a touch gesture; the hint must only
|
||||
// appear when the viewport reports touch capability. Test context must
|
||||
// pass hasTouch:true (real edge-swipe-on-tablet/touch-laptop scenario).
|
||||
const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 }, hasTouch: true });
|
||||
// ── (e) edge-drawer hint visible on first visit at narrow viewport ──
|
||||
// #1402 Bug 2: edge-swipe drawer (#1064/#1184) is a MOBILE feature; original
|
||||
// code/test had the condition inverted (innerWidth > 768). Corrected: assert
|
||||
// edge-drawer at vw=393 (mobile), NOT at desktop.
|
||||
const ctx2 = await browser.newContext({ viewport: { width: 393, height: 800 }, hasTouch: true });
|
||||
const page2 = await ctx2.newPage();
|
||||
await page2.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page2.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
|
||||
@@ -220,9 +220,9 @@ async function main() {
|
||||
await page2.waitForTimeout(HINT_SETTLE_MS);
|
||||
const edgeHint = await hintVisible(page2, 'edge-drawer');
|
||||
if (edgeHint.present && edgeHint.visible) {
|
||||
pass('(e) edge-drawer hint visible at 1024x800');
|
||||
pass('(e) edge-drawer hint visible at 393x800 (mobile — corrected per #1402)');
|
||||
} else {
|
||||
fail(`(e) edge-drawer hint NOT visible at 1024x800 — state=${JSON.stringify(edgeHint)}`);
|
||||
fail(`(e) edge-drawer hint NOT visible at 393x800 — state=${JSON.stringify(edgeHint)}`);
|
||||
}
|
||||
await ctx2.close();
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1065 follow-up — gesture hints must:
|
||||
* 1. Define a hasTouchCapability() helper that probes ontouchstart,
|
||||
* maxTouchPoints, and (pointer: coarse).
|
||||
* 2. Gate every HINTS[*].relevant() body on hasTouchCapability() at the
|
||||
* very top (no hint should fire on mouse-only viewports).
|
||||
* 3. Ship a .gesture-hint parent CSS rule that includes
|
||||
* `width: fit-content` AND `max-width: 360px` so the pill shrinks to
|
||||
* its content instead of stretching full-bleed and being pushed
|
||||
* off-screen by translateX(-50%) on narrow viewports.
|
||||
*
|
||||
* Pure source-file assertions — no browser required.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const JS_PATH = path.join(__dirname, 'public', 'gesture-hints.js');
|
||||
const CSS_PATH = path.join(__dirname, 'public', 'style.css');
|
||||
|
||||
let failures = 0, passes = 0;
|
||||
const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
|
||||
const pass = (m) => { passes++; console.log(' PASS: ' + m); };
|
||||
|
||||
const js = fs.readFileSync(JS_PATH, 'utf8');
|
||||
const css = fs.readFileSync(CSS_PATH, 'utf8');
|
||||
|
||||
// (1) helper exists and probes the three signals
|
||||
if (/function\s+hasTouchCapability\s*\(/.test(js)) pass('hasTouchCapability() defined');
|
||||
else fail('hasTouchCapability() not defined in gesture-hints.js');
|
||||
|
||||
if (/ontouchstart/.test(js)) pass('hasTouchCapability probes ontouchstart');
|
||||
else fail('hasTouchCapability missing ontouchstart probe');
|
||||
|
||||
if (/maxTouchPoints/.test(js)) pass('hasTouchCapability probes maxTouchPoints');
|
||||
else fail('hasTouchCapability missing maxTouchPoints probe');
|
||||
|
||||
if (/pointer:\s*coarse/.test(js)) pass('hasTouchCapability probes (pointer: coarse)');
|
||||
else fail('hasTouchCapability missing (pointer: coarse) probe');
|
||||
|
||||
// (2) every relevant() body must start with the touch gate
|
||||
// Find each `relevant: function () { ... }` block and check.
|
||||
const relevantRe = /relevant:\s*function\s*\(\s*\)\s*\{([\s\S]*?)\n\s{6}\}/g;
|
||||
let m, count = 0, gated = 0;
|
||||
while ((m = relevantRe.exec(js)) !== null) {
|
||||
count++;
|
||||
const body = m[1];
|
||||
// First non-comment statement must be hasTouchCapability gate
|
||||
if (/^\s*if\s*\(\s*!\s*hasTouchCapability\s*\(\s*\)\s*\)\s*return\s+false\s*;/m.test(body)) {
|
||||
gated++;
|
||||
}
|
||||
}
|
||||
if (count >= 4) pass(`found ${count} relevant() predicates`);
|
||||
else fail(`expected ≥4 relevant() predicates, found ${count}`);
|
||||
if (gated === count && count > 0) pass(`all ${gated}/${count} relevant() bodies start with !hasTouchCapability() return false`);
|
||||
else fail(`only ${gated}/${count} relevant() bodies gate on hasTouchCapability()`);
|
||||
|
||||
// (3) .gesture-hint parent rule has width: fit-content + max-width: 360px
|
||||
// Locate the rule block starting `.gesture-hint {` (NOT .gesture-hint-...).
|
||||
const ruleRe = /\n\.gesture-hint\s*\{([\s\S]*?)\}/;
|
||||
const ruleMatch = ruleRe.exec(css);
|
||||
if (!ruleMatch) {
|
||||
fail('.gesture-hint parent CSS rule not found in style.css');
|
||||
} else {
|
||||
pass('.gesture-hint parent CSS rule present');
|
||||
const body = ruleMatch[1];
|
||||
if (/\bwidth:\s*fit-content\b/.test(body)) pass('.gesture-hint declares width: fit-content');
|
||||
else fail('.gesture-hint missing width: fit-content (pill must shrink to content)');
|
||||
if (/\bmax-width:\s*360px\b/.test(body)) pass('.gesture-hint declares max-width: 360px');
|
||||
else fail('.gesture-hint missing max-width: 360px');
|
||||
}
|
||||
|
||||
// (4) defensive: no em-dash or stray "*/" inside .gesture-hint rule body
|
||||
if (ruleMatch) {
|
||||
const body = ruleMatch[1];
|
||||
if (/[\u2014\u2013]/.test(body)) fail('em-dash / en-dash inside .gesture-hint rule body (CSS-parse-fragile)');
|
||||
else pass('no em-dash inside .gesture-hint rule body');
|
||||
}
|
||||
|
||||
console.log(`\ntest-issue-1065-gesture-hints-gates.js: ${passes} passed, ${failures} failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
@@ -56,12 +56,8 @@ for (const role of Object.keys(expectedShapes)) {
|
||||
assert(re.test(shapeBlock), `ROLE_SHAPES.${role} === '${expectedShapes[role]}'`);
|
||||
}
|
||||
|
||||
// ROLE_STYLE shape values match the new map.
|
||||
// #1407 refactored ROLE_STYLE into a live getter (over Object.defineProperty)
|
||||
// whose shape data lives in a _styleShapes literal — parse that instead.
|
||||
const styleBlockMatch =
|
||||
rolesSrc.match(/window\.ROLE_STYLE\s*=\s*\{([\s\S]*?)\};/) ||
|
||||
rolesSrc.match(/_styleShapes\s*=\s*\{([\s\S]*?)\};/);
|
||||
// ROLE_STYLE shape values match the new map
|
||||
const styleBlockMatch = rolesSrc.match(/window\.ROLE_STYLE\s*=\s*\{([\s\S]*?)\};/);
|
||||
const styleBlock = styleBlockMatch ? styleBlockMatch[1] : '';
|
||||
for (const role of Object.keys(expectedShapes)) {
|
||||
// crude per-line check
|
||||
|
||||
@@ -68,17 +68,15 @@ assert(pillEmitRe.test(mapSrc) || /ROLE_LETTERS\[role\][\s\S]{0,200}mc-pill/.tes
|
||||
/mc-pill[\s\S]{0,200}ROLE_LETTERS\[role\]/.test(mapSrc),
|
||||
'pill HTML embeds ROLE_LETTERS[role] as the primary content');
|
||||
|
||||
// V2.c — Dark text on ALL five Wong-default pills (audit override of Tufte's
|
||||
// per-pill switch). #1407 generalized this to a per-role text-color CSS var
|
||||
// (--mc-role-X-text) so darker presets (achromat / trit) can pair white text
|
||||
// with darker bgs and still meet WCAG 1.4.3 AA. The Wong DEFAULT still uses
|
||||
// #1a1a1a — encoded as the fallback in `var(--mc-pill-text, #1a1a1a)` AND
|
||||
// on each `var(--mc-role-X-text, #1a1a1a)`, so any regression that drops the
|
||||
// per-role vars still renders dark text on Wong (no theming illusion).
|
||||
assert(/\.mc-pill\b[^{]*\{[^}]*color\s*:\s*var\(\s*--mc-(?:pill|role-[a-z]+)-text\s*,\s*#1a1a1a\s*\)/i.test(cssSrc),
|
||||
'.mc-pill CSS rule sets color: var(--mc-...-text, #1a1a1a) — #1407 generalized #1356\'s authoritative dark default');
|
||||
assert(/class="mc-pill[^"]*"[^>]*style="[^"]*color:(?:\s*#1a1a1a|'\s*\+\s*fg\b|\s*var\(--mc-role-[a-z]+-text)/i.test(mapSrc),
|
||||
'.mc-pill render-site emits inline color (#1a1a1a, "+ fg +", or var(--mc-role-X-text, #1a1a1a)) — defense-in-depth for divIcon (#1407)');
|
||||
// V2.c — Dark text on ALL five pills (audit override of Tufte's per-pill switch).
|
||||
// Require the CSS rule `.mc-pill { color: #1a1a1a }` (authoritative).
|
||||
// The inline-style fallback alone is NOT enough: a regression that drops the
|
||||
// CSS rule but keeps a stray inline style would still green, masking the
|
||||
// theming-illusion bug (round-1 adversarial #5 short-circuit).
|
||||
assert(/\.mc-pill\b[^{]*\{[^}]*color\s*:\s*#1a1a1a/i.test(cssSrc),
|
||||
'.mc-pill CSS rule sets color #1a1a1a (authoritative, not just inline-style fallback)');
|
||||
assert(/class="mc-pill[^"]*"[^>]*style="[^"]*color:\s*#1a1a1a/i.test(mapSrc),
|
||||
'.mc-pill render-site also emits inline color #1a1a1a (defense-in-depth for divIcon)');
|
||||
|
||||
// V2.d — font-size ≥ 10px (audit bumped from 9px).
|
||||
const pillFontMatch = cssSrc.match(/\.mc-pill\b[^{]*\{[^}]*font[^;]*;/);
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1400 — root cause of recurring nav-vanishing class of bugs.
|
||||
*
|
||||
* Symptom: at desktop viewports (1024..1711), the `.nav-links` strip
|
||||
* rendered at NEGATIVE y (operator probe: y=-57, height=56), entirely
|
||||
* above the visible 0..52 band of `.top-nav` which has `overflow:hidden`.
|
||||
*
|
||||
* Root cause: PR #1060 (commit eaf14a61) added a global
|
||||
* .nav-link { min-height: 48px; display:inline-flex; align-items:center; }
|
||||
* The 48px link + padding inflated `.nav-links` to 56px tall inside a 52px
|
||||
* `.top-nav` with `overflow:hidden`. With `align-items: center`, Firefox
|
||||
* centers the over-tall flex item at a negative y → strip clipped above
|
||||
* viewport.
|
||||
*
|
||||
* Acceptance (from #1400):
|
||||
* - Desktop: `.nav-links` rect.y >= 0 AND every `.nav-links > a` is
|
||||
* vertically inside the visible top-nav band (y >= 0 AND y+height <= 60).
|
||||
* - Mobile (<768px): touch-target preserved — `.nav-link` min-height
|
||||
* computed style >= 48px (regression guard for #1060).
|
||||
*
|
||||
* Mutation guard: re-adding `min-height: 48px` to global `.nav-link`
|
||||
* must make this test fail with negative y at desktop widths.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
const DESKTOP_WIDTHS = [1024, 1366, 1711];
|
||||
const MOBILE_WIDTH = 480;
|
||||
const HEIGHT = 800;
|
||||
const TOPNAV_HEIGHT_MAX = 60; // 52px nominal + a few px slack
|
||||
|
||||
async function settleNav(page) {
|
||||
await page.waitForSelector('.top-nav .nav-links');
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.top-nav .nav-links');
|
||||
if (!el) return false;
|
||||
const r1 = el.getBoundingClientRect();
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const r2 = el.getBoundingClientRect();
|
||||
resolve(r1.top === r2.top && r1.height === r2.height);
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
} catch (err) {
|
||||
if (process.env.CHROMIUM_REQUIRE === '1') {
|
||||
console.error(`test-issue-1400-nav-vertical-clip.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-issue-1400-nav-vertical-clip.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
// === Desktop: vertical clip guard ===
|
||||
for (const w of DESKTOP_WIDTHS) {
|
||||
await page.setViewportSize({ width: w, height: HEIGHT });
|
||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
|
||||
await settleNav(page);
|
||||
|
||||
const probe = await page.evaluate(() => {
|
||||
const nav = document.querySelector('.top-nav');
|
||||
const links = document.querySelector('.nav-links');
|
||||
const anchors = Array.from(document.querySelectorAll('.nav-links > a'));
|
||||
const r = (el) => {
|
||||
if (!el) return null;
|
||||
const b = el.getBoundingClientRect();
|
||||
return { y: b.y, height: b.height, bottom: b.y + b.height };
|
||||
};
|
||||
return {
|
||||
nav: r(nav),
|
||||
links: r(links),
|
||||
anchors: anchors.map((a) => ({ href: a.getAttribute('href'), ...r(a) })),
|
||||
};
|
||||
});
|
||||
|
||||
const tag = `vw=${w}`;
|
||||
if (!probe.links) {
|
||||
console.error(`FAIL ${tag}: .nav-links not found`);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
assert.ok(
|
||||
probe.links.y >= 0,
|
||||
`${tag}: .nav-links y=${probe.links.y} must be >= 0 (issue #1400 root-cause regression: clipped above viewport)`,
|
||||
);
|
||||
assert.ok(
|
||||
probe.anchors.length > 0,
|
||||
`${tag}: expected >=1 .nav-links > a, got 0`,
|
||||
);
|
||||
for (const a of probe.anchors) {
|
||||
assert.ok(
|
||||
a.y >= 0,
|
||||
`${tag}: nav-link href=${a.href} y=${a.y} must be >= 0`,
|
||||
);
|
||||
assert.ok(
|
||||
a.bottom <= TOPNAV_HEIGHT_MAX,
|
||||
`${tag}: nav-link href=${a.href} bottom=${a.bottom} must be <= ${TOPNAV_HEIGHT_MAX} (overflowing 52px top-nav)`,
|
||||
);
|
||||
}
|
||||
console.log(`PASS ${tag}: .nav-links y=${probe.links.y.toFixed(1)} h=${probe.links.height.toFixed(1)}; ${probe.anchors.length} anchors all inside top-nav band`);
|
||||
passes++;
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${tag}: ${err.message}`);
|
||||
console.error(` probe: ${JSON.stringify(probe)}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
// === Mobile: touch-target preserved (#1060 regression guard) ===
|
||||
await page.setViewportSize({ width: MOBILE_WIDTH, height: HEIGHT });
|
||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
|
||||
// open hamburger so .nav-link is rendered (display:none otherwise on mobile until .open)
|
||||
await page.evaluate(() => {
|
||||
const links = document.querySelector('.nav-links');
|
||||
if (links) links.classList.add('open');
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
const mobileProbe = await page.evaluate(() => {
|
||||
const anchors = Array.from(document.querySelectorAll('.nav-links > a'));
|
||||
return anchors.slice(0, 3).map((a) => {
|
||||
const cs = getComputedStyle(a);
|
||||
return { href: a.getAttribute('href'), minHeight: parseFloat(cs.minHeight) || 0 };
|
||||
});
|
||||
});
|
||||
|
||||
const tag = `vw=${MOBILE_WIDTH}`;
|
||||
try {
|
||||
assert.ok(mobileProbe.length > 0, `${tag}: expected mobile nav-links anchors, got 0`);
|
||||
for (const a of mobileProbe) {
|
||||
assert.ok(
|
||||
a.minHeight >= 48,
|
||||
`${tag}: nav-link href=${a.href} min-height=${a.minHeight} must be >= 48 (touch-target regression of #1060)`,
|
||||
);
|
||||
}
|
||||
console.log(`PASS ${tag}: mobile .nav-link min-height >= 48 (touch-target preserved per #1060)`);
|
||||
passes++;
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${tag}: ${err.message}`);
|
||||
console.error(` probe: ${JSON.stringify(mobileProbe)}`);
|
||||
failures++;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\ntest-issue-1400-nav-vertical-clip.js: ${passes} passed, ${failures} failed`);
|
||||
if (failures > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('test-issue-1400-nav-vertical-clip.js: ERROR', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1402 — Gesture-hint regressions on iPhone-class mobile.
|
||||
*
|
||||
* Per issue body, vw=393, /#/home, console probe at deploy:
|
||||
* bottomNav: true, navDrawer: true, pullEl: false, storedKeys: []
|
||||
*
|
||||
* Asserts (gates the 4 fixes):
|
||||
* (1) vw=393 /#/home → tab-swipe hint renders within 1500ms (Bug 1)
|
||||
* (2) vw=393 /#/home → edge-drawer hint renders within 1500ms (Bug 2 — currently
|
||||
* inverted: code says innerWidth > 768)
|
||||
* (3) vw=393 /#/home → pull-refresh hint renders within 1500ms (Bug 3 — currently
|
||||
* requires .pull-to-reconnect in DOM, which only exists on WS-disconnect)
|
||||
* (4) vw=393 /#/channels and /#/observers → row-swipe hint renders (Bug 4 — currently
|
||||
* scoped to /packets|/nodes only)
|
||||
* (5) vw=1024 /#/home → edge-drawer hint does NOT render (mobile-only per fix)
|
||||
* (6) auto-fade does NOT mark seen for tab-swipe; explicit dismiss DOES
|
||||
* (regression guard on the dismissal flow under the new render conditions)
|
||||
* (7) FIRST-LOAD path: vw=393 /#/home, fresh page (no hashchange fired), hints render.
|
||||
* Bug confirmed via operator console trace: hints_in_dom=0 on initial load
|
||||
* but hints_appended_in_2s=[row-swipe,tab-swipe] after a hashchange.
|
||||
* Asserts the schedule path runs without needing a hashchange.
|
||||
* (8) HASHCHANGE path: after first load, navigate to a different route — hints
|
||||
* relevant for the new route render. Validates _routeChangeBound still works.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
const HINT_SETTLE_MS = 1700; // SHOW_DELAY_MS (800) + margin
|
||||
|
||||
const KEYS = {
|
||||
rowSwipe: 'meshcore-gesture-hints-row-swipe',
|
||||
tabSwipe: 'meshcore-gesture-hints-tab-swipe',
|
||||
edgeDrawer: 'meshcore-gesture-hints-edge-drawer',
|
||||
pullRefresh: 'meshcore-gesture-hints-pull-refresh',
|
||||
};
|
||||
|
||||
async function clearAllHintFlags(page) {
|
||||
await page.evaluate((keys) => {
|
||||
Object.values(keys).forEach((k) => localStorage.removeItem(k));
|
||||
}, KEYS);
|
||||
}
|
||||
|
||||
async function hintVisible(page, hintId) {
|
||||
return page.evaluate((id) => {
|
||||
const el = document.querySelector('[data-gesture-hint="' + id + '"]');
|
||||
if (!el) return { present: false };
|
||||
const cs = getComputedStyle(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
present: true,
|
||||
visible: cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity || '1') > 0.01 && r.width > 0 && r.height > 0,
|
||||
};
|
||||
}, hintId);
|
||||
}
|
||||
|
||||
async function freshContext(browser, viewport, hasTouch) {
|
||||
return browser.newContext({ viewport, hasTouch: !!hasTouch });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
} catch (err) {
|
||||
if (requireChromium) {
|
||||
console.error(`test-issue-1402-gesture-hints-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-issue-1402-gesture-hints-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0, passes = 0;
|
||||
const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
|
||||
const pass = (m) => { passes++; console.log(' PASS: ' + m); };
|
||||
const assert = (cond, msg) => { if (cond) pass(msg); else fail(msg); };
|
||||
void assert; // exported via fail/pass helpers; named for preflight grep clarity
|
||||
|
||||
// ── Mobile (vw=393, hasTouch) — operator's actual device class ──
|
||||
const mobileCtx = await freshContext(browser, { width: 393, height: 852 }, true);
|
||||
const mPage = await mobileCtx.newPage();
|
||||
mPage.setDefaultTimeout(15000);
|
||||
mPage.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
// First-visit /#/home setup.
|
||||
await mPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
|
||||
await clearAllHintFlags(mPage);
|
||||
await mPage.reload({ waitUntil: 'domcontentloaded' });
|
||||
await mPage.waitForTimeout(HINT_SETTLE_MS);
|
||||
|
||||
// Sanity probe — mirrors the operator's console probe.
|
||||
const probe = await mPage.evaluate(() => ({
|
||||
vw: window.innerWidth,
|
||||
bottomNav: !!document.querySelector('[data-bottom-nav]'),
|
||||
navDrawer: !!document.querySelector('.nav-drawer, [data-nav-drawer]'),
|
||||
pullEl: !!document.querySelector('.pull-to-reconnect'),
|
||||
pointerCoarse: window.matchMedia && window.matchMedia('(pointer: coarse)').matches,
|
||||
}));
|
||||
console.log(' PROBE (mobile /#/home): ' + JSON.stringify(probe));
|
||||
|
||||
// ── (1) Bug 1: tab-swipe at /#/home, vw=393 ──
|
||||
const tabSwipe = await hintVisible(mPage, 'tab-swipe');
|
||||
if (tabSwipe.present && tabSwipe.visible) {
|
||||
pass('(1) tab-swipe hint visible at vw=393 /#/home within 1500ms (Bug 1)');
|
||||
} else {
|
||||
fail(`(1) tab-swipe hint NOT visible at vw=393 /#/home — state=${JSON.stringify(tabSwipe)} probe=${JSON.stringify(probe)}`);
|
||||
}
|
||||
|
||||
// ── (2) Bug 2: edge-drawer at /#/home, vw=393 ──
|
||||
const edgeMobile = await hintVisible(mPage, 'edge-drawer');
|
||||
if (edgeMobile.present && edgeMobile.visible) {
|
||||
pass('(2) edge-drawer hint visible at vw=393 /#/home (Bug 2 — was inverted to desktop-only)');
|
||||
} else {
|
||||
fail(`(2) edge-drawer hint NOT visible at vw=393 /#/home — state=${JSON.stringify(edgeMobile)}`);
|
||||
}
|
||||
|
||||
// ── (3) Bug 3: pull-refresh at /#/home, vw=393 (touch viewport) ──
|
||||
const pullRefresh = await hintVisible(mPage, 'pull-refresh');
|
||||
if (pullRefresh.present && pullRefresh.visible) {
|
||||
pass('(3) pull-refresh hint visible at vw=393 /#/home (Bug 3 — was gated on WS-disconnect element)');
|
||||
} else {
|
||||
fail(`(3) pull-refresh hint NOT visible at vw=393 /#/home — state=${JSON.stringify(pullRefresh)}`);
|
||||
}
|
||||
|
||||
await mobileCtx.close();
|
||||
|
||||
// ── (4) Bug 4: row-swipe on /#/channels and /#/observers ──
|
||||
for (const route of ['/#/channels', '/#/observers']) {
|
||||
const ctx = await freshContext(browser, { width: 393, height: 852 }, true);
|
||||
const p = await ctx.newPage();
|
||||
p.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
await p.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' });
|
||||
await clearAllHintFlags(p);
|
||||
await p.reload({ waitUntil: 'domcontentloaded' });
|
||||
await p.waitForTimeout(HINT_SETTLE_MS);
|
||||
const rs = await hintVisible(p, 'row-swipe');
|
||||
if (rs.present && rs.visible) {
|
||||
pass(`(4) row-swipe hint visible at vw=393 ${route} (Bug 4 — route scope widened)`);
|
||||
} else {
|
||||
fail(`(4) row-swipe hint NOT visible at vw=393 ${route} — state=${JSON.stringify(rs)}`);
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ── (5) Desktop: edge-drawer hint must NOT render at vw=1024 (mobile-only) ──
|
||||
const dCtx = await freshContext(browser, { width: 1024, height: 800 }, false);
|
||||
const dPage = await dCtx.newPage();
|
||||
await dPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
|
||||
await clearAllHintFlags(dPage);
|
||||
await dPage.reload({ waitUntil: 'domcontentloaded' });
|
||||
await dPage.waitForTimeout(HINT_SETTLE_MS);
|
||||
const edgeDesktop = await hintVisible(dPage, 'edge-drawer');
|
||||
if (!edgeDesktop.present || !edgeDesktop.visible) {
|
||||
pass('(5) edge-drawer hint NOT visible at vw=1024 /#/home (mobile-only per Bug 2 fix)');
|
||||
} else {
|
||||
fail(`(5) edge-drawer hint SHOULD NOT render at vw=1024 but did — state=${JSON.stringify(edgeDesktop)}`);
|
||||
}
|
||||
await dCtx.close();
|
||||
|
||||
// ── (6) tab-swipe explicit-dismiss sets seen flag ──
|
||||
const dismissCtx = await freshContext(browser, { width: 393, height: 852 }, true);
|
||||
const dpPage = await dismissCtx.newPage();
|
||||
await dpPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
|
||||
await clearAllHintFlags(dpPage);
|
||||
await dpPage.reload({ waitUntil: 'domcontentloaded' });
|
||||
await dpPage.waitForTimeout(HINT_SETTLE_MS);
|
||||
const clicked = await dpPage.evaluate(() => {
|
||||
const el = document.querySelector('[data-gesture-hint="tab-swipe"]');
|
||||
if (!el) return false;
|
||||
const btn = el.querySelector('[data-gesture-hint-dismiss]');
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
});
|
||||
await dpPage.waitForTimeout(300);
|
||||
const flagAfter = await dpPage.evaluate((k) => localStorage.getItem(k), KEYS.tabSwipe);
|
||||
if (clicked && flagAfter === 'seen') {
|
||||
pass('(6) tab-swipe explicit dismiss sets localStorage seen flag');
|
||||
} else {
|
||||
fail(`(6) tab-swipe dismiss did not record seen — clicked=${clicked} flag=${flagAfter}`);
|
||||
}
|
||||
await dismissCtx.close();
|
||||
|
||||
// ── (7) FIRST-LOAD path: fresh page, no hashchange — hints must render ──
|
||||
// Operator console trace showed hints_in_dom=0 on initial paint and only
|
||||
// hashchange triggered the schedule path. Asserts schedule fires without nav.
|
||||
const flCtx = await freshContext(browser, { width: 393, height: 852 }, true);
|
||||
const flPage = await flCtx.newPage();
|
||||
flPage.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
// Pre-clear flags via prelude script BEFORE any navigation so the very-first
|
||||
// page-load is clean. (Reloading would still be a "first load" technically,
|
||||
// but this exercises the genuinely-cold path with no prior hashchange.)
|
||||
await flPage.addInitScript((keys) => {
|
||||
try { Object.values(keys).forEach((k) => localStorage.removeItem(k)); } catch (_) {}
|
||||
}, KEYS);
|
||||
await flPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
|
||||
await flPage.waitForTimeout(HINT_SETTLE_MS);
|
||||
const flHints = await flPage.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('[data-gesture-hint]')).map((e) => e.getAttribute('data-gesture-hint'))
|
||||
);
|
||||
if (flHints.includes('tab-swipe')) {
|
||||
pass(`(7) FIRST-LOAD: tab-swipe hint rendered without prior hashchange (hints=${JSON.stringify(flHints)})`);
|
||||
} else {
|
||||
fail(`(7) FIRST-LOAD: no tab-swipe hint on initial paint (hints=${JSON.stringify(flHints)})`);
|
||||
}
|
||||
await flCtx.close();
|
||||
|
||||
// ── (8) HASHCHANGE path: after first load, navigating still triggers hints ──
|
||||
const hcCtx = await freshContext(browser, { width: 393, height: 852 }, true);
|
||||
const hcPage = await hcCtx.newPage();
|
||||
hcPage.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
await hcPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
|
||||
await clearAllHintFlags(hcPage);
|
||||
// Mark home-relevant hints as seen so we can prove navigation to a NEW route
|
||||
// surfaces NEW hints (row-swipe on packets) — proving the hashchange path is alive.
|
||||
await hcPage.evaluate((keys) => {
|
||||
localStorage.setItem(keys.tabSwipe, 'seen');
|
||||
localStorage.setItem(keys.edgeDrawer, 'seen');
|
||||
localStorage.setItem(keys.pullRefresh, 'seen');
|
||||
}, KEYS);
|
||||
await hcPage.waitForTimeout(300);
|
||||
await hcPage.evaluate(() => { location.hash = '#/packets'; });
|
||||
await hcPage.waitForTimeout(HINT_SETTLE_MS);
|
||||
const rowAfterNav = await hintVisible(hcPage, 'row-swipe');
|
||||
if (rowAfterNav.present && rowAfterNav.visible) {
|
||||
pass('(8) HASHCHANGE: row-swipe hint rendered after nav from /#/home to /#/packets');
|
||||
} else {
|
||||
fail(`(8) HASHCHANGE: row-swipe not rendered after hashchange — state=${JSON.stringify(rowAfterNav)}`);
|
||||
}
|
||||
await hcCtx.close();
|
||||
|
||||
await browser.close();
|
||||
console.log(`\ntest-issue-1402-gesture-hints-e2e.js: ${passes} passed, ${failures} failed`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('test-issue-1402-gesture-hints-e2e.js: FAIL —', err); process.exit(1); });
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* #1407 — cb-preset propagation + WCAG AA for every preset/role.
|
||||
*
|
||||
* Two bugs:
|
||||
* 1. window.ROLE_COLORS is a STATIC literal that's never resynced when
|
||||
* MeshCorePresets.applyPreset() rewrites the --mc-role-* CSS vars.
|
||||
* The hardcoded values are the LEGACY April palette (#dc2626 et al),
|
||||
* not even the current Wong defaults from #1357.
|
||||
* 2. The achromat preset pairs dark text (#1a1a1a) with 3 dark grays
|
||||
* whose contrast falls below WCAG 1.4.3 AA (4.5:1): repeater 1.27,
|
||||
* companion 2.55, room 4.43.
|
||||
*
|
||||
* This test fails on master and passes after the fix lands.
|
||||
*
|
||||
* Pure node + vm.createContext — runs in the JS-unit-tests CI step
|
||||
* without a browser. Mirrors test-issue-1361-cb-presets.js sandbox shape.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
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');
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
// ─── WCAG helpers (independent of cb-presets, so we validate the impl) ───
|
||||
function hexToRgb(hex) {
|
||||
hex = String(hex || '').trim();
|
||||
if (hex[0] !== '#' || hex.length !== 7) return null;
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16)
|
||||
};
|
||||
}
|
||||
function chanLin(c) { var s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }
|
||||
function relLum(hex) { var rgb = hexToRgb(hex); if (!rgb) return 0; return 0.2126*chanLin(rgb.r)+0.7152*chanLin(rgb.g)+0.0722*chanLin(rgb.b); }
|
||||
function contrast(fg, bg) {
|
||||
var L1 = relLum(fg), L2 = relLum(bg);
|
||||
var hi = Math.max(L1, L2), lo = Math.min(L1, L2);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
// ─── Browser-ish sandbox (CSS var setProperty/getPropertyValue + listeners) ───
|
||||
function makeSandbox() {
|
||||
const root = {
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
},
|
||||
getAttribute() { return null; },
|
||||
setAttribute() {}
|
||||
};
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return this._attrs[k] || null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; },
|
||||
dataset: {}
|
||||
};
|
||||
const listeners = {};
|
||||
const storage = {
|
||||
_data: {},
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(this._data, k) ? this._data[k] : null; },
|
||||
setItem(k, v) { this._data[k] = String(v); },
|
||||
removeItem(k) { delete this._data[k]; }
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root,
|
||||
body: body,
|
||||
readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
createElement() {
|
||||
var el = { _children: [], style: {}, textContent: '', id: '',
|
||||
setAttribute() {}, appendChild(c) { this._children.push(c); } };
|
||||
return el;
|
||||
},
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {},
|
||||
},
|
||||
localStorage: storage,
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
addEventListener(ev, cb) { (listeners[ev] = listeners[ev] || []).push(cb); },
|
||||
dispatchEvent(ev) { (listeners[ev.type] || []).forEach(function (cb) { cb(ev); }); return true; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
fetch: function () { return { then: function () { return { then: function () { return { catch: function () {} }; }, catch: function () {} }; } }; },
|
||||
matchMedia: function () { return { matches: false }; },
|
||||
// getComputedStyle reads from the root.style._vars set by cb-presets
|
||||
getComputedStyle: function (el) {
|
||||
return {
|
||||
getPropertyValue: function (k) {
|
||||
return (root.style._vars[k] || '');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body, storage, listeners };
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 A: ROLE_COLORS is NOT the static legacy palette ===');
|
||||
let env;
|
||||
try {
|
||||
env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
} catch (e) {
|
||||
assert(false, 'sandbox load failed: ' + e.message);
|
||||
}
|
||||
|
||||
const RC = env && env.sandbox.window.ROLE_COLORS;
|
||||
assert(!!RC, 'window.ROLE_COLORS is defined');
|
||||
// MUTATION GUARD: ROLE_COLORS must be exposed via a getter that reads live
|
||||
// CSS vars — NOT a plain hardcoded data property. The bug is that it's a
|
||||
// static literal disconnected from --mc-role-* CSS vars.
|
||||
const RCDesc = env && Object.getOwnPropertyDescriptor(env.sandbox.window, 'ROLE_COLORS');
|
||||
assert(RCDesc && typeof RCDesc.get === 'function',
|
||||
'window.ROLE_COLORS must be a getter property (live read of --mc-role-* CSS vars), not a static literal');
|
||||
|
||||
// Direct CSS-var test: simulate what cb-presets.js does without going through
|
||||
// applyPreset's legacy ROLE_COLORS mutation path. Set the CSS var directly →
|
||||
// ROLE_COLORS getter must reflect it.
|
||||
env.root.style.setProperty('--mc-role-repeater', '#abcdef');
|
||||
const live = env.sandbox.window.ROLE_COLORS.repeater;
|
||||
assert(String(live).toLowerCase() === '#abcdef',
|
||||
'ROLE_COLORS.repeater reflects live --mc-role-repeater CSS var (got ' + live + ')');
|
||||
env.root.style.removeProperty('--mc-role-repeater');
|
||||
|
||||
console.log('\n=== #1407 B: ROLE_COLORS tracks --mc-role-* CSS vars live ===');
|
||||
const MCP = env && env.sandbox.window.MeshCorePresets;
|
||||
assert(!!MCP, 'MeshCorePresets exists');
|
||||
if (MCP) {
|
||||
// Apply default preset → CSS vars become Wong → ROLE_COLORS should report Wong.
|
||||
MCP.applyPreset('default');
|
||||
const def = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(def === '#d55e00', 'after applyPreset("default") ROLE_COLORS.repeater === #D55E00 Wong (got ' + def + ')');
|
||||
|
||||
// Switch to deut → ROLE_COLORS.repeater should change to IBM orange #FE6100.
|
||||
MCP.applyPreset('deut');
|
||||
const deut = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(deut === '#fe6100', 'after applyPreset("deut") ROLE_COLORS.repeater === #FE6100 IBM orange (got ' + deut + ')');
|
||||
|
||||
// Switch to achromat → should be dark gray #333333.
|
||||
MCP.applyPreset('achromat');
|
||||
const ach = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(ach === '#333333', 'after applyPreset("achromat") ROLE_COLORS.repeater === #333333 (got ' + ach + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 C: ROLE_STYLE.color also reads live ===');
|
||||
if (MCP) {
|
||||
MCP.applyPreset('trit');
|
||||
const rs = env.sandbox.window.ROLE_STYLE && env.sandbox.window.ROLE_STYLE.repeater;
|
||||
const c = rs && String(rs.color || '').toLowerCase();
|
||||
assert(c === '#cc6677', 'after applyPreset("trit") ROLE_STYLE.repeater.color === #CC6677 (got ' + c + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 D: applyPreset writes --mc-role-X-text CSS vars ===');
|
||||
if (MCP) {
|
||||
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
|
||||
MCP.applyPreset(id);
|
||||
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
||||
const v = env.root.style.getPropertyValue('--mc-role-' + role + '-text');
|
||||
assert(/^#[0-9a-f]{6}$/i.test(v), 'preset "' + id + '" sets --mc-role-' + role + '-text (got "' + v + '")');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 E: WCAG 1.4.3 AA — every (preset, role) pair ≥ 4.5:1 ===');
|
||||
if (MCP) {
|
||||
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
|
||||
MCP.applyPreset(id);
|
||||
const preset = MCP.list.find(function (p) { return p.id === id; });
|
||||
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
|
||||
const bg = preset.roleColors[role];
|
||||
const text = env.root.style.getPropertyValue('--mc-role-' + role + '-text');
|
||||
const ratio = contrast(text, bg);
|
||||
assert(ratio >= 4.5,
|
||||
'WCAG 1.4.3 AA: preset "' + id + '" role "' + role + '" bg=' + bg +
|
||||
' text=' + text + ' contrast=' + ratio.toFixed(2) + ':1 (need ≥4.5)');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== #1407 F: pill text color is driven by CSS var, not hardcoded ===');
|
||||
// style.css `.mc-pill` rule must use var(--mc-role-*-text) — NOT hardcoded #1a1a1a.
|
||||
const pillRuleMatch = styleSrc.match(/\.mc-cluster\s+\.mc-pill\s*\{[^}]*\}/);
|
||||
assert(pillRuleMatch, '.mc-cluster .mc-pill rule found in style.css');
|
||||
if (pillRuleMatch) {
|
||||
const block = pillRuleMatch[0];
|
||||
assert(/var\(--mc-pill-text|var\(--mc-role-/.test(block),
|
||||
'.mc-cluster .mc-pill uses var(--mc-...-text) for color (got: ' + block.replace(/\s+/g,' ').slice(0,200) + ')');
|
||||
}
|
||||
// map.js inline style: must not hardcode color:#1a1a1a on the pill
|
||||
const inlineHardcoded = /color:\s*#1a1a1a/.test(mapSrc);
|
||||
assert(!inlineHardcoded, 'public/map.js does not hardcode color:#1a1a1a on .mc-pill inline style');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,49 +0,0 @@
|
||||
/* Issue #1409 — channels.js must NOT unconditionally force-enable
|
||||
* 'channels-show-encrypted' in localStorage on every init.
|
||||
*
|
||||
* The bug: channels.js set localStorage.setItem('channels-show-encrypted', 'true')
|
||||
* unconditionally on init, which made it impossible for an operator to ever
|
||||
* hide the 246 encrypted-placeholder channels.
|
||||
*
|
||||
* Test strategy: source-grep. The file must not contain a
|
||||
* setItem('channels-show-encrypted', 'true') call anywhere — there is no
|
||||
* legitimate place to force this on; the only writer should be a future
|
||||
* user-toggle handler that writes BOTH 'true' and 'false' under a condition.
|
||||
*/
|
||||
'use strict';
|
||||
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(' \u2705 ' + name); }
|
||||
catch (e) { failed++; console.log(' \u274c ' + name + ': ' + e.message); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
|
||||
|
||||
console.log('Issue #1409 — no force-enable of channels-show-encrypted');
|
||||
|
||||
test('channels.js does NOT unconditionally setItem(channels-show-encrypted, true)', function () {
|
||||
// Match any whitespace/quote variant of:
|
||||
// localStorage.setItem('channels-show-encrypted', 'true')
|
||||
// or with double quotes. A user-toggle handler would set a VARIABLE,
|
||||
// not the literal string 'true', so this is a safe gate.
|
||||
var re = /localStorage\s*\.\s*setItem\s*\(\s*['"]channels-show-encrypted['"]\s*,\s*['"]true['"]\s*\)/;
|
||||
var m = src.match(re);
|
||||
assert.strictEqual(m, null,
|
||||
'Found forbidden literal force-set of channels-show-encrypted=true in public/channels.js. ' +
|
||||
'A user-toggle handler should pass a boolean variable, not the literal string "true".');
|
||||
});
|
||||
|
||||
test('channels.js still reads channels-show-encrypted (toggle gate preserved)', function () {
|
||||
// We are NOT removing the read path; the reader is still needed so a
|
||||
// future user toggle works. This sanity-check ensures the fix did not
|
||||
// also delete the reader.
|
||||
assert.ok(/getItem\(\s*['"]channels-show-encrypted['"]\s*\)/.test(src),
|
||||
'Expected getItem(channels-show-encrypted) to still be present');
|
||||
});
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* #1412 — customizer nodeColors must NOT auto-push server config into
|
||||
* ROLE_COLORS overrides, or it defeats CB-preset propagation.
|
||||
*
|
||||
* Bug (CDP-verified on staging): PR #1408 made window.ROLE_COLORS a live
|
||||
* getter that reads --mc-role-* CSS vars. cb-presets.applyPreset() writes
|
||||
* those vars, so consumers SHOULD see new colors. But customize-v2.js:553
|
||||
* runs early on every page load and pushes effectiveConfig.nodeColors
|
||||
* (server config, legacy April palette) into the override map, which the
|
||||
* getter prefers over CSS vars. Net effect: ROLE_COLORS is frozen on the
|
||||
* legacy palette forever; presets only update the CSS, not the JS.
|
||||
*
|
||||
* Fix: server-config nodeColors must only write --node-* CSS var (legacy
|
||||
* compat for anything still reading --node-*). It must NOT touch the
|
||||
* override map. User-chosen colors in the customizer continue to win via
|
||||
* setRoleColorOverride() (explicit, intentional override).
|
||||
*
|
||||
* Test strategy: extract the actual code block from customize-v2.js that
|
||||
* processes effectiveConfig.nodeColors, run it in a vm sandbox with a
|
||||
* legacy-palette config, apply preset "deut", assert ROLE_COLORS reflects
|
||||
* the preset (not the server config).
|
||||
*
|
||||
* Mutation guard: re-introducing the `window.ROLE_COLORS[role] = nc[role]`
|
||||
* write to customize-v2.js makes the first test fail.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
|
||||
const cv2Src = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
|
||||
|
||||
// Browser-ish sandbox (CSS var setProperty/getPropertyValue).
|
||||
function makeSandbox() {
|
||||
const root = {
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
},
|
||||
getAttribute() { return null; },
|
||||
setAttribute() {}
|
||||
};
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return this._attrs[k] || null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; },
|
||||
dataset: {}
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root,
|
||||
body: body,
|
||||
readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
createElement() { return { style: {}, setAttribute() {}, appendChild() {} }; },
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {},
|
||||
},
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
addEventListener() {},
|
||||
dispatchEvent() { return true; },
|
||||
fetch: function () { return { then: function () { return { then: function () { return { catch: function () {} }; }, catch: function () {} }; } }; },
|
||||
matchMedia: function () { return { matches: false }; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
getComputedStyle: function () {
|
||||
return { getPropertyValue: function (k) { return (root.style._vars[k] || ''); } };
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body };
|
||||
}
|
||||
|
||||
// ─── Extract the two nodeColors-processing blocks from customize-v2.js. ───
|
||||
// We want to execute the REAL source so reverting the fix breaks the test.
|
||||
// Block 1: the effective-config apply path (≈ line 550).
|
||||
// Block 2: the early-overrides apply path (≈ line 2146).
|
||||
function extractBlock(src, anchor) {
|
||||
const idx = src.indexOf(anchor);
|
||||
if (idx === -1) throw new Error('anchor not found: ' + anchor);
|
||||
// Walk forward to the matching closing brace of the surrounding `if (nc) { ... }`.
|
||||
// Slice forward a generous window then balance braces from the first '{' after anchor.
|
||||
const start = src.indexOf('{', idx);
|
||||
if (start === -1) throw new Error('open brace not found after anchor');
|
||||
let depth = 0, end = -1;
|
||||
for (let i = start; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end === -1) throw new Error('matching close brace not found');
|
||||
return src.slice(idx, end + 1);
|
||||
}
|
||||
|
||||
// Block A — main effective-config push: `var nc = effectiveConfig.nodeColors;`
|
||||
const blockA = extractBlock(cv2Src, 'var nc = effectiveConfig.nodeColors;');
|
||||
// Block B — early overrides: `if (earlyOverrides.nodeColors) {`
|
||||
const blockB = extractBlock(cv2Src, 'if (earlyOverrides.nodeColors) {');
|
||||
|
||||
console.log('\n=== #1412 A: server-config nodeColors does NOT clobber preset ROLE_COLORS ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
|
||||
// Simulate user choosing the "deut" preset.
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
// CSS var should be IBM orange now.
|
||||
assert(env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase() === '#fe6100',
|
||||
'precondition: --mc-role-repeater is #FE6100 after applyPreset("deut")');
|
||||
|
||||
// Now simulate customize-v2 picking up the server config (legacy palette).
|
||||
const setupBlockA =
|
||||
'var root = document.documentElement.style;\n' +
|
||||
'var userOverrides = undefined;\n' +
|
||||
'var effectiveConfig = { nodeColors: { repeater: "#dc2626", companion: "#2563eb", room: "#16a34a", sensor: "#d97706", observer: "#8b5cf6" } };\n' +
|
||||
blockA + '\n';
|
||||
vm.runInContext(setupBlockA, env.sandbox);
|
||||
|
||||
// The --node-* CSS vars should still be written for legacy consumers.
|
||||
assert(env.root.style.getPropertyValue('--node-repeater') === '#dc2626',
|
||||
'--node-repeater CSS var is still written (legacy compat preserved)');
|
||||
|
||||
// The KEY assertion: ROLE_COLORS must still reflect the preset, NOT the
|
||||
// server-config legacy palette.
|
||||
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got === '#fe6100',
|
||||
'ROLE_COLORS.repeater === #FE6100 after server-config push (got ' + got + ')');
|
||||
|
||||
const gotCompanion = String(env.sandbox.window.ROLE_COLORS.companion).toLowerCase();
|
||||
assert(gotCompanion !== '#2563eb',
|
||||
'ROLE_COLORS.companion is NOT the server-config legacy #2563eb (got ' + gotCompanion + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1412 B: early-overrides path also stays out of ROLE_COLORS override map ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
|
||||
const setupBlockB =
|
||||
'var root = document.documentElement.style;\n' +
|
||||
'var earlyOverrides = { nodeColors: { repeater: "#dc2626", companion: "#2563eb" } };\n' +
|
||||
blockB + '\n';
|
||||
// earlyOverrides path also writes --node-* and (per fix) only --node-*.
|
||||
// The extracted block may not write --node-* — that's fine; we only care
|
||||
// it does NOT push into the override map.
|
||||
try { vm.runInContext(setupBlockB, env.sandbox); }
|
||||
catch (e) { /* if the block touches APIs we didn't stub, ignore — the
|
||||
override-map assertion below is what matters */ }
|
||||
|
||||
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got === '#fe6100',
|
||||
'ROLE_COLORS.repeater === #FE6100 after early-overrides push (got ' + got + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1412 C: explicit setRoleColorOverride() still wins (user customizer pick) ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
|
||||
// User manually picks a node color in the customizer.
|
||||
env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff');
|
||||
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got === '#ff00ff',
|
||||
'after setRoleColorOverride("repeater","#ff00ff") ROLE_COLORS.repeater === #ff00ff (got ' + got + ')');
|
||||
|
||||
// Clearing the override lets the preset show through again.
|
||||
env.sandbox.window.setRoleColorOverride('repeater', '');
|
||||
const got2 = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got2 === '#fe6100',
|
||||
'after clearing override, ROLE_COLORS.repeater reverts to preset #FE6100 (got ' + got2 + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1412 D: customize.js per-key node-color picker uses setRoleColorOverride ===');
|
||||
{
|
||||
// Static guard: the legacy customizer (customize.js) handlers for the node
|
||||
// color pickers must call setRoleColorOverride(key, value) — NOT mutate
|
||||
// ROLE_COLORS directly. The proxy-on-read trick in roles.js handles direct
|
||||
// assignment, but going through the explicit API keeps semantics obvious
|
||||
// and lets us delete the proxy layer later.
|
||||
const customizeSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize.js'), 'utf8');
|
||||
|
||||
// Grep for the two affected handlers (data-node input handler + reset).
|
||||
// Locate the input[data-node] handler — slice forward through the inner forEach callback.
|
||||
const nodeInputStart = customizeSrc.indexOf("querySelectorAll('input[data-node]')");
|
||||
const nodeInputHandler = nodeInputStart >= 0 ? [customizeSrc.slice(nodeInputStart, nodeInputStart + 800)] : null;
|
||||
assert(nodeInputHandler, 'node color input handler block found in customize.js');
|
||||
if (nodeInputHandler) {
|
||||
assert(/setRoleColorOverride\s*\(/.test(nodeInputHandler[0]),
|
||||
'node color input handler calls setRoleColorOverride()');
|
||||
assert(!/window\.ROLE_COLORS\s*\[[^\]]+\]\s*=/.test(nodeInputHandler[0]),
|
||||
'node color input handler does NOT assign window.ROLE_COLORS[key] = … directly');
|
||||
}
|
||||
|
||||
const nodeResetStart = customizeSrc.indexOf("querySelectorAll('[data-reset-node]')");
|
||||
const nodeResetHandler = nodeResetStart >= 0 ? [customizeSrc.slice(nodeResetStart, nodeResetStart + 800)] : null;
|
||||
assert(nodeResetHandler, 'node color reset handler block found in customize.js');
|
||||
if (nodeResetHandler) {
|
||||
assert(/setRoleColorOverride\s*\(/.test(nodeResetHandler[0]),
|
||||
'node color reset handler calls setRoleColorOverride()');
|
||||
assert(!/window\.ROLE_COLORS\[/.test(nodeResetHandler[0]),
|
||||
'node color reset handler does NOT write window.ROLE_COLORS[key] directly');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1413 — More button overlaps nav-stats badge at vw~1200px.
|
||||
*
|
||||
* Symptom: at viewport ~1101..1599px on a non-mobile page (e.g.
|
||||
* /#/packets), the ".nav-more-btn" (in .nav-left) and ".nav-stats"
|
||||
* (in .nav-right) overlap horizontally. CDP-confirmed: at vw=1200,
|
||||
* .nav-more-btn rect (x=499..556) sat on top of .nav-stats (x=502..961),
|
||||
* a ~54px x-axis overlap. Visually the stats badge number rendered on
|
||||
* top of the "More" text and the chevron.
|
||||
*
|
||||
* Acceptance (from issue #1413):
|
||||
* - At vw=1101..1920 (sample step), .nav-more-btn.right + GAP <=
|
||||
* .nav-stats.left, where GAP >= 8px.
|
||||
* - At vw <= 1100, .nav-stats is display:none (no change).
|
||||
* - Nav doesn't horizontally scroll at any viewport.
|
||||
*
|
||||
* Root cause: .top-nav uses display:flex with justify-content:
|
||||
* space-between, but .nav-left had no flex-grow and .nav-links had no
|
||||
* flex-grow either, so .nav-left only consumed its content's intrinsic
|
||||
* width. .nav-right (flex-shrink:0) then sat at its natural position
|
||||
* computed from total content — and the JS Priority+ fits() check
|
||||
* succeeded based on intrinsic widths that under-reported the real
|
||||
* collision because .top-nav has overflow:hidden masking it.
|
||||
*
|
||||
* Fix (verified via CDP at vw 1101..1920): `.nav-links { flex: 1 1
|
||||
* auto; min-width: 0 }` + `.top-nav { column-gap: 16px }`. Reverting
|
||||
* either part of the fix reintroduces overlap at vw=1200.
|
||||
*
|
||||
* Mutation guard: revert the CSS fix → this test fails at vw=1200.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
const WIDTHS = [1101, 1200, 1366, 1440, 1600, 1920];
|
||||
const HEIGHT = 800;
|
||||
const MIN_GAP_PX = 8;
|
||||
|
||||
async function main() {
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
} catch (err) {
|
||||
if (process.env.CHROMIUM_REQUIRE === '1') {
|
||||
console.error(`test-issue-1413-nav-overlap-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-issue-1413-nav-overlap-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
for (const w of WIDTHS) {
|
||||
await page.setViewportSize({ width: w, height: HEIGHT });
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.top-nav .nav-links');
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
|
||||
// Settle layout: two consecutive frames identical for nav-right.
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.top-nav .nav-right');
|
||||
if (!el) return false;
|
||||
const r1 = el.getBoundingClientRect();
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const r2 = el.getBoundingClientRect();
|
||||
resolve(r1.right === r2.right && r1.left === r2.left);
|
||||
}));
|
||||
});
|
||||
}, null, { timeout: 5000 });
|
||||
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
const data = await page.evaluate(() => {
|
||||
const more = document.querySelector('.nav-more-btn');
|
||||
const stats = document.querySelector('.nav-stats');
|
||||
const moreVisible = more && getComputedStyle(more).display !== 'none' &&
|
||||
getComputedStyle(more.parentElement).display !== 'none' &&
|
||||
!more.parentElement.classList.contains('is-hidden');
|
||||
const statsVisible = stats && getComputedStyle(stats).display !== 'none';
|
||||
const mb = more ? more.getBoundingClientRect() : null;
|
||||
const sb = stats ? stats.getBoundingClientRect() : null;
|
||||
const topNav = document.querySelector('.top-nav');
|
||||
const tnScrollW = topNav ? topNav.scrollWidth : 0;
|
||||
const tnClientW = topNav ? topNav.clientWidth : 0;
|
||||
return {
|
||||
moreVisible, statsVisible,
|
||||
more: mb ? { x: mb.x, right: mb.right, w: mb.width } : null,
|
||||
stats: sb ? { x: sb.x, right: sb.right, w: sb.width } : null,
|
||||
tnScrollW, tnClientW,
|
||||
};
|
||||
});
|
||||
|
||||
let status = 'PASS';
|
||||
const reasons = [];
|
||||
|
||||
// Acceptance: if both visible, more.right + 8 <= stats.left.
|
||||
if (data.moreVisible && data.statsVisible && data.more && data.stats) {
|
||||
const gap = data.stats.x - data.more.right;
|
||||
if (gap < MIN_GAP_PX) {
|
||||
status = 'FAIL';
|
||||
reasons.push(`overlap: more.right=${data.more.right.toFixed(1)} stats.left=${data.stats.x.toFixed(1)} gap=${gap.toFixed(1)} (need >= ${MIN_GAP_PX})`);
|
||||
}
|
||||
}
|
||||
// No horizontal scroll in nav.
|
||||
if (data.tnScrollW > data.tnClientW + 1) {
|
||||
status = 'FAIL';
|
||||
reasons.push(`top-nav h-scroll: scrollW=${data.tnScrollW} clientW=${data.tnClientW}`);
|
||||
}
|
||||
|
||||
if (status === 'FAIL') {
|
||||
failures++;
|
||||
console.error(`vw=${w} #/packets ${status}: ${reasons.join('; ')}`);
|
||||
} else {
|
||||
passes++;
|
||||
console.log(`vw=${w} #/packets PASS (more.right=${data.more && data.more.right.toFixed(1)} stats.left=${data.stats && data.stats.x.toFixed(1)})`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\ntest-issue-1413-nav-overlap-e2e.js: ${passes} pass, ${failures} fail`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('test-issue-1413-nav-overlap-e2e.js: ERROR', err); process.exit(1); });
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* #1415 — Packets cross-viewport jank source-grep test.
|
||||
*
|
||||
* Asserts the four code-level invariants required by the layout fix:
|
||||
*
|
||||
* 1. Expand-chevron column is pinned narrow at every viewport via an
|
||||
* explicit `.col-expand` class on the first <th>/<td> AND a CSS rule
|
||||
* pinning its width to ~32px (max-width ≤ 36px).
|
||||
* 2. DETAILS column is capped — `.col-details` has a `max-width` ≤ 480px
|
||||
* so wide viewports stop wasting hundreds of px on the last column.
|
||||
* 3. Mobile chrome compaction — the `@media (max-width: 480px)` block
|
||||
* hides `.col-details` (so the table doesn't carry the dead column to
|
||||
* mobile) AND hides the BYOP button in `.page-header` (operator
|
||||
* request: reclaim 60+ px of pre-table chrome).
|
||||
* 4. Mobile-priority detail order — `renderDetail()` renders the Payload
|
||||
* Type as the FIRST `<dt>` of `.detail-meta` (operator's "lead with
|
||||
* packet type"), and wraps the byte-breakdown / hex-dump / field-table
|
||||
* into a `<details class="detail-technical">` element so the
|
||||
* technical fields collapse on mobile (collapsed by default, open on
|
||||
* desktop via the `open` attribute being conditionally set).
|
||||
*
|
||||
* Strategy: pure source-grep — no browser, no playwright. The grep is the
|
||||
* gate. If someone reverts any of the four fixes, the corresponding assert
|
||||
* fails. Cheap to run, deterministic, runs in CI without browser deps.
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' \u2705 ' + msg); }
|
||||
else { failed++; console.error(' \u274c ' + msg); }
|
||||
}
|
||||
|
||||
const pktJs = fs.readFileSync(path.join(__dirname, 'public/packets.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
|
||||
|
||||
// ── 1. col-expand class + CSS pin ────────────────────────────────────────
|
||||
assert(
|
||||
/<th[^>]*class="col-expand"/.test(pktJs),
|
||||
'packets.js header has <th class="col-expand"> on the first column'
|
||||
);
|
||||
assert(
|
||||
/<td class="col-expand"/.test(pktJs),
|
||||
'packets.js row builders emit <td class="col-expand"> for the chevron cell'
|
||||
);
|
||||
|
||||
// CSS must pin width somewhere in the .col-expand selector.
|
||||
var colExpandBlocks = css.match(/\.col-expand\b[^{}]*\{[^}]*\}/g) || [];
|
||||
var pinned = colExpandBlocks.some(function (b) {
|
||||
return /max-width:\s*3[26]px/.test(b) && /min-width:\s*3[26]px/.test(b);
|
||||
});
|
||||
assert(pinned, 'style.css .col-expand pins min-width AND max-width to ~32px');
|
||||
|
||||
// ── 1b. Locked column-priority tiers (operator spec) ─────────────────────
|
||||
// Tier 1 (always — even on smallest mobile): expand, time, type, details
|
||||
// Tier 2 (tablet+): path
|
||||
// Tier 3 (desktop only): hash, observer, rpt
|
||||
// Region/Size/HB stay at the existing low-priority tiers (already 3-5).
|
||||
//
|
||||
// Mapping to priority values (see TableResponsive doc at top of packets.js):
|
||||
// priority 1 → always visible
|
||||
// priority 3 → hidden ≤ 1024 (desktop-only)
|
||||
// priority 5 → hidden ≤ 768 (tablet+ only)
|
||||
function colPriority(klass) {
|
||||
var re = new RegExp('<th[^>]*class="' + klass + '"[^>]*data-priority="(\\d+)"');
|
||||
var m = pktJs.match(re);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
assert(colPriority('col-expand') === 1, 'col-expand is tier-1 priority (always visible)');
|
||||
assert(colPriority('col-time') === 1, 'col-time is tier-1 priority (always visible)');
|
||||
assert(colPriority('col-type') === 1, 'col-type is tier-1 priority (always visible)');
|
||||
assert(colPriority('col-details') === 1, 'col-details is tier-1 priority (always visible)');
|
||||
assert(colPriority('col-path') === 5, 'col-path is tier-2 (hidden ≤768, tablet+ only)');
|
||||
assert(colPriority('col-hash') === 3, 'col-hash is tier-3 (desktop only, hidden ≤1024)');
|
||||
assert(colPriority('col-observer') === 3, 'col-observer is tier-3 (desktop only, hidden ≤1024)');
|
||||
assert(colPriority('col-rpt') === 3, 'col-rpt is tier-3 (desktop only, hidden ≤1024)');
|
||||
|
||||
// ── 2. DETAILS column capped ─────────────────────────────────────────────
|
||||
var colDetailsBlocks = css.match(/\.col-details\b[^{}]*\{[^}]*\}/g) || [];
|
||||
var capped = colDetailsBlocks.some(function (b) {
|
||||
var m = b.match(/max-width:\s*(\d+)px/);
|
||||
return m && parseInt(m[1], 10) <= 480 && parseInt(m[1], 10) >= 200;
|
||||
});
|
||||
assert(capped, 'style.css caps .col-details with max-width ≤ 480px');
|
||||
|
||||
// ── 3. Mobile compaction — DETAILS hidden + BYOP hidden under 480 ────────
|
||||
var mobileBlock = (function () {
|
||||
var idx = css.indexOf('@media (max-width: 480px)');
|
||||
if (idx < 0) return '';
|
||||
var depth = 0, start = -1, end = -1;
|
||||
for (var i = idx; i < css.length; i++) {
|
||||
var c = css[i];
|
||||
if (c === '{') { if (depth === 0) start = i; depth++; }
|
||||
else if (c === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
return start > 0 && end > 0 ? css.slice(start, end + 1) : '';
|
||||
})();
|
||||
assert(mobileBlock.length > 0, 'style.css has a @media (max-width: 480px) block');
|
||||
assert(
|
||||
/pkt-byop[^{}]*\{[^}]*display:\s*none/.test(mobileBlock),
|
||||
'mobile @media block hides the BYOP button (chrome compaction)'
|
||||
);
|
||||
// Note: per LOCKED spec, col-details is tier-1 and stays visible at mobile.
|
||||
// It is the col-path / col-hash / col-observer / col-rpt that drop on mobile,
|
||||
// already enforced via data-priority above (TableResponsive.apply).
|
||||
|
||||
// ── 4. renderDetail mobile-priority ordering ────────────────────────────
|
||||
var dlMatch = pktJs.match(/<dl class="detail-meta">([\s\S]*?)<\/dl>/);
|
||||
assert(!!dlMatch, 'renderDetail emits <dl class="detail-meta">');
|
||||
if (dlMatch) {
|
||||
var dlBody = dlMatch[1];
|
||||
var idxType = dlBody.indexOf('Payload Type');
|
||||
var idxObs = dlBody.indexOf('Observer');
|
||||
assert(idxType >= 0, '.detail-meta still includes Payload Type row');
|
||||
assert(idxObs >= 0, '.detail-meta still includes Observer row');
|
||||
assert(
|
||||
idxType >= 0 && idxObs >= 0 && idxType < idxObs,
|
||||
'.detail-meta lists Payload Type BEFORE Observer (mobile-priority order)'
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap hex / breakdown / observations in a collapsible technical section.
|
||||
assert(
|
||||
/<details[^>]*class="detail-technical"/.test(pktJs),
|
||||
'renderDetail wraps technical fields in <details class="detail-technical">'
|
||||
);
|
||||
|
||||
// ── 5. #1458 P0-A — semantic-first detail title ─────────────────────────
|
||||
// Previously the title hard-coded "Packet Byte Breakdown (N bytes)" when
|
||||
// raw_hex was present. Must be replaced by a type-badge + summary header.
|
||||
assert(
|
||||
!/Packet Byte Breakdown/.test(pktJs),
|
||||
'renderDetail no longer leads with "Packet Byte Breakdown (N bytes)" title'
|
||||
);
|
||||
assert(
|
||||
/<div class="detail-title">[\s\S]{0,200}badge badge-\$\{payloadTypeColor/.test(pktJs),
|
||||
'detail-title leads with a type badge (semantic identity first)'
|
||||
);
|
||||
assert(
|
||||
/<div class="detail-srcdst">/.test(pktJs),
|
||||
'renderDetail emits a .detail-srcdst row (src → dst summary)'
|
||||
);
|
||||
|
||||
// ── 6. #1458 P0-B — raw-bytes disclosure copy ───────────────────────────
|
||||
assert(
|
||||
/<summary>Show raw bytes<\/summary>/.test(pktJs),
|
||||
'detail-technical disclosure summary reads "Show raw bytes" (per spec)'
|
||||
);
|
||||
|
||||
// ── 7. #1458 P0-C — mobile filter-zone collapse ─────────────────────────
|
||||
assert(
|
||||
/pkt-filter-expr/.test(pktJs),
|
||||
'always-on filter input wrapper carries class .pkt-filter-expr'
|
||||
);
|
||||
assert(
|
||||
/\.pkt-filter-expr[^{}]*\{[^}]*display:\s*none/.test(mobileBlock),
|
||||
'mobile @media (max-width: 480px) hides .pkt-filter-expr by default'
|
||||
);
|
||||
assert(
|
||||
/\.filter-bar\.filters-expanded[^{}]*\.pkt-filter-expr[^{}]*\{[^}]*display:/.test(mobileBlock) ||
|
||||
/:has\(\.filter-bar\.filters-expanded\)[^{}]*\.pkt-filter-expr[^{}]*\{[^}]*display:/.test(mobileBlock),
|
||||
'expanded filters reveal .pkt-filter-expr on mobile (Filters ▾ toggle)'
|
||||
);
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* #1418 — cb-presets.js writes --mc-rt-ramp-0..4 + fires cb-preset-changed.
|
||||
*
|
||||
* `route-view.js` reads CSS vars --mc-rt-ramp-0..4 to color the edge gradient
|
||||
* via getComputedStyle. When the user switches color-blind preset,
|
||||
* applyPreset() must:
|
||||
* 1. Write 5 ramp stops from preset.routeRamp (or fallback viridis).
|
||||
* 2. Fire a cb-preset-changed CustomEvent so route-view.js recolorRoute
|
||||
* can walk .mc-rt-edge / .mc-rt-row / .mc-rt-spark-dot live.
|
||||
*
|
||||
* Pattern mirrors test-issue-1407-cb-preset-propagation.js sandbox shape.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
|
||||
const routeSrc = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 ramp A: route-view reads --mc-rt-ramp-* CSS vars ===');
|
||||
assert(/--mc-rt-ramp-/.test(routeSrc),
|
||||
'route-view.js references --mc-rt-ramp-* CSS vars');
|
||||
assert(/cb-preset-changed/.test(routeSrc),
|
||||
'route-view.js listens for cb-preset-changed event');
|
||||
// Selectors recolorRoute touches
|
||||
assert(/mc-rt-edge|mc-rt-spark-dot|mc-rt-row/.test(routeSrc),
|
||||
'recolorRoute touches mc-rt-edge / mc-rt-spark-dot / mc-rt-row classes');
|
||||
|
||||
console.log('\n=== #1418 ramp B: cb-presets writes ramp stops ===');
|
||||
function makeSandbox() {
|
||||
const root = {
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
},
|
||||
getAttribute() { return null; },
|
||||
setAttribute() {}
|
||||
};
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return this._attrs[k] || null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; }
|
||||
};
|
||||
const listeners = {};
|
||||
const storage = {
|
||||
_data: {},
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(this._data, k) ? this._data[k] : null; },
|
||||
setItem(k, v) { this._data[k] = String(v); },
|
||||
removeItem(k) { delete this._data[k]; }
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root, body: body, readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
createElement() {
|
||||
return { _children: [], style: {}, textContent: '', id: '',
|
||||
setAttribute() {}, appendChild(c) { this._children.push(c); } };
|
||||
},
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {}
|
||||
},
|
||||
localStorage: storage,
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
fetch: function () { return Promise.resolve({ ok: false }); },
|
||||
matchMedia: function () { return { matches: false, addEventListener() {}, addListener() {} }; },
|
||||
addEventListener(ev, cb) { (listeners[ev] = listeners[ev] || []).push(cb); },
|
||||
dispatchEvent(ev) { (listeners[ev.type] || []).forEach(function (cb) { cb(ev); }); return true; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
getComputedStyle: function () {
|
||||
return { getPropertyValue: function (k) { return root.style._vars[k] || ''; } };
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body, storage, listeners };
|
||||
}
|
||||
|
||||
let env;
|
||||
try {
|
||||
env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
} catch (e) {
|
||||
assert(false, 'sandbox load failed: ' + e.message);
|
||||
}
|
||||
|
||||
const MCP = env && env.sandbox.window.MeshCorePresets;
|
||||
assert(!!MCP, 'MeshCorePresets exported');
|
||||
|
||||
if (MCP) {
|
||||
console.log('\n --- ramp-stop count for every preset ---');
|
||||
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
|
||||
MCP.applyPreset(id);
|
||||
let stopsSet = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const v = env.root.style.getPropertyValue('--mc-rt-ramp-' + i);
|
||||
if (/^#[0-9a-f]{6}$/i.test(v)) stopsSet++;
|
||||
}
|
||||
assert(stopsSet === 5,
|
||||
'preset "' + id + '" sets all 5 ramp stops (--mc-rt-ramp-0..4) — got ' + stopsSet);
|
||||
});
|
||||
|
||||
console.log('\n --- preset routeRamp values land in CSS vars ---');
|
||||
MCP.applyPreset('default');
|
||||
const preset0 = MCP.list.find(p => p.id === 'default');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const expected = preset0.routeRamp[i].toLowerCase();
|
||||
const actual = env.root.style.getPropertyValue('--mc-rt-ramp-' + i).toLowerCase();
|
||||
assert(actual === expected,
|
||||
'default --mc-rt-ramp-' + i + ' = ' + expected + ' (got ' + actual + ')');
|
||||
}
|
||||
|
||||
console.log('\n --- switching preset rewrites all 5 stops ---');
|
||||
MCP.applyPreset('deut');
|
||||
const deut = MCP.list.find(p => p.id === 'deut');
|
||||
let allRewritten = true;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const actual = env.root.style.getPropertyValue('--mc-rt-ramp-' + i).toLowerCase();
|
||||
if (actual !== deut.routeRamp[i].toLowerCase()) allRewritten = false;
|
||||
}
|
||||
assert(allRewritten, 'switching to deut overwrites every ramp stop');
|
||||
|
||||
console.log('\n --- achromat ramp is luminance (B/W) ---');
|
||||
MCP.applyPreset('achromat');
|
||||
const achr = MCP.list.find(p => p.id === 'achromat');
|
||||
// Achromat ramp is the gray luminance ramp per cb-presets.js line 170.
|
||||
const stop0 = env.root.style.getPropertyValue('--mc-rt-ramp-0').toLowerCase();
|
||||
const stop4 = env.root.style.getPropertyValue('--mc-rt-ramp-4').toLowerCase();
|
||||
assert(stop0 === '#222222', 'achromat ramp[0] === #222222 (got ' + stop0 + ')');
|
||||
assert(stop4 === '#eeeeee', 'achromat ramp[4] === #eeeeee (got ' + stop4 + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1418 ramp C: applyPreset fires cb-preset-changed event ===');
|
||||
if (MCP) {
|
||||
let fired = false, detailId = null;
|
||||
env.sandbox.addEventListener('cb-preset-changed', function (ev) {
|
||||
fired = true;
|
||||
detailId = ev.detail && ev.detail.id;
|
||||
});
|
||||
MCP.applyPreset('prot');
|
||||
assert(fired === true, 'cb-preset-changed event fired on applyPreset()');
|
||||
assert(detailId === 'prot', 'event detail.id === applied preset id (got ' + detailId + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* #1418 — map.js loadRouteFromDeepLink:
|
||||
* - Hop resolution priority (server resolved_path > HopResolver > raw).
|
||||
* - GRP_TXT channel hash → name resolution (enc_ placeholder, SHA-256 byte
|
||||
* match for keyed channels, fallback to "channel 0x<HEX>").
|
||||
*
|
||||
* The deep-link loader is a giant async function; we don't run it end-to-end.
|
||||
* Instead we verify:
|
||||
* 1. Source invariants: priority order is unambiguous in code.
|
||||
* 2. Replica of the chosen-path resolution logic, exercised on fixtures.
|
||||
* 3. Replica of the channel-match predicate (the same `find` callback).
|
||||
* 4. Live SubtleCrypto comparison: SHA-256(name)[0] === target byte
|
||||
* reproduced via node's built-in crypto.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 hop-priority A: source invariants (3-tier priority) ===');
|
||||
// Priority comment is documented; assert the structural keywords are in order.
|
||||
const priorityBlock = mapSrc.match(/Priority:[\s\S]{0,800}rawHops/);
|
||||
assert(!!priorityBlock,
|
||||
'priority block documented in map.js');
|
||||
if (priorityBlock) {
|
||||
const blk = priorityBlock[0];
|
||||
const iResolved = blk.indexOf('resolved_path');
|
||||
const iHopRes = blk.indexOf('HopResolver');
|
||||
const iRaw = blk.indexOf('raw');
|
||||
assert(iResolved >= 0 && iHopRes >= 0 && iRaw >= 0,
|
||||
'priority block mentions all three: resolved_path, HopResolver, raw');
|
||||
assert(iResolved < iHopRes && iHopRes < iRaw,
|
||||
'priority order in comment: resolved_path → HopResolver → raw');
|
||||
}
|
||||
// Structural code path: resolved_path branch checked first, then HopResolver,
|
||||
// then naked rawHops fallback.
|
||||
assert(/if\s*\(\s*Array\.isArray\(resolvedHops\)[^\)]*\)\s*\{[\s\S]{0,200}\}\s*else if\s*\(\s*window\.HopResolver/.test(mapSrc),
|
||||
'code structure: if (resolvedHops valid) else if (window.HopResolver) else (rawHops)');
|
||||
|
||||
console.log('\n=== #1418 hop-priority B: replica of chosen-path selection ===');
|
||||
|
||||
// Replicate the chooseChosenPath logic exactly. window.HopResolver shim
|
||||
// returns a per-pubkey dict; resolveResult[h] is consulted per raw hop.
|
||||
function chooseChosenPath(rawHops, resolvedHopsRaw, hopResolver) {
|
||||
let resolvedHops = null;
|
||||
try {
|
||||
if (resolvedHopsRaw) {
|
||||
resolvedHops = typeof resolvedHopsRaw === 'string' ? JSON.parse(resolvedHopsRaw) : resolvedHopsRaw;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (Array.isArray(resolvedHops) && resolvedHops.length === rawHops.length) {
|
||||
return rawHops.map((h, i) => resolvedHops[i] || h);
|
||||
}
|
||||
if (hopResolver && typeof hopResolver.resolve === 'function' && rawHops.length) {
|
||||
try {
|
||||
const result = hopResolver.resolve(rawHops);
|
||||
return rawHops.map(h => {
|
||||
const r = result ? result[h] : null;
|
||||
return r && r.pubkey ? r.pubkey : h;
|
||||
});
|
||||
} catch (_) { return rawHops; }
|
||||
}
|
||||
return rawHops;
|
||||
}
|
||||
|
||||
const rawHops = ['AA', 'BB', 'CC'];
|
||||
|
||||
// Tier 1: server resolved_path takes priority over HopResolver
|
||||
const serverResolved = ['AAFULL1', 'BBFULL2', 'CCFULL3'];
|
||||
const naiveResolver = { resolve: () => ({ AA: { pubkey: 'WRONG_A' }, BB: { pubkey: 'WRONG_B' }, CC: { pubkey: 'WRONG_C' }}) };
|
||||
let chosen = chooseChosenPath(rawHops, serverResolved, naiveResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
|
||||
'server resolved_path wins over HopResolver (returns ' + JSON.stringify(chosen) + ')');
|
||||
|
||||
// Tier 1 with JSON string input (server returns it stringified sometimes)
|
||||
chosen = chooseChosenPath(rawHops, JSON.stringify(serverResolved), naiveResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
|
||||
'server resolved_path accepts JSON-string input (parses it)');
|
||||
|
||||
// Tier 2: no resolved_path → use HopResolver
|
||||
const smartResolver = { resolve: () => ({ AA: { pubkey: 'AAFULL_DIFF' }, BB: { pubkey: 'BBFULL_DIFF' }, CC: { pubkey: 'CCFULL_DIFF' }}) };
|
||||
chosen = chooseChosenPath(rawHops, null, smartResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
|
||||
'no resolved_path → HopResolver result used (returns ' + JSON.stringify(chosen) + ')');
|
||||
|
||||
// HopResolver returns different from naive prefix → values change
|
||||
chosen = chooseChosenPath(['AB'], null, { resolve: () => ({ AB: { pubkey: 'ABcorrect123' } }) });
|
||||
assert(chosen[0] === 'ABcorrect123',
|
||||
'HopResolver overrides naive prefix when it returns a longer pubkey');
|
||||
|
||||
// HopResolver throws → fallback to raw
|
||||
chosen = chooseChosenPath(rawHops, null, { resolve: () => { throw new Error('boom'); } });
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
|
||||
'HopResolver throw → fallback to rawHops');
|
||||
|
||||
// Tier 3: no resolved_path, no HopResolver → raw prefixes
|
||||
chosen = chooseChosenPath(rawHops, null, null);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
|
||||
'no resolved_path AND no HopResolver → raw prefixes returned as-is');
|
||||
|
||||
// Length mismatch: resolved_path is wrong length → falls through to HopResolver
|
||||
chosen = chooseChosenPath(rawHops, ['only_one'], smartResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
|
||||
'resolved_path with mismatched length → falls through to HopResolver');
|
||||
|
||||
// Per-element falsy in resolved_path → falls back to raw for THAT index
|
||||
chosen = chooseChosenPath(rawHops, ['AAFULL1', null, 'CCFULL3'], null);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL1', 'BB', 'CCFULL3']),
|
||||
'per-index null in resolved_path → falls back to raw for that index only');
|
||||
|
||||
console.log('\n=== #1418 channel A: GRP_TXT match predicate (sync part) ===');
|
||||
|
||||
// Replica of the channel-find predicate from loadRouteFromDeepLink.
|
||||
function findChannelSync(chList, wantHex) {
|
||||
const wantUp = String(wantHex).toUpperCase();
|
||||
return chList.find(c => {
|
||||
const ch = String(c.hash || '').toUpperCase();
|
||||
const nm = String(c.name || '').toUpperCase();
|
||||
return ch.startsWith(wantUp) ||
|
||||
ch === 'ENC_' + wantUp ||
|
||||
nm.includes('0X' + wantUp);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
const channels = [
|
||||
{ hash: 'public_full_hash_AB...', name: 'Public' },
|
||||
{ hash: 'enc_77', name: 'Encrypted (0x77)', encrypted: true },
|
||||
{ hash: 'unknown', name: 'channel 0xCD' }
|
||||
];
|
||||
|
||||
// hash starts with target hex
|
||||
let m = findChannelSync([{ hash: 'AB1234', name: 'Test' }], 'AB');
|
||||
assert(m && m.name === 'Test', 'finds channel where hash starts with target hex');
|
||||
|
||||
// enc_<HEX> placeholder
|
||||
m = findChannelSync(channels, '77');
|
||||
assert(m && m.name === 'Encrypted (0x77)',
|
||||
'matches enc_<HEX> placeholder ("enc_77") for encrypted channel');
|
||||
|
||||
// name contains "0x<HEX>"
|
||||
m = findChannelSync(channels, 'CD');
|
||||
assert(m && m.name === 'channel 0xCD',
|
||||
'matches name containing "0x<HEX>" placeholder');
|
||||
|
||||
// Case-insensitive
|
||||
m = findChannelSync([{ hash: 'enc_ff', name: 'lower' }], 'FF');
|
||||
assert(m && m.name === 'lower', 'case-insensitive match on enc_<HEX>');
|
||||
|
||||
// No match → null (caller falls back to "channel 0x<HEX>")
|
||||
m = findChannelSync(channels, 'XX');
|
||||
assert(m === null, 'no match → null (so caller renders "channel 0x<HEX>" fallback)');
|
||||
|
||||
console.log('\n=== #1418 channel B: SHA-256(name)[0] keyed-channel match ===');
|
||||
|
||||
// The async fallback (SubtleCrypto) computes SHA-256(name)[0] and checks
|
||||
// it against the target byte. Reproduce in node and verify the formula
|
||||
// matches the firmware/decoder convention (first byte of SHA-256).
|
||||
function sha256Byte0(name) {
|
||||
const buf = crypto.createHash('sha256').update(name, 'utf8').digest();
|
||||
return buf[0].toString(16).padStart(2, '0').toUpperCase();
|
||||
}
|
||||
|
||||
// Known channel name → its derived byte
|
||||
const wellKnown = ['Public', 'Test Channel', 'mesh-control', 'general'];
|
||||
wellKnown.forEach(name => {
|
||||
const byte = sha256Byte0(name);
|
||||
assert(/^[0-9A-F]{2}$/.test(byte),
|
||||
'SHA-256("' + name + '")[0] = 0x' + byte + ' (valid 2-hex)');
|
||||
});
|
||||
|
||||
// Construct a fixture where we deliberately want to match channel "Public"
|
||||
const target = sha256Byte0('Public');
|
||||
// Simulate the async match loop: walk the channel list, hash each name,
|
||||
// return the one whose first byte === target.
|
||||
function findChannelAsync(chList, wantHex) {
|
||||
const wantUp = String(wantHex).toUpperCase();
|
||||
for (const c of chList) {
|
||||
if (c.encrypted) continue;
|
||||
if (!c.name) continue;
|
||||
if (sha256Byte0(c.name) === wantUp) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = findChannelAsync([
|
||||
{ name: 'Public' },
|
||||
{ name: 'Other' },
|
||||
{ name: 'Public', encrypted: true } // would match but encrypted → skipped
|
||||
], target);
|
||||
assert(result && result.name === 'Public' && !result.encrypted,
|
||||
'SHA-256 match: returns first non-encrypted channel whose name SHA-256[0] === target byte');
|
||||
|
||||
// Source invariants: the async block exists in map.js
|
||||
assert(/window\.crypto\.subtle/.test(mapSrc), 'map.js uses window.crypto.subtle for SHA-256 fallback');
|
||||
assert(/'SHA-256'/.test(mapSrc), 'map.js requests SHA-256 specifically');
|
||||
assert(/if\s*\(c\.encrypted\)\s*continue/.test(mapSrc),
|
||||
'async loop skips already-known encrypted/placeholder channels');
|
||||
assert(/byteHex\s*===\s*wantUp/.test(mapSrc),
|
||||
'async loop compares first-byte hex to target (byteHex === wantUp)');
|
||||
|
||||
console.log('\n=== #1418 channel C: fallback label format ===');
|
||||
// When no match found, caller renders "Encrypted (0x<HEX>)" for encrypted,
|
||||
// "channel 0x<HEX>" otherwise. Just guard the literal templates exist.
|
||||
assert(/Encrypted \(0x/.test(mapSrc),
|
||||
'encrypted-channel fallback label "Encrypted (0x..." present in map.js');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* #1418 — route-view.js edgeWeight() scales + boundary fix.
|
||||
*
|
||||
* Edge-stroke-width logic (route-view.js `edgeWeight()`):
|
||||
* - Single-path mode → flat 5
|
||||
* - Multi-path interior edge → 3 + ratio*6 (range 3..9)
|
||||
* - Multi-path BOUNDARY edge (origin→hop1 or last-hop→dest) → proxy via
|
||||
* max adjacent edgeCount. Before the recent fix, boundary edges with no
|
||||
* matching prefix returned 1.5 (the floor for unknown interior edges),
|
||||
* visually shrinking origin/dest edges to hairlines.
|
||||
* - Union-of-edges view (in isolatePath/restoreAllPaths) → 2 + ratio*6
|
||||
* (range 2..8).
|
||||
*
|
||||
* Strategy: extract the edgeWeight() function from route-view.js with regex,
|
||||
* eval it into a sandbox seeded with `positions` + `edgeCounts` + `multiPath`
|
||||
* + `totalObservers`, and assert on returns. This exercises the SHIPPING
|
||||
* function — if route-view.js drifts, the test breaks.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 edgeWeight A: source invariants ===');
|
||||
assert(/function\s+edgeWeight\s*\(\s*idx\s*\)/.test(src),
|
||||
'edgeWeight(idx) function exists in route-view.js');
|
||||
assert(/if\s*\(!multiPath\)\s+return\s+5/.test(src),
|
||||
'single-path mode returns flat 5');
|
||||
// Boundary fix invariant: an isOriginEdge / isDestEdge code path exists
|
||||
// and computes a proxy from max adjacent count instead of returning 1.5.
|
||||
assert(/isOriginEdge\s*\|\|\s*isDestEdge/.test(src),
|
||||
'boundary-edge branch present (isOriginEdge || isDestEdge)');
|
||||
assert(/3\s*\+\s*bRatio\s*\*\s*6/.test(src),
|
||||
'boundary branch uses 3 + bRatio*6 scale (not 1.5)');
|
||||
assert(/3\s*\+\s*ratio\s*\*\s*6/.test(src),
|
||||
'interior multi-path uses 3 + ratio*6 (range 3..9)');
|
||||
assert(/2\s*\+\s*ratio\s*\*\s*6/.test(src),
|
||||
'union/isolate view uses 2 + ratio*6 (range 2..8)');
|
||||
|
||||
console.log('\n=== #1418 edgeWeight B: extract + exercise the real function ===');
|
||||
|
||||
// Extract the edgeWeight function body verbatim. The function is declared
|
||||
// inside the IIFE; we regex it out and run it in a sandbox with the closure
|
||||
// variables it expects (positions, edgeCounts, multiPath, totalObservers).
|
||||
const fnMatch = src.match(/function\s+edgeWeight\s*\(\s*idx\s*\)\s*\{[\s\S]*?\n {4}\}/);
|
||||
assert(!!fnMatch, 'edgeWeight() function body extracted from route-view.js');
|
||||
|
||||
function runEdgeWeight(positions, edgeCounts, totalObservers, multiPath, idx) {
|
||||
const ctx = { positions, edgeCounts, totalObservers, multiPath };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fnMatch[0] + '; result = edgeWeight(' + idx + ');', ctx);
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
// --- Single-path mode: always 5 ---
|
||||
const singlePos = [
|
||||
{ pubkey: 'AABB', isOrigin: true },
|
||||
{ pubkey: 'CCDD' },
|
||||
{ pubkey: 'EEFF', isDest: true }
|
||||
];
|
||||
assert(runEdgeWeight(singlePos, {}, 1, false, 0) === 5,
|
||||
'single-path mode: edgeWeight(0) === 5');
|
||||
assert(runEdgeWeight(singlePos, { 'AA→CC': 99 }, 50, false, 1) === 5,
|
||||
'single-path mode: edgeWeight(1) === 5 regardless of edgeCounts');
|
||||
|
||||
// --- Multi-path INTERIOR edge: 3 + ratio*6 ---
|
||||
const mPos = [
|
||||
{ pubkey: 'AABB', isOrigin: true }, // origin
|
||||
{ pubkey: 'CCDD' }, // hop 1 (interior start)
|
||||
{ pubkey: 'EEFF' }, // hop 2 (interior end)
|
||||
{ pubkey: 'GG00', isDest: true } // dest
|
||||
];
|
||||
// Edge 1: CC→EE. edgeCounts has CC→EE: 5 of 10 observers → ratio 0.5
|
||||
// expected = 3 + 0.5*6 = 6
|
||||
let w = runEdgeWeight(mPos, { 'CC→EE': 5 }, 10, true, 1);
|
||||
assert(Math.abs(w - 6) < 0.001,
|
||||
'multi-path interior: ratio 0.5 → weight 6 (got ' + w + ')');
|
||||
// Full coverage: ratio 1.0 → weight 9
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 10 }, 10, true, 1);
|
||||
assert(Math.abs(w - 9) < 0.001,
|
||||
'multi-path interior: ratio 1.0 → weight 9 (got ' + w + ')');
|
||||
// No matching count: falls through to 1.5 floor
|
||||
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 1);
|
||||
assert(w === 1.5,
|
||||
'multi-path interior: no matching edge → 1.5 hairline floor (got ' + w + ')');
|
||||
|
||||
// --- BOUNDARY edge fix: origin→hop1 ---
|
||||
// idx=0: AA(isOrigin) → CC. edgeCounts has CC→EE: 8 of 10
|
||||
// Boundary proxy: look for edges where a==CC (the next-to-boundary node)
|
||||
// 8/10 → weight = 3 + 0.8*6 = 7.8
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 8 }, 10, true, 0);
|
||||
assert(Math.abs(w - 7.8) < 0.001,
|
||||
'boundary edge (origin→hop1): proxied by adjacent CC→EE count 8/10 → 7.8 (got ' + w + ')');
|
||||
|
||||
// --- BOUNDARY edge fix: last-hop→dest ---
|
||||
// idx=2: EE → GG(isDest). Look for edges where b==EE (the from-boundary node)
|
||||
// edgeCounts CC→EE: 7 of 10 → 3 + 0.7*6 = 7.2
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 7 }, 10, true, 2);
|
||||
assert(Math.abs(w - 7.2) < 0.001,
|
||||
'boundary edge (last-hop→dest): proxied by adjacent CC→EE count 7/10 → 7.2 (got ' + w + ')');
|
||||
|
||||
// --- REGRESSION GUARD: boundary edge with NO adjacent edgeCount must NOT
|
||||
// return 1.5 (the old bug). It returns 5 as the documented fallback. ---
|
||||
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 0);
|
||||
assert(w === 5,
|
||||
'boundary edge with no adjacent edgeCount returns 5 (NOT the old 1.5 bug) — got ' + w);
|
||||
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 2);
|
||||
assert(w === 5,
|
||||
'boundary edge (last-hop→dest) with no adjacent count → 5 (NOT 1.5) — got ' + w);
|
||||
|
||||
// --- Multiple matching adjacent edges: use MAX, not sum ---
|
||||
// idx=0: AA(origin)→CC. edgeCounts has CC→EE:3 and CC→FF:7. Max is 7 → 3+0.7*6=7.2
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 3, 'CC→FF': 7 }, 10, true, 0);
|
||||
assert(Math.abs(w - 7.2) < 0.001,
|
||||
'boundary edge: picks MAX adjacent count (max of 3,7 = 7 → 7.2) — got ' + w);
|
||||
|
||||
console.log('\n=== #1418 edgeWeight C: isolated-path union weight (2 + ratio*6) ===');
|
||||
// The 2+ratio*6 formula is in the isolatePath() block. Source-grep guarantees
|
||||
// its presence. Verify the literal expression is unique (not stripped).
|
||||
const occurrences2 = (src.match(/2\s*\+\s*ratio\s*\*\s*6/g) || []).length;
|
||||
assert(occurrences2 >= 1, 'isolatePath union weight formula (2 + ratio*6) present at least once');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* #1418 / PR #1423 polish-review guards.
|
||||
*
|
||||
* Source-grep guards for the polish-review findings addressed on the
|
||||
* route-view feature branch. Each guard pins one finding so future edits
|
||||
* can't silently regress the fix.
|
||||
*
|
||||
* Findings covered (see PR #1423 review comments for full context):
|
||||
* - resize listener leak (carmack/munger)
|
||||
* - 5-staggered-timer fit storm (tufte/doshi)
|
||||
* - empty catch {} swallowing errors (torvalds)
|
||||
* - _detailCache unbounded (carmack) — LRU(50)
|
||||
* - recolorRoute walks document.querySelectorAll (torvalds) — scoped
|
||||
* - deep-link silent failure (doshi) — toast on empty paths
|
||||
* - innerHTML row re-wire factored (dijkstra) — wireRow helper
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rvSrc = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== A. resize listener leak fix (carmack/munger) ===');
|
||||
// Single resize listener attached via window.__mc_routeResizeRefit stash,
|
||||
// torn down on next render() + on teardownIfNavigatedAway.
|
||||
assert(/window\.__mc_routeResizeRefit/.test(rvSrc),
|
||||
'resize handler stashed on window.__mc_routeResizeRefit for dedupe');
|
||||
assert(/removeEventListener\(['"]resize['"],\s*window\.__mc_routeResizeRefit\)/.test(rvSrc),
|
||||
'prior resize handler removed before attaching new one');
|
||||
// Old buggy pattern (anonymous resize listener with no removal) must be gone.
|
||||
const anonResize = rvSrc.match(/window\.addEventListener\(['"]resize['"]\s*,\s*function/g) || [];
|
||||
assert(anonResize.length === 0,
|
||||
'no anonymous window.resize listeners (all go via __mc_routeResizeRefit) — found ' + anonResize.length);
|
||||
|
||||
console.log('\n=== B. fit-storm collapse to rAF (tufte/doshi) ===');
|
||||
// The 5-staggered (0/300/800/1600/2800) and 3-staggered (0/200/600/1400)
|
||||
// timers MUST be gone. Single requestAnimationFrame is the replacement.
|
||||
const bigFitStorm = /setTimeout\(\s*refit\s*,\s*(?:300|800|1600|2800)\s*\)/.test(rvSrc);
|
||||
assert(!bigFitStorm, 'no setTimeout(refit, 300|800|1600|2800) staggered fit storm');
|
||||
const isoFitStorm = /setTimeout\(\s*doFit\s*,\s*(?:200|600|1400)\s*\)/.test(rvSrc);
|
||||
assert(!isoFitStorm, 'no setTimeout(doFit, 200|600|1400) staggered isolate-fit storm');
|
||||
const restoreFitStorm = /setTimeout\(\s*_restoreFit\s*,\s*(?:200|600|1400)\s*\)/.test(rvSrc);
|
||||
assert(!restoreFitStorm, 'no setTimeout(_restoreFit, 200|600|1400) staggered restore-fit storm');
|
||||
assert(/requestAnimationFrame\(\s*refit\s*\)/.test(rvSrc),
|
||||
'requestAnimationFrame(refit) is the new initial-settle path');
|
||||
assert(/requestAnimationFrame\(\s*doFit\s*\)/.test(rvSrc),
|
||||
'requestAnimationFrame(doFit) replaces isolate-path staggered timers');
|
||||
assert(/new ResizeObserver/.test(rvSrc),
|
||||
'ResizeObserver attached to map container for layout-settle re-fit');
|
||||
|
||||
console.log('\n=== C. ResizeObserver lifecycle (carmack) ===');
|
||||
assert(/window\.__mc_routeResizeObserver/.test(rvSrc),
|
||||
'ResizeObserver stashed on window.__mc_routeResizeObserver for dedupe');
|
||||
assert(/__mc_routeResizeObserver[^;]*\.disconnect\(\)/.test(rvSrc),
|
||||
'ResizeObserver disconnected on render() re-entry + teardown');
|
||||
|
||||
console.log('\n=== D. _detailCache LRU bound (carmack) ===');
|
||||
assert(/_detailCache\s*=\s*new\s+Map\(\)/.test(rvSrc),
|
||||
'_detailCache is a Map (LRU-capable) not a plain object');
|
||||
assert(/DETAIL_CACHE_MAX/.test(rvSrc),
|
||||
'DETAIL_CACHE_MAX constant defined (LRU bound)');
|
||||
assert(/_detailCache\.size\s*>=?\s*DETAIL_CACHE_MAX/.test(rvSrc),
|
||||
'LRU eviction guard checks _detailCache.size against DETAIL_CACHE_MAX');
|
||||
|
||||
console.log('\n=== E. catch {} silent swallow → console.warn (torvalds) ===');
|
||||
// Empty `catch (e) {}` (no body) count should be near zero. A handful may
|
||||
// remain where the catch is genuinely a "best-effort" no-op — but the
|
||||
// review flagged 20+ silent swallows; we should be down to ≤5 after the pass.
|
||||
// Empty `catch (e) {}` (no body) count for full-block catches (e). The
|
||||
// inline `} catch (_) {}` no-op removers are intentional (marker may
|
||||
// already be detached). The review flagged 20+ silent block swallows;
|
||||
// after the pass the remaining ones must be legitimately benign
|
||||
// (localStorage may be disabled, marker may have been removed in a race).
|
||||
const blockEmptyCatches = (rvSrc.match(/\}\s*catch\s*\(\s*e\s*\)\s*\{\s*\}/g) || []).length;
|
||||
assert(blockEmptyCatches <= 8,
|
||||
'block-style silent `} catch (e) {}` reduced to ≤8 (was 20+) — current: ' + blockEmptyCatches);
|
||||
assert(/console\.warn\(['"]\[route-view\]/.test(rvSrc),
|
||||
'at least one [route-view] console.warn breadcrumb present');
|
||||
|
||||
console.log('\n=== F. recolorRoute scoped to sidebar (torvalds) ===');
|
||||
// The walks must be scoped to the active sidebar root, not document-wide.
|
||||
// We allow document.querySelectorAll for `.mc-rt-sidebar` (the tear-down)
|
||||
// but NOT for `.mc-rt-edge` / `.mc-rt-row` / `.mc-rt-spark-dot`.
|
||||
const docEdges = /document\.querySelectorAll\(['"]\.mc-rt-edge['"]\)/.test(rvSrc);
|
||||
assert(!docEdges, 'recolorRoute no longer walks document.querySelectorAll(.mc-rt-edge)');
|
||||
const docRows = /document\.querySelectorAll\(['"]\.mc-rt-row['"]\)/.test(rvSrc);
|
||||
assert(!docRows, 'recolorRoute no longer walks document.querySelectorAll(.mc-rt-row)');
|
||||
|
||||
console.log('\n=== G. deep-link empty-paths toast (doshi) ===');
|
||||
// When allPaths.length === 0, surface a sidebar/console message instead of
|
||||
// silently bailing.
|
||||
assert(/allPaths\.length\s*===\s*0[\s\S]{0,400}(?:console\.warn|alert|toast|showToast|notif)/i.test(mapSrc),
|
||||
'deep-link empty-paths path emits a console.warn / toast (no silent return)');
|
||||
|
||||
console.log('\n=== H. wireRow row-wireup helper (dijkstra) ===');
|
||||
assert(/function\s+wireRow\s*\(\s*row\s*\)/.test(rvSrc),
|
||||
'wireRow(row) helper centralizes row event wiring');
|
||||
assert(/sidebar\._wireRow\s*=\s*wireRow/.test(rvSrc),
|
||||
'wireRow stashed on sidebar so restoreAllPaths can reuse');
|
||||
assert(/newRowEls\.forEach\(\s*sidebar\._wireRow/.test(rvSrc),
|
||||
'restoreAllPaths re-wires rows via sidebar._wireRow (not inline duplicate)');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* #1418 — map.js loadRouteFromDeepLink raw_hex byte extraction.
|
||||
*
|
||||
* The deep-link loader peeks at chosen.raw_hex when decoded JSON is empty,
|
||||
* to extract src/destHash and (for GRP_TXT) channel_hash. Wire layout per
|
||||
* cmd/ingestor/decoder.go:
|
||||
* byte0=route+type, byte1=path_len, then path bytes, then ...
|
||||
*
|
||||
* TXT_MSG (type 2): destHash + srcHash bytes after path
|
||||
* RESPONSE (type 1): destHash + srcHash bytes after path
|
||||
* ANON_REQ (type 7): destHash ONLY (no srcHash byte — sender anonymous)
|
||||
* PATH (type 8): destHash + srcHash bytes after path
|
||||
* GRP_TXT (type 5): channel_hash byte after path
|
||||
*
|
||||
* This test asserts behavior by replicating the exact extraction logic
|
||||
* from public/map.js and exercising it on hand-built raw_hex fixtures
|
||||
* built to mirror real wire packets.
|
||||
*
|
||||
* Source invariants (string grep on map.js) also guarded so any code-move
|
||||
* that drops the extraction is caught.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 raw_hex A: source invariants in map.js ===');
|
||||
assert(/TYPES_WITH_DST_SRC\s*=\s*\[\s*1\s*,\s*2\s*,\s*7\s*,\s*8\s*\]/.test(mapSrc),
|
||||
'TYPES_WITH_DST_SRC = [1, 2, 7, 8] (RESPONSE, TXT_MSG, ANON_REQ, PATH)');
|
||||
assert(/payload_type\s*!==\s*7/.test(mapSrc),
|
||||
'ANON_REQ (type 7) special-cased to skip srcHash extraction');
|
||||
assert(/payload_type\s*===\s*5/.test(mapSrc),
|
||||
'GRP_TXT (type 5) branch present for channel_hash extraction');
|
||||
assert(/PAYLOAD_TYPE_MAP\s*=\s*\{[^}]*0:\s*'REQ'[^}]*1:\s*'RESPONSE'[^}]*2:\s*'TXT_MSG'/m.test(mapSrc),
|
||||
'PAYLOAD_TYPE_MAP covers 0=REQ, 1=RESPONSE, 2=TXT_MSG');
|
||||
assert(/5:\s*'GRP_TXT'[^}]*7:\s*'ANON_REQ'[^}]*8:\s*'PATH'/m.test(mapSrc),
|
||||
'PAYLOAD_TYPE_MAP covers 5=GRP_TXT, 7=ANON_REQ, 8=PATH');
|
||||
|
||||
// Polish review (djb): pathLen MUST be bounded before slicing. A crafted
|
||||
// pathLen=200 byte would surface random body bytes as srcHash/destHash.
|
||||
// Cap at MeshCore wire max of 64 hops in BOTH the TXT-family branch and
|
||||
// the GRP_TXT channel-hash branch.
|
||||
assert((mapSrc.match(/pathLen[^>]*>\s*64/g) || []).length >= 2,
|
||||
'raw_hex pathLen capped at >64 in both TXT and GRP_TXT branches (#1423 review/djb)');
|
||||
assert(/Number\.isFinite\(pathLen\)/.test(mapSrc),
|
||||
'raw_hex pathLen guarded with Number.isFinite (rejects NaN from non-hex byte)');
|
||||
|
||||
console.log('\n=== #1418 raw_hex B: replica extractor reproduces map.js logic ===');
|
||||
|
||||
// Pure replica of the extractor inside loadRouteFromDeepLink. If map.js's
|
||||
// logic changes, this replica MUST be updated and the diff explained.
|
||||
function extractSrcDst(rawHex, payloadType) {
|
||||
const TYPES = [1, 2, 7, 8];
|
||||
if (TYPES.indexOf(payloadType) < 0) return { src: null, dst: null };
|
||||
try {
|
||||
const pathLen = parseInt(rawHex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
|
||||
return { src: null, dst: null };
|
||||
}
|
||||
const destOff = 4 + pathLen * 2;
|
||||
if (rawHex.length < destOff + 2) return { src: null, dst: null };
|
||||
const dst = rawHex.slice(destOff, destOff + 2).toUpperCase();
|
||||
let src = null;
|
||||
if (payloadType !== 7 && rawHex.length >= destOff + 4) {
|
||||
src = rawHex.slice(destOff + 2, destOff + 4).toUpperCase();
|
||||
}
|
||||
return { src, dst };
|
||||
} catch (_) { return { src: null, dst: null }; }
|
||||
}
|
||||
|
||||
function extractChannelHash(rawHex, payloadType) {
|
||||
if (payloadType !== 5) return null;
|
||||
try {
|
||||
const pathLen = parseInt(rawHex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) return null;
|
||||
const chOff = 4 + pathLen * 2;
|
||||
if (rawHex.length < chOff + 2) return null;
|
||||
return rawHex.slice(chOff, chOff + 2).toUpperCase();
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
// Build a hex string: route+type byte, path_len, path bytes, then payload.
|
||||
function build(routeType, pathBytes, payloadBytes) {
|
||||
const lenHex = pathBytes.length.toString(16).padStart(2, '0');
|
||||
return routeType + lenHex + pathBytes.join('') + payloadBytes.join('');
|
||||
}
|
||||
|
||||
// Fixture 1: TXT_MSG (type 2), 2 path hops AB,CD, destHash=42, srcHash=99
|
||||
const txt = build('02', ['AB', 'CD'], ['42', '99', 'FF', 'EE']);
|
||||
let r = extractSrcDst(txt, 2);
|
||||
assert(r.dst === '42' && r.src === '99',
|
||||
'TXT_MSG (type 2) extracts destHash=42, srcHash=99 after 2-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
|
||||
|
||||
// Fixture 2: RESPONSE (type 1), 0-hop path
|
||||
const resp = build('01', [], ['7A', '3C']);
|
||||
r = extractSrcDst(resp, 1);
|
||||
assert(r.dst === '7A' && r.src === '3C',
|
||||
'RESPONSE (type 1) extracts destHash + srcHash on 0-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
|
||||
|
||||
// Fixture 3: ANON_REQ (type 7) — destHash present, srcHash MUST be null
|
||||
const anon = build('07', ['11'], ['DD', 'BB', 'CC']);
|
||||
r = extractSrcDst(anon, 7);
|
||||
assert(r.dst === 'DD', 'ANON_REQ (type 7) extracts destHash=DD');
|
||||
assert(r.src === null, 'ANON_REQ (type 7) MUST NOT extract srcHash (anonymous sender) — got ' + r.src);
|
||||
|
||||
// Fixture 4: PATH (type 8) carries both hashes
|
||||
const pathPkt = build('08', ['AA', 'BB', 'CC'], ['11', '22']);
|
||||
r = extractSrcDst(pathPkt, 8);
|
||||
assert(r.dst === '11' && r.src === '22',
|
||||
'PATH (type 8) extracts destHash + srcHash after 3-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
|
||||
|
||||
// Fixture 5: GRP_TXT (type 5) — channel_hash extraction, NOT src/dst
|
||||
const grp = build('05', ['77'], ['AB', 'XX']);
|
||||
const ch = extractChannelHash(grp, 5);
|
||||
assert(ch === 'AB', 'GRP_TXT (type 5) extracts channel_hash=AB after 1-hop path (got ' + ch + ')');
|
||||
r = extractSrcDst(grp, 5);
|
||||
assert(r.src === null && r.dst === null,
|
||||
'GRP_TXT (type 5) is NOT in TYPES_WITH_DST_SRC — extractor returns nulls');
|
||||
|
||||
// Fixture 6: non-extracting types (REQ=0, ACK=3, ADVERT=4, MULTIPART=10, …)
|
||||
[0, 3, 4, 6, 9, 10, 11, 12].forEach(function (pt) {
|
||||
r = extractSrcDst('00' + '00' + 'FFFF', pt);
|
||||
assert(r.src === null && r.dst === null,
|
||||
'payload_type=' + pt + ' (not in TYPES_WITH_DST_SRC) → no extraction');
|
||||
});
|
||||
|
||||
// Edge case: raw_hex too short (path length claims more bytes than present)
|
||||
r = extractSrcDst('02' + '04' + 'AB', 2); // claims 4-hop path, only 1 byte payload
|
||||
assert(r.src === null && r.dst === null, 'truncated raw_hex → null extraction (no crash)');
|
||||
|
||||
// Polish review (djb): malicious pathLen=200 (0xC8) MUST be rejected even
|
||||
// when the body is long enough to slice. Without the cap, the extractor
|
||||
// would surface random body bytes as src/destHash strings in the UI.
|
||||
const evil = '02' + 'C8' + 'AB'.repeat(500); // pathLen=200, plenty of body to slice
|
||||
r = extractSrcDst(evil, 2);
|
||||
assert(r.src === null && r.dst === null,
|
||||
'malicious pathLen=200 → rejected, no OOB-style byte surfacing');
|
||||
const evilCh = extractChannelHash('05' + 'C8' + 'AB'.repeat(500), 5);
|
||||
assert(evilCh === null, 'malicious pathLen=200 (GRP_TXT) → rejected');
|
||||
// Boundary: pathLen=64 (max) still works; 65 rejected.
|
||||
const okBig = '02' + '40' + 'AB'.repeat(64) + 'EE' + 'FF';
|
||||
r = extractSrcDst(okBig, 2);
|
||||
assert(r.dst === 'EE' && r.src === 'FF', 'pathLen=64 (max allowed) still extracts');
|
||||
const tooBig = '02' + '41' + 'AB'.repeat(65) + 'EE' + 'FF';
|
||||
r = extractSrcDst(tooBig, 2);
|
||||
assert(r.src === null && r.dst === null, 'pathLen=65 → rejected (above wire max of 64)');
|
||||
|
||||
console.log('\n=== #1418 raw_hex C: channel_hash NOT extracted for non-GRP_TXT ===');
|
||||
[0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12].forEach(function (pt) {
|
||||
const v = extractChannelHash('05' + '00' + 'AB', pt);
|
||||
assert(v === null, 'payload_type=' + pt + ' returns null channel_hash');
|
||||
});
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* #1418 — route-view.js spider-fan collision logic + loop marker invariants.
|
||||
*
|
||||
* Spider-fan rules (per route-view.js Tufte v7):
|
||||
* - Pixel-distance threshold: COLLISION_THRESHOLD = 14px. Markers within
|
||||
* 14px of each other are grouped and fanned onto an arc of R = 16px.
|
||||
* - Markers further apart than 14px are NOT fanned (no group, kept put).
|
||||
* - Each marker's original LatLng is cached in mk._origLatLng so repeated
|
||||
* re-fan passes (zoom changes, re-render) don't drift.
|
||||
* - Loop case (SRC.pubkey === DST.pubkey, same physical node) → endpoint
|
||||
* markers built with isLoop=true → bigger SVG (size 28) + double
|
||||
* concentric ring (r=10 and r=13 stroke-only circles).
|
||||
*
|
||||
* Strategy: replicate the grouping algorithm verbatim from the IIFE,
|
||||
* apply it to synthetic pixel coordinates, and assert grouping decisions.
|
||||
* Then exercise buildMarkerSVG() by extracting it and checking the loop-
|
||||
* specific SVG markup.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 spider A: source invariants ===');
|
||||
assert(/COLLISION_THRESHOLD\s*=\s*14/.test(src),
|
||||
'COLLISION_THRESHOLD === 14 (pixel proximity for fan trigger)');
|
||||
assert(/var\s+R\s*=\s*16/.test(src),
|
||||
'fan radius R === 16 px (arc that markers offset onto)');
|
||||
assert(/_origLatLng/.test(src),
|
||||
'_origLatLng cache field present (prevents drift on repeated fans)');
|
||||
assert(/if\s*\(!mk\._origLatLng\)\s*mk\._origLatLng\s*=\s*ll/.test(src),
|
||||
'_origLatLng written only ONCE (idempotent — repeated fans use cached origin)');
|
||||
assert(/srcDstSameNode/.test(src),
|
||||
'srcDstSameNode detection (loop case) present');
|
||||
assert(/isLoop:\s*isLoop/.test(src) || /isLoop:\s*srcDstSameNode/.test(src),
|
||||
'isLoop flag passed into buildMarkerSVG for endpoint markers');
|
||||
assert(/if\s*\(isLoop\)\s*size\s*=\s*28/.test(src),
|
||||
'isLoop marker grows to size 28 (vs default 22)');
|
||||
// Two stroke circles for loop endpoints (r=10 endpoint ring + r=13 outer)
|
||||
assert(/r="10"[^>]*fill="none"/.test(src) && /r="13"[^>]*fill="none"/.test(src),
|
||||
'loop markers render double concentric ring (r=10 endpoint + r=13 outer)');
|
||||
|
||||
console.log('\n=== #1418 spider B: replicate grouping logic ===');
|
||||
|
||||
// Verbatim grouping algorithm from spiderFanFor() in route-view.js.
|
||||
// Inputs: array of { x, y } pixel points. Output: array of groups (arrays
|
||||
// of point objects); singletons are NOT returned (matches "if (group.length > 1)").
|
||||
function groupCollisions(pts, threshold) {
|
||||
const visited = {};
|
||||
const groups = [];
|
||||
pts.forEach(function (a, ai) {
|
||||
if (visited[ai]) return;
|
||||
const group = [a];
|
||||
visited[ai] = true;
|
||||
pts.forEach(function (b, bi) {
|
||||
if (bi === ai || visited[bi]) return;
|
||||
const dx = a.x - b.x, dy = a.y - b.y;
|
||||
if (Math.sqrt(dx*dx + dy*dy) < threshold) { group.push(b); visited[bi] = true; }
|
||||
});
|
||||
if (group.length > 1) groups.push(group);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Case 1: two markers 10px apart → grouped (within 14px)
|
||||
let g = groupCollisions([{x:100,y:100},{x:108,y:106}], 14);
|
||||
assert(g.length === 1 && g[0].length === 2,
|
||||
'two markers 10px apart → one group of 2');
|
||||
|
||||
// Case 2: two markers 50px apart → NOT grouped
|
||||
g = groupCollisions([{x:100,y:100},{x:150,y:100}], 14);
|
||||
assert(g.length === 0,
|
||||
'two markers 50px apart → no group (no fan)');
|
||||
|
||||
// Case 3: three markers, two overlap (3px) and one 30px away
|
||||
g = groupCollisions([{x:100,y:100},{x:101,y:103},{x:130,y:100}], 14);
|
||||
assert(g.length === 1 && g[0].length === 2,
|
||||
'three markers: two close + one far → one group of 2 (singleton excluded)');
|
||||
|
||||
// Case 4: exactly at threshold (14px). Spec uses strict-less-than → NOT grouped.
|
||||
g = groupCollisions([{x:0,y:0},{x:14,y:0}], 14);
|
||||
assert(g.length === 0, 'exactly 14px apart → NOT grouped (strict < threshold)');
|
||||
|
||||
// Case 5: cluster of 4 within 14px each → single group of 4
|
||||
g = groupCollisions([{x:100,y:100},{x:102,y:101},{x:99,y:104},{x:101,y:99}], 14);
|
||||
assert(g.length === 1 && g[0].length === 4,
|
||||
'cluster of 4 within threshold → single group of 4');
|
||||
|
||||
console.log('\n=== #1418 spider C: extract buildMarkerSVG + verify loop output ===');
|
||||
|
||||
const fnMatch = src.match(/function\s+buildMarkerSVG\s*\(\s*p\s*,\s*opts\s*\)\s*\{[\s\S]*?\n {2}\}\n/);
|
||||
assert(!!fnMatch, 'buildMarkerSVG() function body extracted');
|
||||
|
||||
if (fnMatch) {
|
||||
const ctx = {};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fnMatch[0], ctx);
|
||||
// Normal endpoint (origin, not loop)
|
||||
const normal = ctx.buildMarkerSVG({ isOrigin: true, resolved: true }, { color: '#3b82f6', seqNum: 1, isLoop: false });
|
||||
assert(normal.size === 22, 'non-loop endpoint marker size === 22 (got ' + normal.size + ')');
|
||||
assert(/r="10"[^>]*fill="none"/.test(normal.html), 'non-loop endpoint has the r=10 single ring');
|
||||
assert(!/r="13"/.test(normal.html), 'non-loop endpoint does NOT have the r=13 outer ring');
|
||||
|
||||
// Loop endpoint
|
||||
const loop = ctx.buildMarkerSVG({ isOrigin: true, resolved: true }, { color: '#3b82f6', seqNum: 1, isLoop: true });
|
||||
assert(loop.size === 28, 'loop marker size === 28 (got ' + loop.size + ')');
|
||||
assert(/r="10"[^>]*fill="none"/.test(loop.html), 'loop endpoint has the r=10 inner ring');
|
||||
assert(/r="13"[^>]*fill="none"/.test(loop.html), 'loop endpoint has the r=13 outer ring');
|
||||
// SVG viewBox should match the larger size
|
||||
assert(/viewBox="0 0 28 28"/.test(loop.html), 'loop marker SVG viewBox is 0 0 28 28');
|
||||
|
||||
// Interior (non-endpoint) marker — no ring
|
||||
const inner = ctx.buildMarkerSVG({ resolved: true }, { color: '#3b82f6', seqNum: 2, isLoop: false });
|
||||
assert(!/r="10"[^>]*fill="none"/.test(inner.html),
|
||||
'interior marker has NO endpoint ring (only main 8px filled circle)');
|
||||
|
||||
// Unresolved hop renders dashed muted circle
|
||||
const unres = ctx.buildMarkerSVG({ resolved: false }, { color: '#3b82f6', seqNum: 3 });
|
||||
assert(/stroke-dasharray="2 2"/.test(unres.html),
|
||||
'unresolved hop rendered with stroke-dasharray="2 2"');
|
||||
}
|
||||
|
||||
console.log('\n=== #1418 spider D: srcDstSameNode loop detection invariants ===');
|
||||
// The detection condition must be lowercase-compared (route-view does
|
||||
// String(...).toLowerCase() === String(...).toLowerCase()) so AaBb === aabb.
|
||||
assert(/positions\[0\]\.pubkey[\s\S]{0,80}positions\[positions\.length-1\]\.pubkey/.test(src),
|
||||
'srcDstSameNode compares positions[0].pubkey vs positions[last].pubkey');
|
||||
assert(/toLowerCase\(\)\s*===\s*String\(positions\[positions\.length-1\]\.pubkey\)\.toLowerCase\(\)/.test(src),
|
||||
'pubkey loop-equality is case-insensitive (toLowerCase on both sides)');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,181 +0,0 @@
|
||||
/* test-issue-1420-tile-providers.js — Dark-tile provider registry tests.
|
||||
*
|
||||
* Asserts the MC_TILE_PROVIDERS registry shape, persistence helpers,
|
||||
* and CSS-filter swap behavior for inverted variants. Tests via VM
|
||||
* sandbox (no jsdom dep). Follows the pattern from cb-presets tests.
|
||||
*/
|
||||
'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); }
|
||||
}
|
||||
|
||||
function makeStorage() {
|
||||
const store = {};
|
||||
return {
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },
|
||||
setItem(k, v) { store[k] = String(v); },
|
||||
removeItem(k) { delete store[k]; },
|
||||
clear() { for (const k of Object.keys(store)) delete store[k]; },
|
||||
_raw: store
|
||||
};
|
||||
}
|
||||
|
||||
function makeSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const events = [];
|
||||
const listeners = {};
|
||||
const tilePane = { style: { filter: '' } };
|
||||
const ctx = {
|
||||
console,
|
||||
setTimeout, clearTimeout,
|
||||
JSON, Date, Math, Object, Array, String, Number, Boolean,
|
||||
localStorage: makeStorage(),
|
||||
document: {
|
||||
documentElement: { getAttribute: () => opts.theme || 'dark' },
|
||||
querySelector: (sel) => sel === '.leaflet-tile-pane' ? tilePane : null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => {},
|
||||
},
|
||||
window: {
|
||||
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
|
||||
},
|
||||
CustomEvent: function (type, init) { this.type = type; this.detail = (init && init.detail) || null; }
|
||||
};
|
||||
ctx.window.localStorage = ctx.localStorage;
|
||||
ctx.globalThis = ctx;
|
||||
vm.createContext(ctx);
|
||||
// Make window mirror globals (the module uses window.X assignment)
|
||||
ctx.window.document = ctx.document;
|
||||
ctx.events = events;
|
||||
ctx.listeners = listeners;
|
||||
ctx.tilePane = tilePane;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadProviders(ctx) {
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'map-tile-providers.js'), 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
}
|
||||
|
||||
console.log('── #1420 Dark-tile provider registry ──');
|
||||
|
||||
test('MC_TILE_PROVIDERS has all 4 IDs with url + attribution', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadProviders(ctx);
|
||||
const reg = ctx.window.MC_TILE_PROVIDERS;
|
||||
assert.ok(reg, 'registry must exist on window');
|
||||
const ids = ['carto-dark', 'esri-darkgray-labels', 'voyager-inverted', 'positron-inverted'];
|
||||
for (const id of ids) {
|
||||
assert.ok(reg[id], 'missing provider: ' + id);
|
||||
assert.ok(typeof reg[id].attribution === 'string' && reg[id].attribution.length > 0, id + ' attribution');
|
||||
// Esri uses baseUrl + refUrl; others use url
|
||||
if (id === 'esri-darkgray-labels') {
|
||||
assert.ok(typeof reg[id].baseUrl === 'string' && reg[id].baseUrl.indexOf('{z}') >= 0, 'esri baseUrl');
|
||||
assert.ok(typeof reg[id].refUrl === 'string' && reg[id].refUrl.indexOf('{z}') >= 0, 'esri refUrl');
|
||||
} else {
|
||||
assert.ok(typeof reg[id].url === 'string' && reg[id].url.indexOf('{z}') >= 0, id + ' url has {z}');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Inverted providers have non-null invertFilter; non-inverted have null', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadProviders(ctx);
|
||||
const reg = ctx.window.MC_TILE_PROVIDERS;
|
||||
assert.strictEqual(reg['carto-dark'].invertFilter, null);
|
||||
assert.strictEqual(reg['esri-darkgray-labels'].invertFilter, null);
|
||||
assert.ok(typeof reg['voyager-inverted'].invertFilter === 'string' && reg['voyager-inverted'].invertFilter.indexOf('invert(') >= 0);
|
||||
assert.ok(typeof reg['positron-inverted'].invertFilter === 'string' && reg['positron-inverted'].invertFilter.indexOf('invert(') >= 0);
|
||||
});
|
||||
|
||||
test('MC_setDarkTileProvider persists to localStorage and dispatches mc-tile-provider-changed', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadProviders(ctx);
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), 'voyager-inverted');
|
||||
assert.ok(ctx.events.length >= 1, 'event dispatched');
|
||||
const ev = ctx.events[ctx.events.length - 1];
|
||||
assert.strictEqual(ev.type, 'mc-tile-provider-changed');
|
||||
assert.ok(ev.detail && ev.detail.id === 'voyager-inverted');
|
||||
});
|
||||
|
||||
test('MC_setDarkTileProvider rejects unknown IDs (no persistence, no dispatch)', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadProviders(ctx);
|
||||
const ok = ctx.window.MC_setDarkTileProvider('not-a-real-provider');
|
||||
assert.strictEqual(ok, false);
|
||||
assert.strictEqual(ctx.localStorage.getItem('mc-dark-tile-provider'), null);
|
||||
assert.strictEqual(ctx.events.length, 0);
|
||||
});
|
||||
|
||||
test('MC_getDarkTileProvider falls back to server default, then carto-dark', () => {
|
||||
const ctx = makeSandbox();
|
||||
loadProviders(ctx);
|
||||
// No localStorage, no server hint → default carto-dark
|
||||
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'carto-dark');
|
||||
// Server-provided default surfaces through
|
||||
ctx.window.MC_setServerDefaultTileProvider('esri-darkgray-labels');
|
||||
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'esri-darkgray-labels');
|
||||
// localStorage wins over server
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
assert.strictEqual(ctx.window.MC_getDarkTileProvider(), 'voyager-inverted');
|
||||
});
|
||||
|
||||
test('Apply filter for inverted provider in dark mode; clear when switching to non-inverted', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadProviders(ctx);
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
ctx.window.MC_applyTileFilter(); // dark theme + inverted → filter set
|
||||
assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'filter applied: ' + ctx.tilePane.style.filter);
|
||||
ctx.window.MC_setDarkTileProvider('carto-dark');
|
||||
ctx.window.MC_applyTileFilter();
|
||||
assert.strictEqual(ctx.tilePane.style.filter, '', 'filter cleared after switching to carto-dark');
|
||||
});
|
||||
|
||||
test('Light mode always clears the CSS filter even if inverted provider is selected', () => {
|
||||
const ctx = makeSandbox({ theme: 'light' });
|
||||
loadProviders(ctx);
|
||||
ctx.tilePane.style.filter = 'invert(1)'; // pre-set from a prior dark session
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
ctx.window.MC_applyTileFilter(); // light theme → must clear regardless of provider
|
||||
assert.strictEqual(ctx.tilePane.style.filter, '');
|
||||
});
|
||||
|
||||
test('Cross-tab storage event re-dispatches mc-tile-provider-changed and re-applies filter', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadProviders(ctx);
|
||||
// Sanity: module registered a storage listener.
|
||||
assert.ok(ctx.listeners.storage && ctx.listeners.storage.length >= 1, 'storage listener registered');
|
||||
// Simulate localStorage change from another tab (do NOT call setActive — that's same-tab).
|
||||
ctx.localStorage.setItem('mc-dark-tile-provider', 'voyager-inverted');
|
||||
const before = ctx.events.length;
|
||||
ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'voyager-inverted', oldValue: null });
|
||||
assert.ok(ctx.events.length > before, 'storage event re-dispatched mc-tile-provider-changed');
|
||||
const ev = ctx.events[ctx.events.length - 1];
|
||||
assert.strictEqual(ev.type, 'mc-tile-provider-changed');
|
||||
assert.strictEqual(ev.detail.id, 'voyager-inverted');
|
||||
assert.strictEqual(ev.detail.crossTab, true);
|
||||
assert.ok(ctx.tilePane.style.filter.indexOf('invert(') >= 0, 'filter re-applied after cross-tab change');
|
||||
// Unknown values from other tabs must be ignored.
|
||||
const beforeIgnored = ctx.events.length;
|
||||
ctx.listeners.storage[0]({ key: 'mc-dark-tile-provider', newValue: 'bogus', oldValue: 'voyager-inverted' });
|
||||
assert.strictEqual(ctx.events.length, beforeIgnored, 'unknown ids do not re-dispatch');
|
||||
// Unrelated keys must be ignored.
|
||||
ctx.listeners.storage[0]({ key: 'other-key', newValue: 'carto-dark', oldValue: null });
|
||||
assert.strictEqual(ctx.events.length, beforeIgnored, 'unrelated key ignored');
|
||||
});
|
||||
|
||||
process.on('beforeExit', () => {
|
||||
console.log('');
|
||||
console.log(' ' + passed + ' passed, ' + failed + ' failed');
|
||||
if (failed) process.exit(1);
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* #1438 FINAL — customizer-v2 applyCSS must write --mc-role-{role}
|
||||
* alongside the legacy --node-{role} WHEN the value comes from
|
||||
* userOverrides (not from server defaults).
|
||||
*
|
||||
* The earlier closing chain (#1439 marker SVG migration, #1441 body.style
|
||||
* tweaks) did NOT extend the per-role write loop. Result:
|
||||
*
|
||||
* - Operator opens customizer, picks a custom color per-role. The pick
|
||||
* goes through setRoleColorOverride() (roles.js) which DOES write
|
||||
* --mc-role-X correctly → marker SVGs recolor live. ✅
|
||||
* - Operator reloads the page. customize-v2.js applyCSS replays from
|
||||
* localStorage userOverrides.nodeColors via the
|
||||
* `for (var role in nc) { root.setProperty('--node-' + role, ...) }`
|
||||
* loop. setRoleColorOverride is NOT replayed. Result: --mc-role-X
|
||||
* falls back to preset defaults; marker SVGs revert to preset
|
||||
* colors even though localStorage still holds the user pick. ❌
|
||||
*
|
||||
* Fix: extend the loop in customize-v2.js applyCSS to write
|
||||
* --mc-role-{role} when the role is present in userOverrides.nodeColors.
|
||||
* Do NOT write --mc-role-* for keys that only exist in server config
|
||||
* (that would re-introduce the #1412 ROLE_COLORS / preset-propagation
|
||||
* regression — server-config writes must stay legacy-vars-only).
|
||||
*
|
||||
* This test runs the extracted block in a vm sandbox so reverting the
|
||||
* fix in customize-v2.js breaks it.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + 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');
|
||||
|
||||
// ─── Extract the nodeColors-processing block from customize-v2.js. ───
|
||||
function extractBlock(src, anchor) {
|
||||
const idx = src.indexOf(anchor);
|
||||
if (idx === -1) throw new Error('anchor not found: ' + anchor);
|
||||
const start = src.indexOf('{', idx);
|
||||
if (start === -1) throw new Error('open brace not found after anchor');
|
||||
let depth = 0, end = -1;
|
||||
for (let i = start; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end === -1) throw new Error('matching close brace not found');
|
||||
return src.slice(idx, end + 1);
|
||||
}
|
||||
const blockA = extractBlock(cv2Src, 'var nc = effectiveConfig.nodeColors;');
|
||||
|
||||
function makeSandbox() {
|
||||
const root = {
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
},
|
||||
getAttribute() { return null; },
|
||||
setAttribute() {}
|
||||
};
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return this._attrs[k] || null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; },
|
||||
dataset: {},
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
}
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root,
|
||||
body: body,
|
||||
readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
createElement() { return { style: {}, setAttribute() {}, appendChild() {} }; },
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {},
|
||||
},
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
addEventListener() {},
|
||||
dispatchEvent() { return true; },
|
||||
fetch: function () { return { then: function () { return { then: function () { return { catch: function () {} }; }, catch: function () {} }; } }; },
|
||||
matchMedia: function () { return { matches: false }; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
getComputedStyle: function () {
|
||||
return { getPropertyValue: function (k) { return (root.style._vars[k] || ''); } };
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body };
|
||||
}
|
||||
|
||||
console.log('\n=== #1438 FINAL A: source invariant — --mc-role-{role} write present ===');
|
||||
assert(/setProperty\(\s*['"]--mc-role-['"]\s*\+\s*role/.test(blockA),
|
||||
'loop body writes --mc-role-{role}');
|
||||
assert(/setProperty\(\s*['"]--node-['"]\s*\+\s*role/.test(blockA),
|
||||
'loop body still writes legacy --node-{role}');
|
||||
|
||||
console.log('\n=== #1438 FINAL B: user override → --mc-role-{role} reflects user hex on reload ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
|
||||
// Precondition: preset wrote --mc-role-repeater to IBM orange.
|
||||
assert(env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase() === '#fe6100',
|
||||
'precondition: applyPreset("deut") wrote --mc-role-repeater = #FE6100');
|
||||
|
||||
// Simulate reload: customize-v2 applyCSS replays from localStorage.
|
||||
// effectiveConfig.nodeColors merges server + user; userOverrides is the
|
||||
// user-only slice.
|
||||
const setup =
|
||||
'var root = document.documentElement.style;\n' +
|
||||
'var userOverrides = { nodeColors: { repeater: "#ff00ff" } };\n' +
|
||||
'var effectiveConfig = { nodeColors: { repeater: "#ff00ff", companion: "#56B4E9" } };\n' +
|
||||
blockA + '\n';
|
||||
vm.runInContext(setup, env.sandbox);
|
||||
|
||||
// Operator's pick MUST be in --mc-role-repeater now.
|
||||
const got = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
|
||||
assert(got === '#ff00ff',
|
||||
'after applyCSS replay, --mc-role-repeater === user pick #ff00ff (got ' + got + ')');
|
||||
|
||||
// And --node-repeater for legacy compat.
|
||||
assert(env.root.style.getPropertyValue('--node-repeater').toLowerCase() === '#ff00ff',
|
||||
'--node-repeater legacy var also written (got ' + env.root.style.getPropertyValue('--node-repeater') + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1438 FINAL C: server-only key does NOT clobber --mc-role-* (preserves #1412) ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
|
||||
// companion has NO user override; server config has its own legacy hex.
|
||||
const setup =
|
||||
'var root = document.documentElement.style;\n' +
|
||||
'var userOverrides = { nodeColors: { repeater: "#ff00ff" } };\n' +
|
||||
'var effectiveConfig = { nodeColors: { repeater: "#ff00ff", companion: "#2563eb" } };\n' +
|
||||
blockA + '\n';
|
||||
vm.runInContext(setup, env.sandbox);
|
||||
|
||||
// --mc-role-companion must remain the preset's value (no clobber from server).
|
||||
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 + ')');
|
||||
|
||||
// --node-companion CAN take the server value (legacy compat is fine here).
|
||||
assert(env.root.style.getPropertyValue('--node-companion').toLowerCase() === '#2563eb',
|
||||
'--node-companion legacy var takes server value (got ' + env.root.style.getPropertyValue('--node-companion') + ')');
|
||||
}
|
||||
|
||||
console.log('\n──────────────────────────');
|
||||
console.log('passed: ' + passed + ', failed: ' + failed);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* #1438 — Map + Live node markers and customizer per-role overrides
|
||||
* don't honor CB-preset switches (stale baked SVG fills).
|
||||
*
|
||||
* Root cause:
|
||||
* - public/live.js addNodeMarker bakes ROLE_COLORS[role] hex into the
|
||||
* SVG `fill=` attribute at marker-creation. After cb-preset switch
|
||||
* the existing SVG nodes are stale until reload.
|
||||
* - public/map.js makeMarkerIcon + observer star overlay: same.
|
||||
* - public/roles.js makeRoleMarkerSVG: same.
|
||||
* - public/customize.js setRoleColorOverride path only updates the
|
||||
* _roleOverrides JS map (and -- via customize-v2.js -- the legacy
|
||||
* `--node-{role}` CSS var) but never writes `--mc-role-{role}`.
|
||||
* So CSS-var-driven surfaces ignore the custom override.
|
||||
*
|
||||
* Fix shape (verified live in chromium: CSS vars on SVG fill DO repaint
|
||||
* across mounted elements when the CSS variable value changes):
|
||||
* 1. SVG marker builders use `fill="var(--mc-role-X)"` so Leaflet's
|
||||
* inline SVGs resolve the live CSS var.
|
||||
* 2. `setRoleColorOverride` writes BOTH `--mc-role-{role}` AND keeps
|
||||
* the legacy `--node-{role}` write side intact.
|
||||
*
|
||||
* This test is the RED gate: assert the source pattern is the var form.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const liveSrc = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const customizeSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1438 A: roles.js makeRoleMarkerSVG uses CSS-var fill ===');
|
||||
{
|
||||
const helperMatch = rolesSrc.match(/window\.makeRoleMarkerSVG[\s\S]*?\n\s*\};/);
|
||||
const block = helperMatch ? helperMatch[0] : '';
|
||||
assert(block.length > 0, 'makeRoleMarkerSVG block located');
|
||||
// The fill expression must reference a CSS var, not a baked hex.
|
||||
// We allow the call site to still pass a colour for matrix-mode
|
||||
// tinting (overrides win); but the DEFAULT path (no color arg)
|
||||
// must produce `fill="var(--mc-role-X)"` so existing markers
|
||||
// recolor when CSS vars change.
|
||||
assert(/var\(--mc-role-/.test(block),
|
||||
'makeRoleMarkerSVG emits var(--mc-role-X) in default fill path');
|
||||
assert(!/fill="\$\{fill\}"/.test(block) || /var\(--mc-role-/.test(block),
|
||||
'fill expression resolves to CSS var when no override passed');
|
||||
}
|
||||
|
||||
console.log('\n=== #1438 B: map.js makeMarkerIcon uses CSS-var fill ===');
|
||||
{
|
||||
const fnIdx = mapSrc.indexOf('function makeMarkerIcon');
|
||||
assert(fnIdx >= 0, 'makeMarkerIcon function located');
|
||||
// Take a generous slice — the function body + observer star overlay.
|
||||
const block = mapSrc.slice(fnIdx, fnIdx + 3500);
|
||||
assert(/var\(--mc-role-/.test(block),
|
||||
'makeMarkerIcon body references var(--mc-role-*)');
|
||||
// Observer star overlay (still inside the slice) must also use the var.
|
||||
assert(/observer.*var\(--mc-role-observer/.test(block) ||
|
||||
/var\(--mc-role-observer/.test(block),
|
||||
'observer star overlay uses var(--mc-role-observer)');
|
||||
}
|
||||
|
||||
console.log('\n=== #1438 C: live.js addNodeMarker fallback uses CSS-var fill ===');
|
||||
{
|
||||
const addIdx = liveSrc.indexOf('function addNodeMarker');
|
||||
assert(addIdx >= 0, 'addNodeMarker function located');
|
||||
const block = liveSrc.slice(addIdx, addIdx + 3500);
|
||||
// Inline fallback SVG (when window.makeRoleMarkerSVG missing) must
|
||||
// also use the CSS var so first-paint before roles.js still recolors.
|
||||
// Tolerate the matrix-mode hard-coded '#008a22' override.
|
||||
const fallbackFills = block.match(/fill="[^"]+"/g) || [];
|
||||
const baked = fallbackFills.filter(f =>
|
||||
/fill="#[0-9a-fA-F]{3,8}"/.test(f) && !/008a22/.test(f));
|
||||
assert(baked.length === 0,
|
||||
'addNodeMarker has no baked hex fill in default path (got: ' +
|
||||
JSON.stringify(baked) + ')');
|
||||
assert(/var\(--mc-role-/.test(block),
|
||||
'addNodeMarker body references var(--mc-role-*)');
|
||||
}
|
||||
|
||||
console.log('\n=== #1438 D: customize.js routes per-role picks through setRoleColorOverride ===');
|
||||
{
|
||||
// The customizer's per-key color picker MUST call
|
||||
// window.setRoleColorOverride so the single helper (in roles.js)
|
||||
// is the only writer of both the _roleOverrides JS map AND the
|
||||
// `--mc-role-{role}` CSS var. Direct CSS-var writes in this file
|
||||
// are tolerated but the routing through the helper is mandatory
|
||||
// so all callers (not just the picker) get the propagation.
|
||||
assert(/setRoleColorOverride\s*\(/.test(customizeSrc),
|
||||
'customize.js calls setRoleColorOverride');
|
||||
// Belt-and-braces: at least one node-color picker handler block
|
||||
// should hit the helper (search for the proximity pattern).
|
||||
var pickerBlock = customizeSrc.match(/data-node[\s\S]{0,800}?setRoleColorOverride/);
|
||||
assert(pickerBlock,
|
||||
'customize.js node-color picker handler routes through setRoleColorOverride');
|
||||
}
|
||||
|
||||
console.log('\n=== #1438 E: roles.js setRoleColorOverride writes --mc-role-* ===');
|
||||
{
|
||||
// Belt-and-braces: regardless of which file is the writer, the
|
||||
// single setRoleColorOverride helper in roles.js should keep the
|
||||
// CSS var in sync so any other caller (not just customize.js) gets
|
||||
// the propagation for free.
|
||||
const fnMatch = rolesSrc.match(/window\.setRoleColorOverride\s*=\s*function[\s\S]*?\n\s*\};/);
|
||||
assert(fnMatch, 'setRoleColorOverride function located in roles.js');
|
||||
if (fnMatch) {
|
||||
assert(/--mc-role-/.test(fnMatch[0]),
|
||||
'setRoleColorOverride body writes --mc-role-{role} CSS var');
|
||||
// Follow-up: cb-presets ships stylesheet rules of the form
|
||||
// body[data-cb-preset="X"] { --mc-role-Y: ...; }
|
||||
// which beat inheritance from :root. The override helper MUST
|
||||
// write to body.style.setProperty too, otherwise the customizer
|
||||
// picks remain invisible after a preset switch.
|
||||
assert(/document\.body/.test(fnMatch[0]) ||
|
||||
/body\.style/.test(rolesSrc.slice(rolesSrc.indexOf('_styleTargets'), rolesSrc.indexOf('_styleTargets') + 600)),
|
||||
'setRoleColorOverride writes to document.body.style (beats body[data-cb-preset] cascade)');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- Summary ---');
|
||||
console.log(passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* #1446 — CB preset is an end-user opt-in, NOT the canonical color source.
|
||||
*
|
||||
* Cascade (top wins):
|
||||
* user per-role override > active CB preset > server config.nodeColors > built-in :root defaults
|
||||
*
|
||||
* 5 acceptance scenarios from the issue. Source-grep + vm-sandbox assertions
|
||||
* because the cascade is enforced by a mix of JS (cb-presets.js setting
|
||||
* body[data-cb-preset]), CSS (body[data-cb-preset="X"] selectors), and inline
|
||||
* style writes (setRoleColorOverride + applyCSS).
|
||||
*
|
||||
* If these fail, reverting the fix on customize-v2.js / cb-presets.js / roles.js
|
||||
* brings them back red.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const ROOT = __dirname;
|
||||
const cv2Src = fs.readFileSync(path.join(ROOT, 'public', 'customize-v2.js'), 'utf8');
|
||||
const rolesSrc = fs.readFileSync(path.join(ROOT, 'public', 'roles.js'), 'utf8');
|
||||
const presetsSrc = fs.readFileSync(path.join(ROOT, 'public', 'cb-presets.js'), 'utf8');
|
||||
const styleSrc = fs.readFileSync(path.join(ROOT, 'public', 'style.css'), 'utf8');
|
||||
|
||||
function makeSandbox(localStorageMap) {
|
||||
localStorageMap = localStorageMap || {};
|
||||
function makeStyle() {
|
||||
return {
|
||||
_vars: {}, // map var → value
|
||||
_imp: {}, // map var → priority ('important' or '')
|
||||
setProperty(k, v, prio) {
|
||||
this._vars[k] = String(v);
|
||||
this._imp[k] = prio || '';
|
||||
},
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
getPropertyPriority(k) { return this._imp[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; delete this._imp[k]; }
|
||||
};
|
||||
}
|
||||
const root = { style: makeStyle(), getAttribute() { return null; }, setAttribute() {} };
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; },
|
||||
hasAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k); },
|
||||
dataset: {},
|
||||
style: makeStyle()
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
_listeners: {},
|
||||
localStorage: {
|
||||
_m: Object.assign({}, localStorageMap),
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(this._m, k) ? this._m[k] : null; },
|
||||
setItem(k, v) { this._m[k] = String(v); },
|
||||
removeItem(k) { delete this._m[k]; }
|
||||
},
|
||||
document: {
|
||||
documentElement: root,
|
||||
body: body,
|
||||
readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
createElement() { return { style: {}, setAttribute() {}, appendChild() {} }; },
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {}
|
||||
},
|
||||
console: console,
|
||||
setTimeout: setTimeout, clearTimeout: clearTimeout,
|
||||
addEventListener(type, fn) {
|
||||
if (!this._listeners[type]) this._listeners[type] = [];
|
||||
this._listeners[type].push(fn);
|
||||
},
|
||||
dispatchEvent(ev) {
|
||||
var arr = this._listeners[ev && ev.type] || [];
|
||||
arr.forEach(function (fn) { try { fn(ev); } catch (e) {} });
|
||||
return true;
|
||||
},
|
||||
fetch: function () { return Promise.resolve({ json: function () { return Promise.resolve({}); } }); },
|
||||
matchMedia: function () { return { matches: false, addEventListener() {} }; },
|
||||
CustomEvent: function (t, o) { this.type = t; this.detail = o && o.detail; },
|
||||
Event: function (t) { this.type = t; },
|
||||
Proxy: Proxy,
|
||||
getComputedStyle: function () {
|
||||
// Pretend body[data-cb-preset]=X CSS rule applies if attr is set: but
|
||||
// the JS apply also writes the var to root, so just return root's view.
|
||||
return {
|
||||
getPropertyValue: function (k) { return root.style._vars[k] || ''; }
|
||||
};
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body };
|
||||
}
|
||||
|
||||
// ─── SCENARIO 1: Default load with NO localStorage preset → data-cb-preset must NOT be set ───
|
||||
console.log('\n=== #1446 Scenario 1: cold boot with empty localStorage → no preset active ===');
|
||||
{
|
||||
const env = makeSandbox({});
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
// After cb-presets.js auto-init: with no stored preset, body must NOT carry
|
||||
// data-cb-preset (or it must be "none"), so the body[data-cb-preset="X"]
|
||||
// CSS rules in style.css do not apply.
|
||||
const attr = env.body.getAttribute('data-cb-preset');
|
||||
assert(attr === null || attr === 'none' || attr === '',
|
||||
'cold boot: body[data-cb-preset] not forced to "default" (got: ' + JSON.stringify(attr) + ')');
|
||||
// And cb-presets must NOT have stomped --mc-role-repeater on root with the
|
||||
// Wong default, because that would lock out server config.
|
||||
const repAtBoot = env.root.style.getPropertyValue('--mc-role-repeater');
|
||||
assert(!repAtBoot,
|
||||
'cold boot: cb-presets did NOT auto-write --mc-role-repeater (got: ' + JSON.stringify(repAtBoot) + ')');
|
||||
}
|
||||
|
||||
// ─── SCENARIO 2: Server config nodeColors land on --mc-role-X when no preset is active ───
|
||||
console.log('\n=== #1446 Scenario 2: server config.nodeColors → --mc-role-X (no preset active) ===');
|
||||
{
|
||||
const env = makeSandbox({});
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
vm.runInContext(cv2Src, env.sandbox);
|
||||
env.sandbox.window._customizerV2.init({ nodeColors: { repeater: '#aaaaaa', companion: '#56B4E9' } });
|
||||
const rep = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
|
||||
assert(rep === '#aaaaaa',
|
||||
'server-only nodeColors with NO active preset writes --mc-role-repeater = #aaaaaa (got: ' + JSON.stringify(rep) + ')');
|
||||
}
|
||||
|
||||
// ─── SCENARIO 3: User per-role override beats active preset (writes to body inline w/ !important) ───
|
||||
console.log('\n=== #1446 Scenario 3: user override > active CB preset (body inline !important) ===');
|
||||
{
|
||||
const env = makeSandbox({});
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff');
|
||||
const bodyVal = env.body.style.getPropertyValue('--mc-role-repeater').toLowerCase();
|
||||
const bodyPrio = env.body.style.getPropertyPriority('--mc-role-repeater');
|
||||
assert(bodyVal === '#ff00ff',
|
||||
'setRoleColorOverride writes --mc-role-repeater on body.style (got: ' + JSON.stringify(bodyVal) + ')');
|
||||
assert(bodyPrio === 'important',
|
||||
'setRoleColorOverride writes with !important so it wins against body[data-cb-preset] cascade (got: ' + JSON.stringify(bodyPrio) + ')');
|
||||
}
|
||||
|
||||
// ─── SCENARIO 4: Clear per-role override → reverts to active preset ───
|
||||
console.log('\n=== #1446 Scenario 4: clear override → reverts to active CB preset ===');
|
||||
{
|
||||
const env = makeSandbox({});
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff');
|
||||
env.sandbox.window.setRoleColorOverride('repeater', null);
|
||||
const bodyVal = env.body.style.getPropertyValue('--mc-role-repeater').toLowerCase();
|
||||
// After clear: body inline should be restored to the snapshot (deut #FE6100) OR removed.
|
||||
// The snapshot logic in roles.js restores the prior body inline value (which
|
||||
// was '' before override since applyPreset writes only to root).
|
||||
// Either way, after clear, the body inline override must NOT shadow the preset.
|
||||
// Effective truth: root still carries #fe6100 from applyPreset.
|
||||
const rootVal = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
|
||||
assert(rootVal === '#fe6100',
|
||||
'after clearing override, --mc-role-repeater on root still shows preset deut #FE6100 (got: ' + JSON.stringify(rootVal) + ')');
|
||||
assert(bodyVal !== '#ff00ff',
|
||||
'after clearing override, body inline no longer carries the user pick (got: ' + JSON.stringify(bodyVal) + ')');
|
||||
}
|
||||
|
||||
// ─── SCENARIO 5: Clear preset → reverts to server config / built-in default ───
|
||||
console.log('\n=== #1446 Scenario 5: clear preset → reverts to server config / default ===');
|
||||
{
|
||||
const env = makeSandbox({ 'meshcore-cb-preset': 'deut' });
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
vm.runInContext(cv2Src, env.sandbox);
|
||||
env.sandbox.window._customizerV2.init({ nodeColors: { repeater: '#aaaaaa' } });
|
||||
// Confirm deut is active first.
|
||||
const repWithPreset = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
|
||||
assert(repWithPreset === '#fe6100',
|
||||
'precondition: deut active → --mc-role-repeater = #FE6100 (got: ' + JSON.stringify(repWithPreset) + ')');
|
||||
// Now clear the preset.
|
||||
const hasClear = typeof env.sandbox.window.MeshCorePresets.clearPreset === 'function';
|
||||
assert(hasClear, 'MeshCorePresets.clearPreset() exists');
|
||||
if (hasClear) env.sandbox.window.MeshCorePresets.clearPreset();
|
||||
else { env.body.removeAttribute('data-cb-preset'); /* fallback to keep test going */ }
|
||||
const attr = env.body.getAttribute('data-cb-preset');
|
||||
assert(attr === null || attr === 'none' || attr === '',
|
||||
'after clearPreset, body[data-cb-preset] is unset/none (got: ' + JSON.stringify(attr) + ')');
|
||||
const repAfter = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
|
||||
assert(repAfter === '#aaaaaa',
|
||||
'after clearPreset, --mc-role-repeater reverts to server config #aaaaaa (got: ' + JSON.stringify(repAfter) + ')');
|
||||
}
|
||||
|
||||
// ─── SCENARIO 6: existing localStorage preset still applies (backward compat) ───
|
||||
console.log('\n=== #1446 Scenario 6: backward compat — existing localStorage preset still applies ===');
|
||||
{
|
||||
const env = makeSandbox({ 'meshcore-cb-preset': 'deut' });
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
// cb-presets auto-init must have applied deut from storage.
|
||||
const attr = env.body.getAttribute('data-cb-preset');
|
||||
assert(attr === 'deut', 'stored "deut" preset auto-applies on boot (got: ' + JSON.stringify(attr) + ')');
|
||||
const rep = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase();
|
||||
assert(rep === '#fe6100', 'stored preset writes --mc-role-repeater = #FE6100 (got: ' + JSON.stringify(rep) + ')');
|
||||
}
|
||||
|
||||
// ─── Source-grep: customizer UI puts per-role pickers FIRST, then preset selector ───
|
||||
console.log('\n=== #1446 Scenario 7: customizer UI re-org — node-color pickers come BEFORE preset selector ===');
|
||||
{
|
||||
// Find _renderNodes() return string: it concatenates _renderColorblindPresetSelector() + node rows + ...
|
||||
// After fix: node rows come first, preset selector is in a labelled-secondary block.
|
||||
const renderNodesIdx = cv2Src.indexOf('function _renderNodes()');
|
||||
const after = cv2Src.slice(renderNodesIdx, renderNodesIdx + 4000);
|
||||
const presetIdx = after.indexOf('_renderColorblindPresetSelector');
|
||||
const rolesIdx = after.indexOf("Node Role Colors");
|
||||
assert(presetIdx > rolesIdx && rolesIdx > 0,
|
||||
'Node Role Colors section appears BEFORE Colorblind Preset block in _renderNodes (rolesIdx=' + rolesIdx + ', presetIdx=' + presetIdx + ')');
|
||||
// Preset section labelled as optional.
|
||||
assert(/Optional[^<]*colorblind|colorblind[^<]*\(optional\)/i.test(cv2Src),
|
||||
'preset section labelled "Optional" / "(optional)" in customizer UI');
|
||||
}
|
||||
|
||||
// ─── SCENARIO 9 (#1446 followup): customize-v2 setOverride path must also write
|
||||
// !important on body.style when a preset is active — same gotcha as
|
||||
// setRoleColorOverride (roles.js), different code path. The legacy
|
||||
// customize.js color picker routes through setRoleColorOverride; the
|
||||
// customize-v2.js color picker routes through setOverride → _runPipeline
|
||||
// → applyCSS. Both code paths must produce the same observable. ───
|
||||
console.log('\n=== #1446 Scenario 9: customize-v2 applyCSS writes user override on body with !important when preset active ===');
|
||||
{
|
||||
const env = makeSandbox({ 'meshcore-cb-preset': 'deut' });
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
vm.runInContext(cv2Src, env.sandbox);
|
||||
// Init with server config + simulate a user override stored already.
|
||||
env.sandbox.localStorage.setItem('cs-theme-overrides', JSON.stringify({ nodeColors: { repeater: '#ff00ff' } }));
|
||||
env.sandbox.window._customizerV2.init({ nodeColors: { repeater: '#aaaaaa' } });
|
||||
// Body has data-cb-preset=deut (auto-init), user override = #ff00ff.
|
||||
// After applyCSS: body.style[--mc-role-repeater] must be #ff00ff with !important
|
||||
// so it survives the body[data-cb-preset="deut"] CSS rule cascade.
|
||||
const val = env.body.style.getPropertyValue('--mc-role-repeater').toLowerCase();
|
||||
const prio = env.body.style.getPropertyPriority('--mc-role-repeater');
|
||||
assert(val === '#ff00ff',
|
||||
'applyCSS writes user override on body.style (got: ' + JSON.stringify(val) + ')');
|
||||
assert(prio === 'important',
|
||||
'applyCSS writes user override on body.style with !important (got: ' + JSON.stringify(prio) + ')');
|
||||
}
|
||||
|
||||
// ─── Source-grep: style.css must NOT define a body[data-cb-preset="default"] rule
|
||||
// that locks --mc-role-X to Wong — Wong is the :root default already, and the
|
||||
// "default" preset selection is the same as "no preset". ───
|
||||
console.log('\n=== #1446 Scenario 8: style.css does not redundantly clamp Wong via body[data-cb-preset="default"] ===');
|
||||
{
|
||||
// Either the body[data-cb-preset="default"] block is removed, OR the rule body
|
||||
// does not set --mc-role-* (only resets text). Either way, when default is
|
||||
// active the rule must not write --mc-role-repeater = #D55E00 (which would
|
||||
// mask server config). The safest implementation: drop the "default" block.
|
||||
const re = /body\[data-cb-preset="default"\][^{]*\{([^}]*)\}/;
|
||||
const m = styleSrc.match(re);
|
||||
if (!m) {
|
||||
assert(true, 'no body[data-cb-preset="default"] block in style.css (preferred)');
|
||||
} else {
|
||||
const blockBody = m[1];
|
||||
assert(!/--mc-role-(repeater|companion|room|sensor|observer)\s*:/.test(blockBody),
|
||||
'body[data-cb-preset="default"] block does not redefine --mc-role-{role} (would mask server config)');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n──────────────────────────');
|
||||
console.log('passed: ' + passed + ', failed: ' + failed);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* #1450 — Custom navbar logo aspect ratio preservation.
|
||||
*
|
||||
* The default inline-SVG wordmark has a fixed 125x36 box matching its
|
||||
* viewBox; pinning it via `.brand-logo { width: 125px }` was correct for
|
||||
* the SVG. When the operator sets `branding.logoUrl`, customize-v2 swaps
|
||||
* the inline <svg> for an <img class="brand-logo"> — that <img> then
|
||||
* inherits the same `width: 125px` (plus hardcoded width/height attrs),
|
||||
* stretching every non-3.08:1 image into a pill shape.
|
||||
*
|
||||
* Fix: split the CSS rule so `svg.brand-logo` keeps the 125px pin, but
|
||||
* `img.brand-logo` uses `width: auto` (with a `max-width` cap) so the
|
||||
* natural aspect ratio of operator-provided images is preserved. Drop
|
||||
* the hardcoded width/height attrs from the IMG element created in
|
||||
* customize-v2 _setBrandLogoUrl.
|
||||
*
|
||||
* Pure-Node, no browser. Parses style.css + greps customize-v2.js.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = __dirname;
|
||||
const CSS = fs.readFileSync(path.join(ROOT, 'public/style.css'), 'utf8');
|
||||
const CUSTOMIZE = fs.readFileSync(path.join(ROOT, 'public/customize-v2.js'), 'utf8');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(' ✓ ' + name); }
|
||||
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
||||
}
|
||||
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
|
||||
|
||||
// Extract bodies of ALL CSS rules whose selector matches `sel` (literal)
|
||||
// at brace-depth 0 (i.e. NOT inside an @media or other at-rule). Returns
|
||||
// an array of declaration-body strings.
|
||||
function findTopLevelRules(css, sel) {
|
||||
const out = [];
|
||||
let depth = 0;
|
||||
let i = 0;
|
||||
while (i < css.length) {
|
||||
const ch = css[i];
|
||||
if (ch === '{') { depth++; i++; continue; }
|
||||
if (ch === '}') { depth--; i++; continue; }
|
||||
if (depth === 0 && ch === sel[0]) {
|
||||
// Try to match selector at this position
|
||||
if (css.startsWith(sel, i)) {
|
||||
// Make sure prev char is whitespace/newline/, /} to avoid mid-token matches
|
||||
const prev = i > 0 ? css[i - 1] : '\n';
|
||||
if (/[\s},]/.test(prev)) {
|
||||
// Find next `{`
|
||||
const open = css.indexOf('{', i + sel.length);
|
||||
if (open > 0) {
|
||||
const close = css.indexOf('}', open);
|
||||
if (close > open) {
|
||||
out.push(css.slice(open + 1, close));
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findTopLevelRule(css, sel) {
|
||||
const all = findTopLevelRules(css, sel);
|
||||
return all.length ? all[0] : null;
|
||||
}
|
||||
|
||||
console.log('── #1450 logo aspect ratio ──');
|
||||
|
||||
test('img.brand-logo CSS rule exists and uses width:auto (not pinned)', () => {
|
||||
const body = findTopLevelRule(CSS, 'img.brand-logo');
|
||||
assert(body, 'expected a top-level `img.brand-logo` CSS rule');
|
||||
assert(/width\s*:\s*auto/i.test(body),
|
||||
'expected `width: auto` in img.brand-logo (got: ' + body.trim().replace(/\s+/g, ' ') + ')');
|
||||
assert(/max-width\s*:\s*\d+px/i.test(body),
|
||||
'expected a `max-width: <N>px` cap on img.brand-logo to prevent very-wide logos blowing nav layout');
|
||||
assert(/height\s*:\s*36px/i.test(body),
|
||||
'expected `height: 36px` on img.brand-logo (matches default SVG height)');
|
||||
});
|
||||
|
||||
test('svg.brand-logo CSS rule still pins width:125px (no default regression)', () => {
|
||||
const body = findTopLevelRule(CSS, 'svg.brand-logo');
|
||||
assert(body, 'expected a top-level `svg.brand-logo` CSS rule keeping the default-wordmark pin');
|
||||
assert(/width\s*:\s*125px/i.test(body),
|
||||
'svg.brand-logo MUST keep width: 125px to preserve the default wordmark layout');
|
||||
assert(/height\s*:\s*36px/i.test(body),
|
||||
'svg.brand-logo MUST keep height: 36px');
|
||||
});
|
||||
|
||||
test('mobile media-query splits the .brand-logo rule into svg/img variants', () => {
|
||||
// Find the @media block(s) and look for split rules.
|
||||
// We just require BOTH `svg.brand-logo` and `img.brand-logo` to appear
|
||||
// somewhere inside an @media block, AND the bare `.brand-logo { height
|
||||
// ... width: <px> }` form to NOT exist in tablet/mobile breakpoints
|
||||
// (since that pinned width is what we're getting away from for IMG).
|
||||
const mediaIdx = CSS.indexOf('@media');
|
||||
assert(mediaIdx > 0, 'expected @media blocks in style.css');
|
||||
const tail = CSS.slice(mediaIdx);
|
||||
assert(/svg\.brand-logo\s*\{[^}]*width\s*:\s*\d+px/i.test(tail),
|
||||
'expected `svg.brand-logo { ... width:<N>px }` inside a media query (tablet pin)');
|
||||
assert(/img\.brand-logo\s*\{[^}]*width\s*:\s*auto/i.test(tail),
|
||||
'expected `img.brand-logo { ... width:auto }` inside a media query (tablet IMG variant)');
|
||||
});
|
||||
|
||||
test('customize-v2 _setBrandLogoUrl does NOT hardcode width/height attrs on the IMG', () => {
|
||||
// The fix removes these two setAttribute calls so CSS img.brand-logo
|
||||
// governs sizing without overriding aspect.
|
||||
assert(!/img\.setAttribute\(\s*['"]width['"]\s*,\s*['"]125['"]\s*\)/.test(CUSTOMIZE),
|
||||
'customize-v2.js still sets img width="125" — that overrides the CSS width:auto and squishes custom logos');
|
||||
assert(!/img\.setAttribute\(\s*['"]height['"]\s*,\s*['"]36['"]\s*\)/.test(CUSTOMIZE),
|
||||
'customize-v2.js still sets img height="36" — CSS height:36px is sufficient and the attr blocks aspect math when only one dim is constrained');
|
||||
});
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* #1454 — customizer toggle for "show encrypted channels".
|
||||
*
|
||||
* The read gate at public/channels.js ~L1564 has long been:
|
||||
* var showEnc = localStorage.getItem('channels-show-encrypted') === 'true';
|
||||
* but there has been NO UI affordance to flip it. This PR adds a checkbox
|
||||
* to the customizer Display panel that toggles the localStorage value and
|
||||
* fires a custom event the channels page listens for to re-fetch live —
|
||||
* no full page reload.
|
||||
*
|
||||
* Source-grep invariants (cheap, deterministic). Reverting the production
|
||||
* code must break these.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const customizeSrc = fs.readFileSync(
|
||||
path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
|
||||
const channelsSrc = fs.readFileSync(
|
||||
path.join(__dirname, 'public', 'channels.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1454 A: read gate in channels.js unchanged ===');
|
||||
// The localStorage read-gate stays as the source-of-truth contract.
|
||||
assert(
|
||||
/localStorage\.getItem\(\s*['"]channels-show-encrypted['"]\s*\)\s*===\s*['"]true['"]/
|
||||
.test(channelsSrc),
|
||||
'channels.js still reads localStorage["channels-show-encrypted"] === "true"');
|
||||
assert(
|
||||
/includeEncrypted=true/.test(channelsSrc),
|
||||
'channels.js still appends includeEncrypted=true to the channels fetch');
|
||||
|
||||
console.log('\n=== #1454 B: customizer registers a write path ===');
|
||||
assert(
|
||||
customizeSrc.indexOf("channels-show-encrypted") !== -1,
|
||||
'customize-v2.js mentions the channels-show-encrypted localStorage key');
|
||||
assert(
|
||||
/localStorage\.setItem\(\s*['"]channels-show-encrypted['"]\s*,\s*['"]true['"]\s*\)/
|
||||
.test(customizeSrc),
|
||||
'customize-v2.js sets the key to "true" when toggle goes ON');
|
||||
assert(
|
||||
/localStorage\.removeItem\(\s*['"]channels-show-encrypted['"]\s*\)/
|
||||
.test(customizeSrc),
|
||||
'customize-v2.js removes the key when toggle goes OFF (clean default)');
|
||||
assert(
|
||||
/CustomEvent\(\s*['"]mc-channels-show-encrypted-changed['"]/
|
||||
.test(customizeSrc),
|
||||
'customize-v2.js dispatches mc-channels-show-encrypted-changed on toggle');
|
||||
assert(
|
||||
/data-cv2-channels-show-encrypted/.test(customizeSrc),
|
||||
'customize-v2.js renders an input with data-cv2-channels-show-encrypted hook');
|
||||
assert(
|
||||
/type="checkbox"[^>]*data-cv2-channels-show-encrypted/.test(customizeSrc) ||
|
||||
/data-cv2-channels-show-encrypted[^>]*type="checkbox"/.test(customizeSrc),
|
||||
'the toggle is a checkbox input');
|
||||
|
||||
console.log('\n=== #1454 C: channels.js listens for the event ===');
|
||||
assert(
|
||||
/addEventListener\(\s*['"]mc-channels-show-encrypted-changed['"]/
|
||||
.test(channelsSrc),
|
||||
'channels.js has a listener for mc-channels-show-encrypted-changed');
|
||||
assert(
|
||||
/mc-channels-show-encrypted-changed[\s\S]{0,200}loadChannels\s*\(/
|
||||
.test(channelsSrc),
|
||||
'the listener calls loadChannels() to re-fetch without page reload');
|
||||
|
||||
console.log('\n=== #1454 D: default-off invariant preserved ===');
|
||||
// Setting must only ever store the string "true". OFF deletes the key so
|
||||
// the read-gate cleanly returns false. No "false" string ever written.
|
||||
assert(
|
||||
!/localStorage\.setItem\(\s*['"]channels-show-encrypted['"]\s*,\s*['"]false['"]\s*\)/
|
||||
.test(customizeSrc),
|
||||
'customize-v2.js never writes the string "false" (delete-on-off)');
|
||||
|
||||
console.log('\n#1454 results: ' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Issue #1456 — UI rename "Usefulness" → "Traffic share" + tooltips
|
||||
* for both Traffic share and Bridge score.
|
||||
*
|
||||
* Backend keeps `usefulness_score` for API compat; a new
|
||||
* `traffic_share_score` field carries the same value and is what new
|
||||
* UI consumers should read (UI falls back to usefulness_score for
|
||||
* graceful degradation). The Go-side surface check for the new field
|
||||
* lives in cmd/server/traffic_share_score_test.go.
|
||||
*
|
||||
* This file pins the frontend pieces by file-grep on nodes.js.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'nodes.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1456: nodes.js renders "Traffic share" label ===');
|
||||
|
||||
// The node-stats-table row that surfaces the score. Locate the row by
|
||||
// its stable id `row-usefulness-score` (kept for backwards-compat /
|
||||
// DOM hooks).
|
||||
const rowIdx = src.indexOf('id="row-usefulness-score"');
|
||||
assert(rowIdx > 0, 'usefulness-score row block present in nodes.js');
|
||||
const rowBlock = src.slice(Math.max(0, rowIdx - 400), rowIdx + 800);
|
||||
|
||||
assert(rowBlock.includes('Traffic share'),
|
||||
'row block contains visible label "Traffic share"');
|
||||
assert(!/>\s*Usefulness\s*</.test(rowBlock),
|
||||
'row block no longer renders ">Usefulness<" as visible cell text');
|
||||
|
||||
console.log('\n=== #1456: tooltip text present for both scores ===');
|
||||
|
||||
assert(/betweenness centrality/i.test(src),
|
||||
'nodes.js contains tooltip fragment "betweenness centrality" (Bridge tooltip)');
|
||||
assert(/non-advert (mesh )?traffic/i.test(src),
|
||||
'nodes.js contains tooltip fragment "non-advert traffic" (Traffic share tooltip)');
|
||||
assert(/chokepoint|irreplaceable/i.test(src),
|
||||
'nodes.js Bridge tooltip mentions chokepoint/irreplaceable framing');
|
||||
|
||||
console.log('\n=== #1456: percent formatting near score render ===');
|
||||
|
||||
const usefulRender = src.slice(rowIdx - 600, rowIdx + 600);
|
||||
assert(/\*\s*100\)?\s*\.toFixed\(1\)/.test(usefulRender) ||
|
||||
/toFixed\(1\)\s*\+\s*['"]%['"]/.test(usefulRender) ||
|
||||
/\$\{pct\}%/.test(usefulRender),
|
||||
'traffic-share render emits percent-formatted value (×100 + toFixed(1))');
|
||||
|
||||
console.log('\n=== #1456: UI prefers traffic_share_score, falls back to usefulness_score ===');
|
||||
|
||||
// New frontend reads the new field with graceful fallback so stale
|
||||
// servers (still only emitting usefulness_score) keep working.
|
||||
assert(/traffic_share_score/.test(src),
|
||||
'nodes.js references the new traffic_share_score field');
|
||||
const fallbackRe = /n\.traffic_share_score\s*[!=]=\s*null\s*\?\s*n\.traffic_share_score\s*:\s*n\.usefulness_score/;
|
||||
assert(fallbackRe.test(src) ||
|
||||
/traffic_share_score\s*\?\?\s*n?\.?usefulness_score/.test(src) ||
|
||||
/traffic_share_score[\s\S]{0,80}usefulness_score/.test(src),
|
||||
'nodes.js falls back to usefulness_score when traffic_share_score is absent');
|
||||
|
||||
console.log('\n────────────────────────────────────────');
|
||||
console.log(` ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -1,351 +0,0 @@
|
||||
/* test-issue-1461-mobile-page-actions.js — behavioral test for the mobile
|
||||
* page-actions wiring shipped in #1471. Loads mobile-page-actions.js into
|
||||
* a vm sandbox with a minimal DOM mock (no jsdom dep), then exercises the
|
||||
* public observable surfaces:
|
||||
*
|
||||
* 1. On mobile + /#/packets, hashchange synthesizes a "Filters ▾" button
|
||||
* and a "⏸" button under .nav-left, and clicking them delegates to the
|
||||
* real #filterToggleBtn / #pktPauseBtn elements.
|
||||
* 2. The bottom-nav More sheet receives 3 injected mirrors (Favorites /
|
||||
* Search / Customize) when the sheet element is present.
|
||||
* 3. On desktop viewport, no buttons are injected.
|
||||
* 4. Idempotent: re-init does not duplicate mirrors.
|
||||
* 5. Route change off /packets clears the slot AND closes the detail sheet.
|
||||
*
|
||||
* No source-grep. All assertions are on real function side effects.
|
||||
*/
|
||||
'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); }
|
||||
}
|
||||
|
||||
// ── Minimal Element / Document shim — no jsdom dep ────────────────────────
|
||||
function makeElement(tag) {
|
||||
const el = {
|
||||
tagName: String(tag || 'div').toUpperCase(),
|
||||
children: [],
|
||||
parentNode: null,
|
||||
id: '',
|
||||
className: '',
|
||||
type: '',
|
||||
title: '',
|
||||
textContent: '',
|
||||
_attrs: {},
|
||||
_listeners: {},
|
||||
_clickCount: 0,
|
||||
};
|
||||
el.style = { _cssText: '', get cssText() { return this._cssText; }, set cssText(v) { this._cssText = v; } };
|
||||
el.setAttribute = function (k, v) {
|
||||
this._attrs[k] = String(v);
|
||||
if (k === 'class') this.className = String(v);
|
||||
if (k === 'id') this.id = String(v);
|
||||
};
|
||||
el.getAttribute = function (k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; };
|
||||
el.appendChild = function (child) { child.parentNode = this; this.children.push(child); return child; };
|
||||
el.insertBefore = function (child, ref) {
|
||||
child.parentNode = this;
|
||||
const idx = this.children.indexOf(ref);
|
||||
if (idx < 0) this.children.push(child);
|
||||
else this.children.splice(idx, 0, child);
|
||||
return child;
|
||||
};
|
||||
el.removeChild = function (child) {
|
||||
const idx = this.children.indexOf(child);
|
||||
if (idx >= 0) this.children.splice(idx, 1);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
};
|
||||
el.addEventListener = function (type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); };
|
||||
el.dispatchEvent = function (ev) { (this._listeners[ev.type] || []).forEach(fn => fn(ev)); };
|
||||
el.click = function () {
|
||||
this._clickCount++;
|
||||
this.dispatchEvent({ type: 'click', target: this, preventDefault() {}, stopPropagation() {} });
|
||||
};
|
||||
el.closest = function (sel) {
|
||||
let cur = this;
|
||||
while (cur) {
|
||||
if (_elementMatches(cur, sel)) return cur;
|
||||
cur = cur.parentNode;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
el.querySelector = function (sel) { return _walk(this, sel, true)[0] || null; };
|
||||
el.querySelectorAll = function (sel) { return _walk(this, sel, false); };
|
||||
el.classList = {
|
||||
add(c) { const s = new Set(String(el.className).split(/\s+/).filter(Boolean)); s.add(c); el.className = Array.from(s).join(' '); },
|
||||
remove(c) { const s = new Set(String(el.className).split(/\s+/).filter(Boolean)); s.delete(c); el.className = Array.from(s).join(' '); },
|
||||
contains(c) { return new Set(String(el.className).split(/\s+/).filter(Boolean)).has(c); },
|
||||
};
|
||||
Object.defineProperty(el, 'innerHTML', {
|
||||
configurable: true,
|
||||
get() { return ''; },
|
||||
set(v) {
|
||||
if (v === '' || v == null) {
|
||||
el.children.forEach(c => { c.parentNode = null; });
|
||||
el.children = [];
|
||||
}
|
||||
},
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function _elementMatches(el, sel) {
|
||||
if (!el || !sel) return false;
|
||||
sel = String(sel).trim();
|
||||
// Comma list: any selector matches
|
||||
if (sel.indexOf(',') >= 0) {
|
||||
return sel.split(',').some(s => _elementMatches(el, s.trim()));
|
||||
}
|
||||
// Descendant combinator
|
||||
if (sel.indexOf(' ') >= 0) {
|
||||
const parts = sel.split(/\s+/);
|
||||
const last = parts[parts.length - 1];
|
||||
if (!_elementMatches(el, last)) return false;
|
||||
let cur = el.parentNode;
|
||||
for (let i = parts.length - 2; i >= 0; i--) {
|
||||
let found = false;
|
||||
while (cur) {
|
||||
if (_elementMatches(cur, parts[i])) { found = true; cur = cur.parentNode; break; }
|
||||
cur = cur.parentNode;
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// ID
|
||||
if (sel.startsWith('#')) return el.id === sel.slice(1);
|
||||
// Attribute [data-X] or [data-X="v"]
|
||||
const attrMatch = sel.match(/^\[([a-zA-Z0-9-]+)(?:="([^"]+)")?\]$/);
|
||||
if (attrMatch) {
|
||||
const k = attrMatch[1], v = attrMatch[2];
|
||||
if (!Object.prototype.hasOwnProperty.call(el._attrs, k)) return false;
|
||||
return v == null ? true : el._attrs[k] === v;
|
||||
}
|
||||
// .class
|
||||
if (sel.startsWith('.')) {
|
||||
return el.classList && el.classList.contains(sel.slice(1));
|
||||
}
|
||||
// tag.class
|
||||
if (sel.indexOf('.') > 0) {
|
||||
const [tag, ...cls] = sel.split('.');
|
||||
if (el.tagName.toLowerCase() !== tag.toLowerCase()) return false;
|
||||
return cls.every(c => el.classList && el.classList.contains(c));
|
||||
}
|
||||
// Bare tag
|
||||
return el.tagName.toLowerCase() === sel.toLowerCase();
|
||||
}
|
||||
|
||||
function _walk(root, sel, firstOnly) {
|
||||
const out = [];
|
||||
function visit(n) {
|
||||
if (n !== root && _elementMatches(n, sel)) {
|
||||
out.push(n);
|
||||
if (firstOnly) return true;
|
||||
}
|
||||
for (const c of (n.children || [])) {
|
||||
if (visit(c) && firstOnly) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const docElements = {};
|
||||
const docRoot = makeElement('html');
|
||||
const docBody = makeElement('body');
|
||||
docRoot.appendChild(docBody);
|
||||
const documentListeners = {};
|
||||
const windowListeners = {};
|
||||
|
||||
const documentMock = {
|
||||
documentElement: docRoot,
|
||||
body: docBody,
|
||||
createElement(tag) { return makeElement(tag); },
|
||||
getElementById(id) {
|
||||
if (docElements[id]) return docElements[id];
|
||||
function find(n) {
|
||||
if (n.id === id) return n;
|
||||
for (const c of (n.children || [])) { const r = find(c); if (r) return r; }
|
||||
return null;
|
||||
}
|
||||
return find(docRoot);
|
||||
},
|
||||
querySelector(sel) { return docRoot.querySelector(sel) || docBody.querySelector(sel); },
|
||||
querySelectorAll(sel) { return docRoot.querySelectorAll(sel).concat(docBody.querySelectorAll(sel)); },
|
||||
addEventListener(type, fn) { (documentListeners[type] = documentListeners[type] || []).push(fn); },
|
||||
dispatchEvent(ev) { (documentListeners[ev.type] || []).forEach(fn => fn(ev)); return true; },
|
||||
readyState: 'complete',
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
console,
|
||||
setTimeout, clearTimeout,
|
||||
JSON, Date, Math, Object, Array, String, Number, Boolean, Set, Map,
|
||||
document: documentMock,
|
||||
window: {
|
||||
innerWidth: opts.innerWidth || 390,
|
||||
addEventListener(type, fn) { (windowListeners[type] = windowListeners[type] || []).push(fn); },
|
||||
dispatchEvent(ev) { (windowListeners[ev.type] || []).forEach(fn => fn(ev)); return true; },
|
||||
},
|
||||
location: { hash: opts.hash || '#/packets' },
|
||||
};
|
||||
ctx.globalThis = ctx;
|
||||
ctx.window.document = documentMock;
|
||||
ctx.window.location = ctx.location;
|
||||
vm.createContext(ctx);
|
||||
ctx._docRoot = docRoot;
|
||||
ctx._docBody = docBody;
|
||||
ctx._documentListeners = documentListeners;
|
||||
ctx._windowListeners = windowListeners;
|
||||
ctx._registerElement = (el) => { if (el.id) docElements[el.id] = el; };
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadMPA(ctx) {
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'mobile-page-actions.js'), 'utf8');
|
||||
vm.runInContext(src, ctx);
|
||||
}
|
||||
|
||||
function buildMobileDOM(ctx, opts) {
|
||||
opts = opts || {};
|
||||
const body = ctx._docBody;
|
||||
|
||||
const nav = makeElement('div');
|
||||
nav.className = 'nav-left';
|
||||
body.appendChild(nav);
|
||||
|
||||
const realFilter = makeElement('button');
|
||||
realFilter.id = 'filterToggleBtn';
|
||||
realFilter.setAttribute('class', 'filter-toggle-btn');
|
||||
body.appendChild(realFilter);
|
||||
ctx._registerElement(realFilter);
|
||||
|
||||
const realPause = makeElement('button');
|
||||
realPause.id = 'pktPauseBtn';
|
||||
body.appendChild(realPause);
|
||||
ctx._registerElement(realPause);
|
||||
|
||||
if (opts.bottomNav !== false) {
|
||||
const sheet = makeElement('div');
|
||||
sheet.setAttribute('data-bottom-nav-sheet', '');
|
||||
body.appendChild(sheet);
|
||||
|
||||
['favToggle', 'searchToggle', 'customizeToggle'].forEach(id => {
|
||||
const btn = makeElement('button');
|
||||
btn.id = id;
|
||||
body.appendChild(btn);
|
||||
ctx._registerElement(btn);
|
||||
});
|
||||
const sep = makeElement('div');
|
||||
sep.setAttribute('class', 'bottom-nav-sheet-sep');
|
||||
sheet.appendChild(sep);
|
||||
}
|
||||
|
||||
const detailSheet = makeElement('div');
|
||||
detailSheet.id = 'mobileDetailSheet';
|
||||
detailSheet.classList.add('open');
|
||||
body.appendChild(detailSheet);
|
||||
ctx._registerElement(detailSheet);
|
||||
|
||||
return { nav, realFilter, realPause };
|
||||
}
|
||||
|
||||
console.log('── #1461 mobile-page-actions behavioral tests ──');
|
||||
|
||||
test('mobile + /#/packets: hashchange injects ⏸ + Filters ▾ buttons under .nav-left', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
const { nav, realFilter, realPause } = buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
|
||||
const slot = ctx.document.getElementById('navPageActions');
|
||||
assert.ok(slot, 'navPageActions slot was created');
|
||||
assert.strictEqual(slot.parentNode, nav, 'slot is appended under .nav-left');
|
||||
|
||||
const btnTexts = slot.children.map(c => c.textContent);
|
||||
assert.ok(btnTexts.includes('⏸'), 'pause button (⏸) injected — got: ' + JSON.stringify(btnTexts));
|
||||
assert.ok(btnTexts.some(t => /Filters/.test(t)), 'Filters button injected — got: ' + JSON.stringify(btnTexts));
|
||||
|
||||
const pauseBtn = slot.children.find(c => c.textContent === '⏸');
|
||||
pauseBtn.click();
|
||||
assert.strictEqual(realPause._clickCount, 1, 'pause-mirror delegates click to #pktPauseBtn');
|
||||
|
||||
const filtBtn = slot.children.find(c => /Filters/.test(c.textContent));
|
||||
filtBtn.click();
|
||||
assert.strictEqual(realFilter._clickCount, 1, 'Filters-mirror delegates click to #filterToggleBtn');
|
||||
});
|
||||
|
||||
test('mobile: 3 More-sheet mirrors (Favorites/Search/Customize) injected', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
|
||||
const sheet = ctx.document.querySelector('[data-bottom-nav-sheet]');
|
||||
assert.ok(sheet, 'sheet found');
|
||||
const mirrors = sheet.querySelectorAll('[data-mpa-mirror]');
|
||||
assert.strictEqual(mirrors.length, 3, 'exactly 3 mirrors injected — got ' + mirrors.length);
|
||||
const ids = mirrors.map(m => m.getAttribute('data-mpa-mirror')).sort();
|
||||
assert.deepStrictEqual(ids, ['customizeToggle', 'favToggle', 'searchToggle']);
|
||||
|
||||
const favReal = ctx.document.getElementById('favToggle');
|
||||
const favMirror = mirrors.find(m => m.getAttribute('data-mpa-mirror') === 'favToggle');
|
||||
favMirror.click();
|
||||
assert.strictEqual(favReal._clickCount, 1, 'Favorites mirror delegates click to #favToggle');
|
||||
});
|
||||
|
||||
test('mobile: idempotent — re-init does not duplicate More-sheet mirrors', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
const sheet = ctx.document.querySelector('[data-bottom-nav-sheet]');
|
||||
const mirrors = sheet.querySelectorAll('[data-mpa-mirror]');
|
||||
assert.strictEqual(mirrors.length, 3, 'still 3 mirrors after re-init — got ' + mirrors.length);
|
||||
});
|
||||
|
||||
test('desktop viewport (innerWidth=1280): slot stays empty', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 1280, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
const slot = ctx.document.getElementById('navPageActions');
|
||||
if (slot) {
|
||||
assert.strictEqual(slot.children.length, 0, 'desktop slot empty — got ' + slot.children.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile + non-packets route: hashchange clears the slot', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
loadMPA(ctx);
|
||||
ctx.location.hash = '#/map';
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
const slot = ctx.document.getElementById('navPageActions');
|
||||
if (slot) {
|
||||
assert.strictEqual(slot.children.length, 0, 'slot cleared off /packets — got ' + slot.children.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile detail sheet closes on route change away from /packets', () => {
|
||||
const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' });
|
||||
buildMobileDOM(ctx);
|
||||
const sheet = ctx.document.getElementById('mobileDetailSheet');
|
||||
assert.ok(sheet.classList.contains('open'), 'pre: sheet open');
|
||||
loadMPA(ctx);
|
||||
ctx.location.hash = '#/map';
|
||||
(ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' }));
|
||||
assert.ok(!sheet.classList.contains('open'), 'sheet closed after route change off /packets');
|
||||
});
|
||||
|
||||
console.log(`\n#1461 mobile-page-actions: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,226 +0,0 @@
|
||||
/* test-issue-1470-node-tile-helper.js — behavioral test for the
|
||||
* _applyTilesToNodeMap helper shipped in #1471 and roles.js
|
||||
* getActiveTileProvider() / getTileUrl() integration with the
|
||||
* MC_TILE_PROVIDERS registry.
|
||||
*
|
||||
* Strategy:
|
||||
* - vm-load public/map-tile-providers.js + public/roles.js into a single
|
||||
* sandbox with mocked localStorage + document(theme=dark).
|
||||
* - Extract the _applyTilesToNodeMap function source from public/nodes.js
|
||||
* by regex and run it in the same sandbox.
|
||||
* - Mock L.tileLayer / L.map to capture the URL + options the helper passes
|
||||
* and the .addTo() target. Assert that selecting voyager-inverted in the
|
||||
* customizer ends up calling tileLayer with the voyager URL AND setting
|
||||
* the tile pane filter to the provider.invertFilter string.
|
||||
*
|
||||
* No source-grep on the production fix. The assertions exercise the helper
|
||||
* code path end-to-end. Reverting the fix (re-hardcoding OSM) breaks this.
|
||||
*/
|
||||
'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); }
|
||||
}
|
||||
|
||||
function makeStorage() {
|
||||
const store = {};
|
||||
return {
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },
|
||||
setItem(k, v) { store[k] = String(v); },
|
||||
removeItem(k) { delete store[k]; },
|
||||
clear() { for (const k of Object.keys(store)) delete store[k]; },
|
||||
};
|
||||
}
|
||||
|
||||
function makeLeafletMock() {
|
||||
const calls = [];
|
||||
const tilePane = { style: { filter: '' } };
|
||||
const fakeMap = {
|
||||
_tileLayers: [],
|
||||
getPane: (name) => name === 'tilePane' ? tilePane : null,
|
||||
};
|
||||
const L = {
|
||||
tileLayer(url, opts) {
|
||||
const layer = {
|
||||
url, opts,
|
||||
addTo(map) {
|
||||
calls.push({ kind: 'tileLayer', url, opts, map });
|
||||
map._tileLayers.push(this);
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
},
|
||||
map() { return fakeMap; },
|
||||
marker() { return { addTo: () => ({ bindPopup: () => {} }) }; },
|
||||
};
|
||||
return { L, calls, fakeMap, tilePane };
|
||||
}
|
||||
|
||||
function makeSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const ctx = {
|
||||
console,
|
||||
setTimeout, clearTimeout,
|
||||
JSON, Date, Math, Object, Array, String, Number, Boolean, Set, Map,
|
||||
fetch: () => Promise.resolve({ ok: false, json: () => Promise.resolve({}) }),
|
||||
localStorage: makeStorage(),
|
||||
document: {
|
||||
documentElement: {
|
||||
getAttribute: () => opts.theme || 'dark',
|
||||
style: { getPropertyValue: () => '' },
|
||||
},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
getElementById: () => null,
|
||||
createElement: () => ({ style: {}, appendChild: () => {}, setAttribute: () => {}, addEventListener: () => {} }),
|
||||
addEventListener: () => {},
|
||||
body: { appendChild: () => {}, style: {} },
|
||||
head: { appendChild: () => {} },
|
||||
readyState: 'complete',
|
||||
},
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
matchMedia: () => ({ matches: opts.prefersDark !== false, addEventListener: () => {} }),
|
||||
},
|
||||
CustomEvent: function (type, init) { this.type = type; this.detail = (init && init.detail) || null; },
|
||||
};
|
||||
ctx.window.localStorage = ctx.localStorage;
|
||||
ctx.window.document = ctx.document;
|
||||
ctx.globalThis = ctx;
|
||||
vm.createContext(ctx);
|
||||
// After context creation, make the sandbox global object also be `window`
|
||||
// so `window.X = ...` in roles.js makes X a bare global the same file
|
||||
// can reference. We do this by copying assignments back — simpler: install
|
||||
// a Proxy. Cheapest practical fix: shadow common globals after load.
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadInto(ctx, relPath) {
|
||||
const src = fs.readFileSync(path.join(__dirname, relPath), 'utf8');
|
||||
vm.runInContext(src, ctx, { filename: relPath });
|
||||
// Mirror window.* back to sandbox globals so code that uses bare names
|
||||
// (which in a browser are window.X) can still resolve them in vm. We do
|
||||
// this AFTER each file load so window.getTileUrl, window.TILE_DARK, etc.
|
||||
// become bare refs `getTileUrl` / `TILE_DARK` for callers in the same
|
||||
// sandbox.
|
||||
for (const k of Object.keys(ctx.window)) {
|
||||
if (!(k in ctx)) ctx[k] = ctx.window[k];
|
||||
}
|
||||
}
|
||||
|
||||
function extractApplyTilesHelper() {
|
||||
const nodesSrc = fs.readFileSync(path.join(__dirname, 'public', 'nodes.js'), 'utf8');
|
||||
// Match the function _applyTilesToNodeMap(map) { ... } block. Brace-count.
|
||||
const startMatch = nodesSrc.match(/function\s+_applyTilesToNodeMap\s*\(map\)\s*\{/);
|
||||
if (!startMatch) throw new Error('_applyTilesToNodeMap definition not found in public/nodes.js');
|
||||
const start = startMatch.index;
|
||||
let depth = 0, i = start;
|
||||
for (; i < nodesSrc.length; i++) {
|
||||
const ch = nodesSrc[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) { i++; break; }
|
||||
}
|
||||
}
|
||||
return nodesSrc.slice(start, i);
|
||||
}
|
||||
|
||||
console.log('── #1470 node-detail inset-map tile-provider routing ──');
|
||||
|
||||
test('roles.js + providers: getActiveTileProvider returns null in light mode', () => {
|
||||
const ctx = makeSandbox({ theme: 'light', prefersDark: false });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
const p = ctx.window.getActiveTileProvider();
|
||||
assert.strictEqual(p, null, 'expected null in light mode, got ' + JSON.stringify(p));
|
||||
});
|
||||
|
||||
test('roles.js + providers: getActiveTileProvider returns selected provider in dark mode', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
const p = ctx.window.getActiveTileProvider();
|
||||
assert.ok(p, 'provider returned in dark mode');
|
||||
assert.ok(typeof p.url === 'string' && /voyager/.test(p.url),
|
||||
'provider url is voyager — got ' + JSON.stringify(p.url));
|
||||
assert.ok(typeof p.invertFilter === 'string' && /invert\(/.test(p.invertFilter),
|
||||
'voyager-inverted invertFilter present — got ' + JSON.stringify(p.invertFilter));
|
||||
});
|
||||
|
||||
test('roles.js: getTileUrl returns voyager URL in dark mode when voyager-inverted selected', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
const url = ctx.window.getTileUrl();
|
||||
assert.ok(/voyager/.test(url), 'getTileUrl returns voyager URL — got ' + url);
|
||||
});
|
||||
|
||||
test('_applyTilesToNodeMap: dark + voyager-inverted → tileLayer(voyagerURL) + invert filter', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
ctx.window.MC_setDarkTileProvider('voyager-inverted');
|
||||
|
||||
const mock = makeLeafletMock();
|
||||
ctx.L = mock.L;
|
||||
ctx._fakeMap = mock.fakeMap;
|
||||
vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' });
|
||||
vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx);
|
||||
|
||||
assert.ok(mock.calls.length >= 1, 'tileLayer was called — got ' + mock.calls.length + ' calls');
|
||||
const firstCall = mock.calls[0];
|
||||
assert.ok(/voyager/.test(firstCall.url),
|
||||
'first tileLayer URL is voyager — got ' + firstCall.url);
|
||||
assert.ok(/invert\(/.test(mock.tilePane.style.filter),
|
||||
'tile pane filter set to invert(...) — got ' + JSON.stringify(mock.tilePane.style.filter));
|
||||
});
|
||||
|
||||
test('_applyTilesToNodeMap: dark + carto-dark (non-inverted) → no invert filter applied', () => {
|
||||
const ctx = makeSandbox({ theme: 'dark' });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
ctx.window.MC_setDarkTileProvider('carto-dark');
|
||||
|
||||
const mock = makeLeafletMock();
|
||||
ctx.L = mock.L;
|
||||
ctx._fakeMap = mock.fakeMap;
|
||||
vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' });
|
||||
vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx);
|
||||
|
||||
assert.ok(mock.calls.length >= 1, 'tileLayer was called');
|
||||
assert.ok(/cartocdn|basemaps\.cartocdn|dark_all/.test(mock.calls[0].url),
|
||||
'carto-dark URL — got ' + mock.calls[0].url);
|
||||
assert.strictEqual(mock.tilePane.style.filter, '',
|
||||
'no invert filter for carto-dark — got ' + JSON.stringify(mock.tilePane.style.filter));
|
||||
});
|
||||
|
||||
test('_applyTilesToNodeMap: light mode → uses TILE_LIGHT (carto light_all), no invert', () => {
|
||||
const ctx = makeSandbox({ theme: 'light', prefersDark: false });
|
||||
loadInto(ctx, 'public/map-tile-providers.js');
|
||||
loadInto(ctx, 'public/roles.js');
|
||||
|
||||
const mock = makeLeafletMock();
|
||||
ctx.L = mock.L;
|
||||
ctx._fakeMap = mock.fakeMap;
|
||||
vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' });
|
||||
vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx);
|
||||
|
||||
assert.ok(mock.calls.length >= 1, 'tileLayer called');
|
||||
assert.ok(/light_all|openstreetmap/.test(mock.calls[0].url),
|
||||
'light mode uses light tile URL — got ' + mock.calls[0].url);
|
||||
assert.strictEqual(mock.tilePane.style.filter, '', 'no invert filter in light mode');
|
||||
});
|
||||
|
||||
console.log(`\n#1470 node-detail tile helper: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Issue #1473 — Prefix generator (#/analytics?tab=prefix-tool) must NOT
|
||||
* suggest any prefix whose FIRST byte is 0x00 or 0xFF, since the MeshCore
|
||||
* firmware keygen routine re-rolls such identities by convention (HEAD
|
||||
* 8ede7641, examples/simple_repeater/main.cpp:83).
|
||||
*
|
||||
* Reporter: @halo779 (community).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const assert = require('assert');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'prefix-reserved.js'), 'utf8');
|
||||
const sandbox = { module: { exports: {} }, exports: {}, window: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox);
|
||||
const PR = (sandbox.module.exports && sandbox.module.exports.isReservedPrefix)
|
||||
? sandbox.module.exports
|
||||
: sandbox.window.PrefixReserved;
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
console.log('\n=== #1473: filterReserved() drops 00 and FF from 1-byte space ===');
|
||||
const all1byte = [];
|
||||
for (let i = 0; i < 256; i++) all1byte.push(i.toString(16).toUpperCase().padStart(2, '0'));
|
||||
const filtered = PR.filterReserved(all1byte);
|
||||
|
||||
test('filtered length is 254 (256 - 2 reserved)', () => assert.strictEqual(filtered.length, 254));
|
||||
test('"00" not in filtered output', () => assert.strictEqual(filtered.indexOf('00'), -1));
|
||||
test('"FF" not in filtered output', () => assert.strictEqual(filtered.indexOf('FF'), -1));
|
||||
test('"01" still present', () => assert.ok(filtered.indexOf('01') >= 0));
|
||||
test('"FE" still present', () => assert.ok(filtered.indexOf('FE') >= 0));
|
||||
test('"A3" still present', () => assert.ok(filtered.indexOf('A3') >= 0));
|
||||
|
||||
console.log('\n=== #1473: reservedCount() per byte length ===');
|
||||
test('1-byte reserved count = 2', () => assert.strictEqual(PR.reservedCount(1), 2));
|
||||
test('2-byte reserved count = 512', () => assert.strictEqual(PR.reservedCount(2), 512));
|
||||
test('3-byte reserved count = 131072', () => assert.strictEqual(PR.reservedCount(3), 131072));
|
||||
|
||||
console.log('\n=== #1473: simulated generator never returns a reserved prefix ===');
|
||||
// Mirrors the production generator loop: random sampling + reserved filter.
|
||||
// We pre-seed the RNG to return the reserved hex values up front; the loop
|
||||
// MUST iterate past them and end on a non-reserved value.
|
||||
function generateOne(bytes, usedSet, rng) {
|
||||
const totalSpace = Math.pow(256, bytes);
|
||||
const hexLen = bytes * 2;
|
||||
let attempts = 0, prefix;
|
||||
do {
|
||||
prefix = Math.floor(rng() * totalSpace).toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
} while ((usedSet.has(prefix) || PR.isReservedPrefix(prefix)) && ++attempts < 2000);
|
||||
return prefix;
|
||||
}
|
||||
|
||||
// Sequence that biases towards reserved first, then a valid prefix.
|
||||
const seq = [0, 255, 0, 255, 0, 0x42, 0x77, 0x10];
|
||||
let rngIdx = 0;
|
||||
const biasedRng = () => seq[rngIdx++ % seq.length] / 256;
|
||||
for (let k = 0; k < 20; k++) {
|
||||
const out = generateOne(1, new Set(), biasedRng);
|
||||
test('1-byte gen run #' + k + ' (' + out + ') is not reserved',
|
||||
() => assert.strictEqual(PR.isReservedPrefix(out), false));
|
||||
}
|
||||
|
||||
// Enumeration-style generator (fallback path in production) — first available
|
||||
// non-reserved.
|
||||
function enumerateFirstFree(bytes, usedSet) {
|
||||
const totalSpace = Math.pow(256, bytes);
|
||||
const hexLen = bytes * 2;
|
||||
for (let i = 0; i < totalSpace; i++) {
|
||||
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
if (!usedSet.has(p) && !PR.isReservedPrefix(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
test('enumerate-first-free (1-byte, empty used) returns "01" (skips 00)',
|
||||
() => assert.strictEqual(enumerateFirstFree(1, new Set()), '01'));
|
||||
|
||||
// Used = everything except 00, FE, FF → must return FE, NOT 00 or FF.
|
||||
const usedAllBut3 = new Set();
|
||||
for (let i = 0; i < 256; i++) {
|
||||
if (i !== 0x00 && i !== 0xFE && i !== 0xFF) {
|
||||
usedAllBut3.add(i.toString(16).toUpperCase().padStart(2, '0'));
|
||||
}
|
||||
}
|
||||
test('with only {00, FE, FF} free, generator returns "FE" (NOT 00 or FF)',
|
||||
() => assert.strictEqual(enumerateFirstFree(1, usedAllBut3), 'FE'));
|
||||
|
||||
console.log('\n=== #1473: analytics.js wires reserved filter into the generator ===');
|
||||
const analyticsSrc = fs.readFileSync(path.join(__dirname, 'public', 'analytics.js'), 'utf8');
|
||||
test('analytics.js references PrefixReserved (generator wiring)',
|
||||
() => assert.ok(/PrefixReserved/.test(analyticsSrc)));
|
||||
test('analytics.js mentions the reserved-excluded note in the generator card',
|
||||
() => assert.ok(/0x00 and 0xFF[\s\S]{0,200}excluded/i.test(analyticsSrc)));
|
||||
test('analytics.js loads prefix-reserved before analytics in index.html',
|
||||
() => {
|
||||
const html = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
|
||||
const pri = html.indexOf('prefix-reserved.js');
|
||||
const ani = html.indexOf('analytics.js');
|
||||
assert.ok(pri > 0 && ani > 0 && pri < ani, 'prefix-reserved.js must load before analytics.js');
|
||||
});
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
if (failed) process.exit(1);
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Issue #1473 — The MeshCore firmware keygen routine re-rolls any new
|
||||
* identity whose public-key first byte is 0x00 or 0xFF — a KEYGEN
|
||||
* CONVENTION (not a protocol-level rule). Firmware citation (HEAD
|
||||
* 8ede7641, examples/simple_repeater/main.cpp:83):
|
||||
*
|
||||
* while (... && (pub_key[0] == 0x00 || pub_key[0] == 0xFF)) { ... }
|
||||
*
|
||||
* Only the FIRST byte is checked — internal 00 / FF bytes are normal.
|
||||
*
|
||||
* This test pins:
|
||||
* - isReservedPrefix() semantics (case-insensitive, first byte only)
|
||||
* - markReservedCells() applies .prefix-reserved + disables click on matrix
|
||||
*
|
||||
* Reporter: @halo779 (community).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const assert = require('assert');
|
||||
|
||||
// Load prefix-reserved.js as a CommonJS module under vm.
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'prefix-reserved.js'), 'utf8');
|
||||
const sandbox = { module: { exports: {} }, exports: {}, window: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox);
|
||||
const PR = (sandbox.module.exports && sandbox.module.exports.isReservedPrefix)
|
||||
? sandbox.module.exports
|
||||
: sandbox.window.PrefixReserved;
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
console.log('\n=== #1473: isReservedPrefix() ===');
|
||||
test('"00" is reserved', () => assert.strictEqual(PR.isReservedPrefix('00'), true));
|
||||
test('"FF" is reserved', () => assert.strictEqual(PR.isReservedPrefix('FF'), true));
|
||||
test('lowercase "ff" is reserved', () => assert.strictEqual(PR.isReservedPrefix('ff'), true));
|
||||
test('lowercase "00" is reserved', () => assert.strictEqual(PR.isReservedPrefix('00'), true));
|
||||
test('"01" is NOT reserved', () => assert.strictEqual(PR.isReservedPrefix('01'), false));
|
||||
test('"FE" is NOT reserved', () => assert.strictEqual(PR.isReservedPrefix('FE'), false));
|
||||
test('"A3" is NOT reserved', () => assert.strictEqual(PR.isReservedPrefix('A3'), false));
|
||||
test('2-byte "0042" reserved (first byte = 00)', () => assert.strictEqual(PR.isReservedPrefix('0042'), true));
|
||||
test('2-byte "FF01" reserved (first byte = FF)', () => assert.strictEqual(PR.isReservedPrefix('FF01'), true));
|
||||
test('2-byte "01FF" NOT reserved (first byte = 01)', () => assert.strictEqual(PR.isReservedPrefix('01FF'), false));
|
||||
test('3-byte "001234" reserved', () => assert.strictEqual(PR.isReservedPrefix('001234'), true));
|
||||
test('3-byte "FFAABB" reserved', () => assert.strictEqual(PR.isReservedPrefix('FFAABB'), true));
|
||||
test('empty string NOT reserved', () => assert.strictEqual(PR.isReservedPrefix(''), false));
|
||||
|
||||
console.log('\n=== #1473: markReservedCells() on a mock 1-byte matrix ===');
|
||||
function mkCell(hex) {
|
||||
const classes = new Set(['hash-cell']);
|
||||
const attrs = { 'data-hex': hex };
|
||||
return {
|
||||
getAttribute: k => attrs[k],
|
||||
setAttribute: (k, v) => { attrs[k] = String(v); },
|
||||
classList: {
|
||||
add: c => classes.add(c),
|
||||
remove: c => classes.delete(c),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
_attrs: attrs,
|
||||
_classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
const cells = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const hex = i.toString(16).toUpperCase().padStart(2, '0');
|
||||
cells.push(mkCell(hex));
|
||||
}
|
||||
cells[0].classList.add('hash-active');
|
||||
cells[255].classList.add('hash-active');
|
||||
cells[10].classList.add('hash-active');
|
||||
|
||||
const root = { querySelectorAll: () => cells };
|
||||
const marked = PR.markReservedCells(root);
|
||||
|
||||
test('markReservedCells returned 2 (00 and FF)', () => assert.strictEqual(marked, 2));
|
||||
test('cell 00 has .prefix-reserved', () => assert.strictEqual(cells[0]._classes.has('prefix-reserved'), true));
|
||||
test('cell FF has .prefix-reserved', () => assert.strictEqual(cells[255]._classes.has('prefix-reserved'), true));
|
||||
test('cell 01 does NOT have .prefix-reserved', () => assert.strictEqual(cells[1]._classes.has('prefix-reserved'), false));
|
||||
test('cell A3 does NOT have .prefix-reserved', () => assert.strictEqual(cells[0xA3]._classes.has('prefix-reserved'), false));
|
||||
test('cell FE does NOT have .prefix-reserved', () => assert.strictEqual(cells[0xFE]._classes.has('prefix-reserved'), false));
|
||||
test('cell 00 had .hash-active removed (no click handler attached)', () => assert.strictEqual(cells[0]._classes.has('hash-active'), false));
|
||||
test('cell FF had .hash-active removed', () => assert.strictEqual(cells[255]._classes.has('hash-active'), false));
|
||||
test('cell 10 still has .hash-active (untouched)', () => assert.strictEqual(cells[10]._classes.has('hash-active'), true));
|
||||
test('cell 00 has aria-disabled=true', () => assert.strictEqual(cells[0]._attrs['aria-disabled'], 'true'));
|
||||
test('cell FF has aria-disabled=true', () => assert.strictEqual(cells[255]._attrs['aria-disabled'], 'true'));
|
||||
test('cell 00 title cites MeshCore firmware keygen', () => assert.ok(/MeshCore firmware keygen/i.test(cells[0]._attrs.title)));
|
||||
test('cell FF title cites MeshCore firmware keygen', () => assert.ok(/MeshCore firmware keygen/i.test(cells[255]._attrs.title)));
|
||||
|
||||
console.log('\n=== #1473: prefix-reserved.js loaded by index.html ===');
|
||||
const indexHtml = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
|
||||
test('index.html includes prefix-reserved.js script', () => assert.ok(/prefix-reserved\.js/.test(indexHtml)));
|
||||
const styleCss = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
|
||||
test('style.css defines .prefix-reserved', () => assert.ok(/\.prefix-reserved\b/.test(styleCss)));
|
||||
test('style.css disables pointer events on reserved cell',
|
||||
() => assert.ok(/\.prefix-reserved[\s\S]{0,400}pointer-events:\s*none/i.test(styleCss)));
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
if (failed) process.exit(1);
|
||||
@@ -87,13 +87,12 @@ async function run() {
|
||||
}
|
||||
});
|
||||
|
||||
await test('Mobile viewport (375px): observer column drops from row, but IATA badge still appears in expanded detail panel (#1415 locked spec)', async () => {
|
||||
// #1415 locked column-priority spec: col-observer is tier-3 (desktop only,
|
||||
// hidden ≤1024px). The iron rule: anything hidden from the row at the
|
||||
// current viewport MUST appear in the expanded Details pane. So at 375px:
|
||||
// (a) `td.col-observer` is hidden (display:none via .col-hidden)
|
||||
// (b) tapping a row opens the panel whose .detail-meta carries the
|
||||
// Observer row + .badge-iata next to the observer name.
|
||||
await test('Mobile viewport (375px): observer IATA badge stays visible — not clipped', async () => {
|
||||
// #1189 R1 mesh-operator finding: no narrow-viewport validation existed.
|
||||
// At 375px (iPhone SE) the .col-observer cell must still render its
|
||||
// .badge-iata pill within the viewport bounds. If a future CSS change
|
||||
// moves observer behind a hidden column or clips the badge off-screen,
|
||||
// this assertion turns red.
|
||||
const mobile = await browser.newContext({ viewport: { width: 375, height: 812 } });
|
||||
const mpage = await mobile.newPage();
|
||||
mpage.setDefaultTimeout(15000);
|
||||
@@ -102,25 +101,16 @@ async function run() {
|
||||
await mpage.reload({ waitUntil: 'load' });
|
||||
await mpage.waitForSelector('[data-loaded="true"]', { timeout: 20000 });
|
||||
await mpage.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
|
||||
|
||||
// (a) observer column hidden in the row at 375px
|
||||
const rowObserverVisible = await mpage.$eval(
|
||||
'td.col-observer',
|
||||
el => window.getComputedStyle(el).display !== 'none'
|
||||
).catch(() => false);
|
||||
assert(!rowObserverVisible,
|
||||
'observer column should be hidden in rows at 375px (tier-3, desktop-only per #1415 spec)');
|
||||
|
||||
// (b) tap first row → detail panel renders observer + IATA badge
|
||||
const firstRow = await mpage.$('table tbody tr[data-hash]');
|
||||
assert(firstRow, 'no packet row found to tap');
|
||||
await firstRow.click();
|
||||
await mpage.waitForSelector('.detail-meta', { timeout: 10000 });
|
||||
const detailIata = await mpage.$('.detail-meta .badge-iata');
|
||||
assert(detailIata, '.badge-iata must appear in .detail-meta after tapping a row at 375px');
|
||||
const box = await detailIata.boundingBox();
|
||||
assert(box && box.width > 0 && box.height > 0,
|
||||
`.detail-meta .badge-iata has zero/no dimensions: ${box && (box.width + 'x' + box.height)}`);
|
||||
const badge = await mpage.$('td.col-observer .badge-iata');
|
||||
assert(badge, 'no .badge-iata rendered at 375px viewport');
|
||||
// Must be in the viewport horizontally — not clipped beyond the 375px edge.
|
||||
const box = await badge.boundingBox();
|
||||
assert(box, '.badge-iata has no bounding box (not rendered)');
|
||||
assert(box.x >= 0, `.badge-iata x=${box.x} is off the left edge at 375px`);
|
||||
assert(box.x + box.width <= 375 + 1,
|
||||
`.badge-iata right edge ${box.x + box.width} exceeds 375px viewport`);
|
||||
assert(box.width > 0 && box.height > 0,
|
||||
`.badge-iata has zero dimensions (collapsed/clipped): ${box.width}x${box.height}`);
|
||||
await mobile.close();
|
||||
});
|
||||
|
||||
|
||||
+2
-13
@@ -35,17 +35,6 @@ const VIEWPORTS = [
|
||||
{ w: 1920, h: 900, expectHidden: false }, // AC #5: also exercise 1920px
|
||||
];
|
||||
|
||||
// Per-(page, viewport) override map. After #1415 the packets table's
|
||||
// column priorities were rewritten to the locked spec — at 1080px (>1024)
|
||||
// every packets column is visible (data-priority 3 columns reveal above
|
||||
// 1024px, priority 5 above 768px). The /nodes and /observers tables still
|
||||
// match the original #1056 contract. Default falls through to VIEWPORTS.
|
||||
function expectHidden(pageName, w) {
|
||||
if (pageName === 'packets' && w >= 1080) return false;
|
||||
const vp = VIEWPORTS.find(v => v.w === w);
|
||||
return vp ? vp.expectHidden : false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
@@ -103,7 +92,7 @@ function expectHidden(pageName, w) {
|
||||
};
|
||||
}, p.tableSel);
|
||||
|
||||
if (expectHidden(p.name, vp.w)) {
|
||||
if (vp.expectHidden) {
|
||||
assert(info.hiddenCount >= 1, `expected ≥1 hidden col at ${vp.w}px, got ${info.hiddenCount}`);
|
||||
assert(info.hasPill && info.pillVisible, `expected visible +N pill at ${vp.w}px`);
|
||||
assert(/\+\d+/.test(info.pillText), `pill text "${info.pillText}" missing +N marker`);
|
||||
@@ -116,7 +105,7 @@ function expectHidden(pageName, w) {
|
||||
}
|
||||
});
|
||||
|
||||
if (expectHidden(p.name, vp.w)) {
|
||||
if (vp.expectHidden) {
|
||||
await step(`${tag}: clicking pill reveals hidden columns`, async () => {
|
||||
// Click pill
|
||||
const pillSel = `${p.tableSel} .col-hidden-pill`;
|
||||
|
||||
Reference in New Issue
Block a user