Compare commits

..

2 Commits

Author SHA1 Message Date
openclaw-bot 4ea12087f2 fix(mqtt): persistent session + parallel handler (#1337)
paho defaults (CleanSession=true, empty random ClientID per reconnect,
Order=true) caused the staging ingestor to receive ~7 msg/h while
mosquitto_sub on the same broker/creds/topics received ~6720/h — a 200x
gap. Every watchdog-driven reconnect (~every 5min) made the broker treat
us as a brand-new session and drop the queued backlog.

buildMQTTOpts now sets:
  - SetClientID("corescope-ingestor-<hostname>-<source-tag>")
    persistent + unique across sources, stable across restarts
  - SetCleanSession(false)
    broker keeps subscription state across reconnects and replays the
    backlog we missed
  - SetKeepAlive(30 * time.Second)
    paho-level half-open detection (was unset; relying on OS keepalive)
  - SetOrderMatters(false)
    handler dispatch is parallel; one slow packet no longer stalls all
    others under burst load

The existing watchdog (#1212/#1216) is untouched. Reconnect throttle
(MaxReconnectInterval=30s) is unchanged — no reconnect storm.

Fixes #1337
2026-05-24 03:00:42 +00:00
openclaw-bot 2fd579bc6e test(mqtt): RED — pin persistent-session paho opts for #1337
Three tests that fail on master:
- TestBuildMQTTOpts_PersistentSession_Issue1337 — asserts CleanSession=false,
  non-empty ClientID embedding hostname+source name, KeepAlive=30s, Order=false
- TestBuildMQTTOpts_ClientIDStableAcrossBuilds_Issue1337 — same source name +
  hostname must yield identical ClientID across two builds (otherwise reconnect
  = new session = broker drops the backlog)
- TestBuildMQTTOpts_ClientIDUniquePerSource_Issue1337 — distinct source names
  must yield distinct ClientIDs (duplicate ClientID = broker disconnects the
  older session, infinite flap)

Refs #1337
2026-05-24 02:59:10 +00:00
17 changed files with 179 additions and 991 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"664 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"659 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"37.72%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"38.88%","color":"red"}
-3
View File
@@ -105,7 +105,6 @@ jobs:
node test-channel-fluid-layout.js
node test-issue-1279-p2-code-filter.js
node test-area-filter.js
node test-issue-1293-marker-shapes.js
- name: Verify proto syntax
run: |
@@ -251,7 +250,6 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
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-stats-1343-e2e.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
@@ -285,7 +283,6 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1244-live-vcr-row-hints-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1224-channels-mobile-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1281-location-row-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt
+21 -1
View File
@@ -364,11 +364,31 @@ func buildMQTTOpts(source MQTTSource) *mqtt.ClientOptions {
if tag == "" {
tag = source.Broker
}
// #1337: paho defaults silently throttle delivery on this broker.
// - CleanSession=true + empty ClientID (random per reconnect) made the
// broker treat every reconnect as a brand-new session and discard the
// backlog it had queued since the previous disconnect. With watchdog
// reconnects every ~5min on staging, this lost ~99% of messages.
// - Order=true serialized the default publish handler; one slow packet
// blocked all others, compounding the loss under bursts.
// Fix: persistent unique ClientID + CleanSession=false (broker keeps
// our subscription state across reconnects and forwards what we missed),
// explicit KeepAlive so half-open TCP is detected at the paho layer, and
// Order=false for parallel handler dispatch.
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown-host"
}
clientID := "corescope-ingestor-" + hostname + "-" + tag
opts := mqtt.NewClientOptions().
AddBroker(source.Broker).
SetClientID(clientID).
SetCleanSession(false).
SetKeepAlive(30 * time.Second).
SetOrderMatters(false).
SetAutoReconnect(true).
SetConnectRetry(true).
SetOrderMatters(true).
SetMaxReconnectInterval(30 * time.Second).
SetConnectTimeout(10 * time.Second).
SetWriteTimeout(10 * time.Second)
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"os"
"strings"
"testing"
"time"
)
// Issue #1337: paho client misconfigured — ingestor receives 200× fewer
// messages than mosquitto_sub on the same broker/creds/topics. Root cause
// (hypothesis 1+5): paho defaults — CleanSession=true, empty ClientID
// (auto-random per reconnect), Order=true (handler serialized) — combined
// with the reconnect-every-5min watchdog meant the broker dropped queued
// messages on every reconnect AND the handler couldn't keep up under load.
//
// These tests pin the four paho options that fix the gap:
// 1. CleanSession=false — broker keeps the subscription state across
// reconnects instead of treating each dial
// as a brand-new session.
// 2. ClientID = persistent — broker recognizes the returning session.
// Empty ClientID makes paho generate a fresh
// random one on every reconnect, which is
// treated as a new client by the broker.
// 3. KeepAlive = 30s — half-open TCP detected at the paho layer
// instead of waiting for OS keepalive.
// 4. Order = false — handler dispatch is parallel; one slow
// packet does not block all the others.
//
// All four must be set in buildMQTTOpts. This test fails on master.
func TestBuildMQTTOpts_PersistentSession_Issue1337(t *testing.T) {
source := MQTTSource{
Broker: "ssl://broker.example:8883",
Name: "sjc-test",
}
opts := buildMQTTOpts(source)
if opts.CleanSession {
t.Error("CleanSession must be false (#1337): broker drops queued msgs across reconnects when true")
}
host, _ := os.Hostname()
if opts.ClientID == "" {
t.Fatal("ClientID must be set to a persistent value (#1337): empty = paho generates random per reconnect, broker treats every reconnect as new session")
}
if !strings.Contains(opts.ClientID, "sjc-test") {
t.Errorf("ClientID must embed source name for uniqueness across sources, got %q", opts.ClientID)
}
if host != "" && !strings.Contains(opts.ClientID, host) {
t.Errorf("ClientID must embed hostname for uniqueness across deployments, got %q (host=%q)", opts.ClientID, host)
}
if opts.KeepAlive != int64((30 * time.Second).Seconds()) {
t.Errorf("KeepAlive must be 30s (#1337): got %ds — needed so paho detects half-open TCP", opts.KeepAlive)
}
if opts.Order {
t.Error("Order must be false (#1337): default true serializes handler dispatch; a slow packet stalls all others")
}
}
// Stability: ClientID must be deterministic for a given (hostname, source)
// across two builds. Otherwise reconnect = new session = lost backlog.
func TestBuildMQTTOpts_ClientIDStableAcrossBuilds_Issue1337(t *testing.T) {
source := MQTTSource{Broker: "ssl://broker.example:8883", Name: "stable-test"}
a := buildMQTTOpts(source).ClientID
b := buildMQTTOpts(source).ClientID
if a == "" {
t.Fatal("ClientID empty")
}
if a != b {
t.Errorf("ClientID must be stable across buildMQTTOpts calls (#1337): %q vs %q — random = broker drops session on reconnect", a, b)
}
}
// Distinct sources must NOT share a ClientID — broker disconnects the older
// session whenever a duplicate ClientID connects, causing flapping.
func TestBuildMQTTOpts_ClientIDUniquePerSource_Issue1337(t *testing.T) {
a := buildMQTTOpts(MQTTSource{Broker: "ssl://a:8883", Name: "alpha"}).ClientID
b := buildMQTTOpts(MQTTSource{Broker: "ssl://b:8883", Name: "beta"}).ClientID
if a == b {
t.Errorf("distinct sources must get distinct ClientIDs (#1337): both got %q — duplicate IDs cause broker to disconnect the older one, infinite flap", a)
}
}
+10 -75
View File
@@ -16,20 +16,6 @@ import (
// pulse here is sufficient to keep the snapshot fresh.
const NeighborEdgesBuilderInterval = 60 * time.Second
// neighborBuilderMaxBatch caps how many observation rows a single
// delta tick may process (#1339). With max_open_conns=1, an unbounded
// scan on a multi-million-row table holds the SQLite write lock for
// minutes and starves MQTT ingest. The cap keeps each tick bounded;
// if a backlog accumulates, successive ticks drain it 50k rows at a
// time without ever blocking ingest for long.
const neighborBuilderMaxBatch = 50000
// neighborBuilderSlowTickThreshold is the per-tick wallclock budget
// for the builder. Exceeding it is logged loudly so operators can
// catch a regression of #1339 quickly. The full instrumentation
// framework is tracked in #1340.
const neighborBuilderSlowTickThreshold = 5 * time.Second
// payloadADVERT mirrors the constant in cmd/server/decoder.go.
// Duplicated rather than imported so the ingestor binary stays
// independent of the server package.
@@ -56,25 +42,13 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
stop := make(chan struct{})
done := make(chan struct{})
// Synchronous warm-up: on a fresh DB this is a full scan; on a DB
// with persisted neighbor_edges (most restarts), the watermark
// short-circuits it into a delta scan. Loop until the per-tick
// batch cap stops triggering so we drain any backlog before
// returning — first server load needs a fully-populated table.
wuStart := time.Now()
var wuTotal int
for {
n, err := s.buildAndPersistNeighborEdges()
if err != nil {
log.Printf("[neighbor-build] initial build error: %v", err)
break
}
wuTotal += n
if n < neighborBuilderMaxBatch {
break
}
// Synchronous warm-up: a single pass so the first server load
// after process start sees a populated table.
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
log.Printf("[neighbor-build] initial build error: %v", err)
} else {
log.Printf("[neighbor-build] initial build: %d edges upserted", n)
}
log.Printf("[neighbor-build] initial build: %d edges upserted in %s", wuTotal, time.Since(wuStart))
var stopOnce sync.Once
go func() {
@@ -84,16 +58,10 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
for {
select {
case <-t.C:
start := time.Now()
n, err := s.buildAndPersistNeighborEdges()
dur := time.Since(start)
if err != nil {
log.Printf("[neighbor-build] tick error after %s: %v", dur, err)
if n, err := s.buildAndPersistNeighborEdges(); err != nil {
log.Printf("[neighbor-build] tick error: %v", err)
} else if n > 0 {
log.Printf("[neighbor-build] tick: %d edges in %s (delta from watermark)", n, dur)
}
if dur > neighborBuilderSlowTickThreshold {
log.Printf("[neighbor-build] SLOW tick: %s — possible regression of #1339", dur)
log.Printf("[neighbor-build] %d edges upserted", n)
}
case <-stop:
return
@@ -115,21 +83,6 @@ func (s *Store) StartNeighborEdgesBuilder(interval time.Duration) func() {
// observer↔last-hop on all packet types) and upserts them into
// neighbor_edges. Returns count of attempted upserts.
//
// Watermark / delta semantics (#1339): the builder derives a watermark
// from MAX(neighbor_edges.last_seen). On an empty edges table (fresh
// DB), watermark is 0 and the builder does a full warm-up scan. On
// every subsequent call, the SELECT is restricted to observations
// whose timestamp is strictly greater than the watermark, bounded by
// neighborBuilderMaxBatch. neighbor_edges itself is the persistence —
// no metadata table or in-memory state is required, and restarts
// resume cleanly from whatever the table reflects.
//
// Trade-off (documented for #1340 follow-up): an anomalously-old
// observation that arrives AFTER its timestamp has already been
// crossed by the watermark will be skipped. Acceptable for an
// approximate neighbor graph; a periodic full-rebuild can be added
// later if needed.
//
// Resolution of hop-prefix → full pubkey is done via a one-shot
// SELECT of (lowered) pubkey prefixes from nodes. Prefixes with
// multiple candidates are skipped (matches the conservative
@@ -140,21 +93,6 @@ func (s *Store) buildAndPersistNeighborEdges() (int, error) {
return 0, fmt.Errorf("build prefix index: %w", err)
}
// Derive the watermark from the existing edges table. RFC3339
// → epoch seconds so it can be compared against observations.timestamp
// (stored as INTEGER unix epoch). On an empty edges table both the
// query and the parse return zero → full warm-up scan.
var watermarkRFC sql.NullString
if err := s.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&watermarkRFC); err != nil {
return 0, fmt.Errorf("read watermark: %w", err)
}
var watermarkEpoch int64
if watermarkRFC.Valid && watermarkRFC.String != "" {
if t, parseErr := time.Parse(time.RFC3339, watermarkRFC.String); parseErr == nil {
watermarkEpoch = t.Unix()
}
}
rows, err := s.db.Query(`SELECT
t.payload_type,
t.decoded_json,
@@ -164,10 +102,7 @@ func (s *Store) buildAndPersistNeighborEdges() (int, error) {
o.timestamp
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
WHERE o.timestamp > ?
ORDER BY o.timestamp
LIMIT ?`, watermarkEpoch, neighborBuilderMaxBatch)
LEFT JOIN observers obs ON obs.rowid = o.observer_idx`)
if err != nil {
return 0, fmt.Errorf("scan observations: %w", err)
}
-195
View File
@@ -1,195 +0,0 @@
package main
import (
"fmt"
"path/filepath"
"testing"
"time"
)
// TestNeighborEdgesBuilderDeltaScan enforces issue #1339:
// after the initial (warm-up) full build, subsequent ticks of
// buildAndPersistNeighborEdges MUST scan only observations newer
// than the most recent edge already persisted. The watermark is
// derived from MAX(neighbor_edges.last_seen) — neighbor_edges itself
// is the persistence, no separate metadata table.
//
// RED expectations:
// 1. After warm-up that produces edges, a second build with NO new
// observations is a fast no-op (<1s) and writes nothing.
// 2. After inserting K observations with timestamps strictly newer
// than the prior MAX(last_seen), the next build upserts exactly
// K edges in <1s.
// 3. Initial build (empty neighbor_edges) still does a full scan
// (warm-up preserved).
func TestNeighborEdgesBuilderDeltaScan(t *testing.T) {
if testing.Short() {
t.Skip("synthetic 100k-row benchmark; skipped in -short")
}
dir := t.TempDir()
dbPath := filepath.Join(dir, "delta.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
if _, err := store.db.Exec(
`INSERT INTO nodes (public_key, name) VALUES (?, ?), (?, ?)`,
"aaaaaaaaaa", "from-node",
"bbbbbbbbbb", "first-hop",
); err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(
`INSERT INTO observers (id, name) VALUES (?, ?)`,
"obs-1", "observer-1",
); err != nil {
t.Fatal(err)
}
var obsRowid int64
if err := store.db.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, "obs-1").Scan(&obsRowid); err != nil {
t.Fatal(err)
}
// Baseline timestamps: a contiguous block ending at baselineMaxTs.
const baseline = 100_000
const baselineStartTs int64 = 1735689600 // 2025-01-01 UTC
baselineMaxTs := baselineStartTs + int64(baseline) - 1
tx, err := store.db.Begin()
if err != nil {
t.Fatal(err)
}
txStmt, err := tx.Prepare(`INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
VALUES ('', ?, ?, 0, ?, 0, '{}', 'aaaaaaaaaa')`)
if err != nil {
t.Fatal(err)
}
obsStmt, err := tx.Prepare(`INSERT INTO observations
(transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, '["bb"]', ?)`)
if err != nil {
t.Fatal(err)
}
for i := 0; i < baseline; i++ {
res, err := txStmt.Exec(fmt.Sprintf("h%d", i), baselineStartTs+int64(i), payloadADVERT)
if err != nil {
t.Fatal(err)
}
txID, _ := res.LastInsertId()
if _, err := obsStmt.Exec(txID, obsRowid, baselineStartTs+int64(i)); err != nil {
t.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
// Initial warm-up: drain to completion (StartNeighborEdgesBuilder
// does the same — call directly so the test doesn't depend on the
// goroutine harness). Full scan allowed because neighbor_edges
// starts empty.
for {
n, err := store.buildAndPersistNeighborEdges()
if err != nil {
t.Fatalf("warm-up build: %v", err)
}
if n == 0 || n < 50000 {
break
}
}
var edgesAfterWarmup int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM neighbor_edges`).Scan(&edgesAfterWarmup); err != nil {
t.Fatal(err)
}
if edgesAfterWarmup == 0 {
t.Fatal("warm-up produced 0 edges; can't establish a watermark")
}
// Sanity: MAX(last_seen) should reflect the baseline tail timestamp.
var maxLastSeen string
if err := store.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&maxLastSeen); err != nil {
t.Fatal(err)
}
wantMax := time.Unix(baselineMaxTs, 0).UTC().Format(time.RFC3339)
if maxLastSeen != wantMax {
t.Fatalf("MAX(last_seen) after warm-up: want %s, got %s", wantMax, maxLastSeen)
}
// Tick #2: NO new observations. Expect no-op + fast.
noopStart := time.Now()
n2, err := store.buildAndPersistNeighborEdges()
if err != nil {
t.Fatalf("noop build: %v", err)
}
noopDur := time.Since(noopStart)
if n2 != 0 {
t.Fatalf("expected 0 edges on empty-delta tick; got %d (#1339)", n2)
}
if noopDur > time.Second {
t.Fatalf("empty-delta build took %v; expected <1s — builder is "+
"still doing a full table scan. (#1339)", noopDur)
}
// Tick #3: insert K observations with timestamps strictly newer
// than baselineMaxTs.
const delta = 100
deltaStartTs := baselineMaxTs + 1
tx2, err := store.db.Begin()
if err != nil {
t.Fatal(err)
}
txStmt2, err := tx2.Prepare(`INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, from_pubkey)
VALUES ('', ?, ?, 0, ?, 0, '{}', 'aaaaaaaaaa')`)
if err != nil {
t.Fatal(err)
}
obsStmt2, err := tx2.Prepare(`INSERT INTO observations
(transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, '["bb"]', ?)`)
if err != nil {
t.Fatal(err)
}
for i := 0; i < delta; i++ {
res, err := txStmt2.Exec(fmt.Sprintf("d%d", i), deltaStartTs+int64(i), payloadADVERT)
if err != nil {
t.Fatal(err)
}
txID, _ := res.LastInsertId()
if _, err := obsStmt2.Exec(txID, obsRowid, deltaStartTs+int64(i)); err != nil {
t.Fatal(err)
}
}
if err := tx2.Commit(); err != nil {
t.Fatal(err)
}
deltaStart := time.Now()
n3, err := store.buildAndPersistNeighborEdges()
if err != nil {
t.Fatalf("delta build: %v", err)
}
deltaDur := time.Since(deltaStart)
// Each ADVERT observation with a non-empty path produces 2 edge
// candidates (from↔hop[0] and observer↔hop[-1]). The watermark
// must clamp the scan to the delta rows ONLY — anything more
// proves the WHERE clause was bypassed.
if n3 != delta*2 {
t.Fatalf("expected %d edges upserted (delta only, 2 per advert obs); got %d. "+
"Builder must only scan observations with timestamp > MAX(neighbor_edges.last_seen). (#1339)",
delta*2, n3)
}
if deltaDur > 500*time.Millisecond {
t.Fatalf("delta build of %d rows took %v; expected <500ms. (#1339)", delta, deltaDur)
}
// Sanity: MAX(last_seen) advanced.
var maxLastSeen2 string
if err := store.db.QueryRow(`SELECT MAX(last_seen) FROM neighbor_edges`).Scan(&maxLastSeen2); err != nil {
t.Fatal(err)
}
if maxLastSeen2 <= maxLastSeen {
t.Fatalf("MAX(last_seen) did not advance: was %s, now %s", maxLastSeen, maxLastSeen2)
}
}
-12
View File
@@ -351,18 +351,6 @@
box-shadow: 0 0 4px currentColor;
}
/* #1293 SVG shape-aware legend swatch (replaces the flat colour dot).
* Inline-block wrapper keeps SVG aligned with adjacent text labels. */
.live-shape-swatch {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 6px;
vertical-align: middle;
line-height: 0;
}
.live-shape-swatch svg { display: block; }
/* #1274: marker-style swatches mirror the live map circleMarker ring
* convention (bright white ring = repeater, faded ring = other roles).
* Background uses --role-repeater / --text-muted via CSS variables so
+36 -112
View File
@@ -1712,13 +1712,7 @@
if (roleLegendList) {
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
const li = document.createElement('li');
// #1293 — SVG swatch shows SHAPE + colour so colourblind ops can
// distinguish roles without relying on hue alone (WCAG 1.4.1).
const color = ROLE_COLORS[role] || '#6b7280';
const swatch = window.makeRoleMarkerSVG
? window.makeRoleMarkerSVG(role, color, 14)
: `<span class="live-dot" style="background:${color}" aria-hidden="true"></span>`;
li.innerHTML = `<span class="live-shape-swatch" aria-hidden="true">${swatch}</span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
li.innerHTML = `<span class="live-dot" style="background:${ROLE_COLORS[role] || '#6b7280'}" aria-hidden="true"></span> ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`;
roleLegendList.appendChild(li);
}
}
@@ -2364,115 +2358,49 @@
const isRepeater = n.role === 'repeater';
const zoom = map ? map.getZoom() : 11;
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
// Shape-aware sizing: keep prior visual weight (~6/4 base) but
// route through divIcon so colourblind ops get distinct silhouettes
// (#1293). Size is the SVG box; circleMarker radius ~= size/3.
const sizePx = Math.max(10, Math.round((isRepeater ? 18 : 14) * zoomScale));
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
const svgHtml = (window.makeRoleMarkerSVG
? 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="' + color + '" stroke="#fff" stroke-width="2"/></svg>');
const glow = L.circleMarker([n.lat, n.lon], {
radius: size + 4, fillColor: color, fillOpacity: 0.12, stroke: false, interactive: false
}).addTo(nodesLayer);
const icon = L.divIcon({
html: svgHtml,
className: 'live-node-marker live-node-' + (n.role || 'unknown'),
iconSize: [sizePx, sizePx],
iconAnchor: [sizePx / 2, sizePx / 2],
popupAnchor: [0, -sizePx / 2]
});
const marker = L.marker([n.lat, n.lon], { icon: icon, interactive: true }).addTo(nodesLayer);
// Highlight ring (#1293): a separate stroke-only circleMarker layered
// BENEATH the shape. Hidden by default; pulseNodeMarker grows/fades
// its radius + opacity — never fills, so same-hue concentric stacking
// (issue's "blue-on-blue") is impossible.
const ringPos = [n.lat, n.lon];
const ring = L.circleMarker(ringPos, {
radius: sizePx / 2 + 4,
fillOpacity: 0,
fill: false,
color: color,
weight: 0,
opacity: 0,
interactive: false
const marker = L.circleMarker([n.lat, n.lon], {
radius: size, fillColor: color, fillOpacity: 0.85,
color: '#fff', weight: isRepeater ? 1.5 : 0.5, opacity: isRepeater ? 0.6 : 0.3
}).addTo(nodesLayer);
marker.bindTooltip(n.name || n.public_key.slice(0, 8), {
permanent: false, direction: 'top', offset: [0, -sizePx / 2], className: 'live-tooltip'
permanent: false, direction: 'top', offset: [0, -10], className: 'live-tooltip'
});
marker.on('click', () => showNodeDetail(n.public_key));
marker._highlightRing = ring;
marker._glowMarker = glow;
marker._baseColor = color;
marker._baseSize = sizePx;
marker._role = n.role || 'unknown';
marker._baseSize = size;
nodeMarkers[n.public_key] = marker;
// Apply matrix tint if active — re-render the SVG with matrix colour
// Apply matrix tint if active
if (matrixMode) {
marker._matrixPrevColor = color;
marker._baseColor = '#008a22';
const mxHtml = window.makeRoleMarkerSVG
? window.makeRoleMarkerSVG(marker._role, '#008a22', sizePx)
: svgHtml;
const el = marker.getElement();
if (el) el.innerHTML = mxHtml;
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
glow.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
}
return marker;
}
// #1293 — divIcon helpers. The live-map node marker is now an
// L.marker (divIcon SVG), not an L.circleMarker, so setStyle /
// setRadius are no-ops. These helpers update the DOM element
// directly so existing call-sites (rescale, stale-dim, matrix mode,
// highlight pulse) keep working without same-colour fill stacking.
function _liveMarkerEl(marker) {
if (!marker || typeof marker.getElement !== 'function') return null;
return marker.getElement();
}
function _liveSetMarkerOpacity(marker, opacity) {
var el = _liveMarkerEl(marker);
if (el) el.style.opacity = String(opacity);
}
function _liveSetMarkerSize(marker, sizePx) {
var el = _liveMarkerEl(marker);
if (!el) return;
var svg = el.querySelector('svg');
if (svg) {
svg.setAttribute('width', sizePx);
svg.setAttribute('height', sizePx);
}
marker._baseSize = sizePx;
if (marker._highlightRing && typeof marker._highlightRing.setRadius === 'function') {
marker._highlightRing.setRadius(sizePx / 2 + 4);
}
}
function _liveSetMarkerColor(marker, color) {
var el = _liveMarkerEl(marker);
if (!el) return;
if (window.makeRoleMarkerSVG) {
el.innerHTML = window.makeRoleMarkerSVG(marker._role || 'unknown', color, marker._baseSize || 14);
} else {
// Fallback: tweak fill on first shape
var shape = el.querySelector('svg > *');
if (shape) shape.setAttribute('fill', color);
}
}
window._liveSetMarkerSize = _liveSetMarkerSize;
window._liveSetMarkerColor = _liveSetMarkerColor;
function rescaleMarkers() {
const zoom = map.getZoom();
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
for (const [key, marker] of Object.entries(nodeMarkers)) {
const n = nodeData[key];
const isRepeater = n && n.role === 'repeater';
const sizePx = Math.max(10, Math.round((isRepeater ? 18 : 14) * zoomScale));
_liveSetMarkerSize(marker, sizePx);
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
marker.setRadius(size);
marker._baseSize = size;
if (marker._glowMarker) marker._glowMarker.setRadius(size + 4);
}
}
@@ -2494,14 +2422,15 @@
// API-loaded nodes: dim instead of removing (consistent with static map)
if (marker && !marker._staleDimmed) {
marker._staleDimmed = true;
_liveSetMarkerOpacity(marker, 0.35);
marker.setStyle({ fillOpacity: 0.25, opacity: 0.15 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.04 });
}
} else {
// WS-only nodes: remove to prevent unbounded memory growth
if (marker) {
if (nodesLayer) {
try { nodesLayer.removeLayer(marker); } catch (e) {}
if (marker._highlightRing) try { nodesLayer.removeLayer(marker._highlightRing); } catch (e) {}
if (marker._glowMarker) try { nodesLayer.removeLayer(marker._glowMarker); } catch (e) {}
}
}
delete nodeMarkers[key];
@@ -2512,7 +2441,9 @@
} else if (marker && marker._staleDimmed) {
// Node became active again — restore full opacity
marker._staleDimmed = false;
_liveSetMarkerOpacity(marker, 1);
var isRepeater = n.role === 'repeater';
marker.setStyle({ fillOpacity: 0.85, opacity: isRepeater ? 0.6 : 0.3 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.12 });
}
}
if (pruned) {
@@ -3017,26 +2948,17 @@
requestAnimationFrame(animatePulse);
const baseColor = marker._baseColor || '#6b7280';
const baseSize = marker._baseSize || 14;
const baseSize = marker._baseSize || 6;
marker.setStyle({ fillColor: '#fff', fillOpacity: 1, radius: baseSize + 2, color: color, weight: 2 });
// #1293 — highlight via OUTLINE ring (no same-colour concentric
// fill). Use the marker's pre-allocated _highlightRing; grow + fade
// it. Marker shape/colour is left untouched so colourblind silhouette
// stays distinguishable during the pulse.
const ringHl = marker._highlightRing;
if (ringHl && typeof ringHl.setStyle === 'function') {
try {
ringHl.setStyle({ color: color, weight: 3, opacity: 0.95, fillOpacity: 0, fill: false });
ringHl.setRadius(baseSize / 2 + 4);
setTimeout(() => {
try { ringHl.setStyle({ opacity: 0.4, weight: 2 }); ringHl.setRadius(baseSize / 2 + 8); } catch (e) {}
}, 200);
setTimeout(() => {
try { ringHl.setStyle({ opacity: 0, weight: 0 }); } catch (e) {}
}, 700);
} catch (e) { /* circleMarker absent — ignore */ }
if (marker._glowMarker) {
marker._glowMarker.setStyle({ fillColor: color, fillOpacity: 0.2, radius: baseSize + 6 });
setTimeout(() => marker._glowMarker.setStyle({ fillColor: baseColor, fillOpacity: 0.08, radius: baseSize + 3 }), 500);
}
setTimeout(() => marker.setStyle({ fillColor: color, fillOpacity: 0.95, radius: baseSize + 1, weight: 1.5 }), 150);
setTimeout(() => marker.setStyle({ fillColor: baseColor, fillOpacity: 0.85, radius: baseSize, color: '#fff', weight: marker._baseSize > 6 ? 1.5 : 0.5 }), 700);
nodeActivity[key] = (nodeActivity[key] || 0) + 1;
}
@@ -3190,7 +3112,8 @@
for (const [key, marker] of Object.entries(nodeMarkers)) {
marker._matrixPrevColor = marker._baseColor;
marker._baseColor = '#008a22';
_liveSetMarkerColor(marker, '#008a22');
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
}
} else {
container.classList.remove('matrix-theme');
@@ -3211,7 +3134,8 @@
for (const [key, marker] of Object.entries(nodeMarkers)) {
if (marker._matrixPrevColor) {
marker._baseColor = marker._matrixPrevColor;
_liveSetMarkerColor(marker, marker._matrixPrevColor);
marker.setStyle({ fillColor: marker._matrixPrevColor, color: '#fff', fillOpacity: 0.85, opacity: 1 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: marker._matrixPrevColor });
delete marker._matrixPrevColor;
}
}
-54
View File
@@ -44,18 +44,6 @@
case 'triangle':
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
break;
case 'hexagon': {
// #1293 — pointy-top hexagon for room servers
const hr = c - 1.5;
let hpts = '';
for (let hi = 0; hi < 6; hi++) {
const ha = (hi * 60 - 90) * Math.PI / 180;
hpts += (c + hr * Math.cos(ha)).toFixed(2) + ',' +
(c + hr * Math.sin(ha)).toFixed(2) + ' ';
}
path = `<polygon points="${hpts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
break;
}
case 'star': {
// 5-pointed star
const cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
@@ -274,48 +262,6 @@
toggleBtn.setAttribute('aria-expanded', String(!controlsCollapsed));
});
// #1329: Map controls accordion. Make each section's legend a button-
// style toggle with aria-expanded. On mobile (≤640px) only one section
// is open at a time so the panel never needs internal scrolling. On
// desktop the .mc-collapsed class has no visual effect (CSS only hides
// section bodies inside the mobile media query) so all controls stay
// visible — but single-open behaviour is still tracked for state
// consistency. See test-issue-1329-map-controls-accordion-e2e.js.
(function initMapControlsAccordion() {
const isMobile = window.innerWidth <= 640;
const sections = Array.from(controlsPanel.querySelectorAll('fieldset.mc-section'));
sections.forEach((fs, idx) => {
const legend = fs.querySelector('legend.mc-label');
if (!legend) return;
// Initial state: on mobile only the first section is open; on
// desktop all sections are open.
const open = !isMobile || idx === 0;
legend.setAttribute('role', 'button');
legend.setAttribute('tabindex', '0');
legend.setAttribute('aria-expanded', String(open));
fs.classList.toggle('mc-collapsed', !open);
const setOpen = (target, openNow) => {
target.setAttribute('aria-expanded', String(openNow));
const parent = target.closest('fieldset.mc-section');
if (parent) parent.classList.toggle('mc-collapsed', !openNow);
};
const onActivate = (e) => {
e.preventDefault();
const currentlyOpen = legend.getAttribute('aria-expanded') === 'true';
// Single-open: close every other section first.
sections.forEach(other => {
const otherLegend = other.querySelector('legend.mc-label');
if (otherLegend && otherLegend !== legend) setOpen(otherLegend, false);
});
setOpen(legend, !currentlyOpen);
};
legend.addEventListener('click', onActivate);
legend.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') onActivate(e);
});
});
})();
// Bind controls
var clustersEl = document.getElementById('mcClusters');
if (clustersEl) {
+5 -84
View File
@@ -55,95 +55,16 @@
sensor: 'Sensors', observer: 'Observers'
};
// #1293 — Marker shape per role (WCAG 1.4.1 — shape, not only colour).
// Single source of truth; ROLE_STYLE.shape is derived from this map.
window.ROLE_SHAPES = {
repeater: 'circle',
companion: 'square',
room: 'hexagon',
sensor: 'triangle',
observer: 'diamond'
};
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 },
repeater: { color: '#dc2626', shape: 'diamond', radius: 10, weight: 2 },
companion: { color: '#2563eb', shape: 'circle', radius: 8, weight: 2 },
room: { color: '#16a34a', shape: 'square', radius: 9, weight: 2 },
sensor: { color: '#d97706', shape: 'triangle', radius: 8, weight: 2 },
observer: { color: '#8b5cf6', shape: 'diamond', radius: 9, weight: 2 }
observer: { color: '#8b5cf6', shape: 'star', radius: 11, weight: 2 }
};
// Glyphs mirror the ROLE_SHAPES (used in tooltips, legends, lists).
window.ROLE_EMOJI = {
repeater: '', companion: '', room: '', sensor: '▲', observer: ''
};
/**
* #1293 Shared SVG marker generator. Returns a self-contained
* <svg>...</svg> string for the given role/colour/size, with white
* stroke for contrast (works on both dark + light tiles). Used by:
* - public/live.js addNodeMarker (L.divIcon)
* - public/live.js role legend swatches
* - public/map.js makeMarkerIcon (legacy switch retained for
* per-role overrides + observer star overlay)
*
* Reads ROLE_SHAPES for the role's geometry; falls back to circle.
* Caller controls colour to allow theming overrides (matrix mode,
* stale dim, etc.) without rebuilding the marker.
*/
window.makeRoleMarkerSVG = function (role, color, size) {
var shape = (window.ROLE_SHAPES && window.ROLE_SHAPES[role]) || 'circle';
size = size || 16;
var c = size / 2;
var fill = color || (window.ROLE_COLORS && window.ROLE_COLORS[role]) || '#6b7280';
var path;
switch (shape) {
case 'square':
path = '<rect x="3" y="3" width="' + (size - 6) + '" height="' + (size - 6) +
'" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
break;
case 'triangle':
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + (size - 2) +
' 2,' + (size - 2) + '" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
break;
case 'diamond':
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + c + ' ' +
c + ',' + (size - 2) + ' 2,' + c +
'" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
break;
case 'hexagon': {
// Pointy-top hexagon centred at (c,c), inscribed radius ≈ c-1.5
var r = c - 1.5;
var pts = '';
for (var i = 0; i < 6; i++) {
var a = (i * 60 - 90) * Math.PI / 180;
pts += (c + r * Math.cos(a)).toFixed(2) + ',' +
(c + r * Math.sin(a)).toFixed(2) + ' ';
}
path = '<polygon points="' + pts.trim() + '" fill="' + fill +
'" stroke="#fff" stroke-width="2"/>';
break;
}
case 'star': {
var cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
var spts = '';
for (var j = 0; j < 5; j++) {
var aO = (j * 72 - 90) * Math.PI / 180;
var aI = ((j * 72) + 36 - 90) * Math.PI / 180;
spts += (cx + outer * Math.cos(aO)) + ',' + (cy + outer * Math.sin(aO)) + ' ';
spts += (cx + inner * Math.cos(aI)) + ',' + (cy + inner * Math.sin(aI)) + ' ';
}
path = '<polygon points="' + spts.trim() + '" fill="' + fill +
'" stroke="#fff" stroke-width="1.5"/>';
break;
}
default: // circle
path = '<circle cx="' + c + '" cy="' + c + '" r="' + (c - 2) +
'" fill="' + fill + '" stroke="#fff" stroke-width="2"/>';
}
return '<svg width="' + size + '" height="' + size +
'" viewBox="0 0 ' + size + ' ' + size +
'" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' + path + '</svg>';
repeater: '', companion: '', room: '', sensor: '▲', observer: ''
};
window.ROLE_SORT = ['repeater', 'companion', 'room', 'sensor', 'observer'];
+3 -33
View File
@@ -1633,12 +1633,8 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
/* === Responsive — Tablet (≤900px) === */
@media (max-width: 900px) {
/* nav-stats hidden in the (min-width:768) and (max-width:1100) band
below see #1343. Keeping the rule here too is harmless but
misleading: it implies 900px is the breakpoint when in reality
the JS applyNavPriority assumes (and the 1100px block enforces)
the hide-band extends up to 1100px. */
.panel-right { width: 320px; min-width: 320px; }
.nav-stats { display: none; }
.brand-logo { height: 32px; width: 112px; }
.nav-link { padding: 14px 8px; font-size: 13px; }
.map-controls { width: 180px; font-size: 12px; }
@@ -1837,34 +1833,8 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
.search-box { width: 95vw; }
.search-overlay { padding-top: 60px; }
/* Map controls #1329: drop fixed 200px cap, use accordion sections
instead so visible content fits without internal scrolling. Panel can
grow to fill available height; max-height bound by viewport so it
never escapes the screen. */
.map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: calc(100vh - 80px); font-size: 12px; padding: 10px 12px; }
/* On mobile, hide collapsed section bodies (everything inside the
fieldset except the legend). The legend remains tappable to expand. */
.map-controls fieldset.mc-section.mc-collapsed > *:not(legend) { display: none; }
.map-controls fieldset.mc-section > legend.mc-label {
cursor: pointer;
user-select: none;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
}
/* ▸ / ▾ indicator via ::after so we don't touch markup */
.map-controls fieldset.mc-section > legend.mc-label::after {
content: '▾';
font-size: 10px;
color: var(--text-muted);
margin-left: 8px;
transition: transform 0.15s;
}
.map-controls fieldset.mc-section.mc-collapsed > legend.mc-label::after {
content: '▸';
}
/* Map controls */
.map-controls { width: calc(100vw - 24px); right: 12px; top: 8px; max-height: 200px; font-size: 12px; padding: 10px 12px; }
#leaflet-map { z-index: 0; }
#map-wrap { z-index: 0; }
+4 -4
View File
@@ -157,11 +157,11 @@
o.setAttribute('role', 'group');
o.setAttribute('aria-label', 'Row actions');
var hash = row.getAttribute('data-hash') || row.getAttribute('data-id') || '';
var hashAttr = ' data-hash="' + String(hash).replace(/"/g, '&quot;') + '"';
o.innerHTML =
'<button type="button" class="row-action-btn" data-row-action="trace"' + hashAttr + '>Trace</button>' +
'<button type="button" class="row-action-btn" data-row-action="filter"' + hashAttr + '>Filter</button>' +
'<button type="button" class="row-action-btn" data-row-action="copy"' + hashAttr + '>Copy hash</button>';
'<button type="button" class="row-action-btn" data-row-action="trace">Trace</button>' +
'<button type="button" class="row-action-btn" data-row-action="filter">Filter</button>' +
'<button type="button" class="row-action-btn" data-row-action="copy" data-hash="' +
String(hash).replace(/"/g, '&quot;') + '">Copy hash</button>';
document.body.appendChild(o);
rowOverlay = o;
return o;
-126
View File
@@ -1,126 +0,0 @@
/**
* #1293 Marker shape variation per role + colorblind-safe palette.
*
* Acceptance:
* - ROLE_SHAPES map exposed by roles.js, with repeater=circle,
* companion=square, room=hexagon, sensor=triangle, observer=diamond.
* - ROLE_STYLE.shape values match ROLE_SHAPES (single source of truth).
* - A shared helper `window.makeRoleMarkerSVG(role, color, size)` exists
* and can produce a hexagon path for the room role (covers the
* previously-missing shape in map.js's switch).
* - public/live.js uses `L.divIcon` (shape-aware) for node markers,
* NOT the legacy `L.circleMarker` in `addNodeMarker`.
* - public/live.js legend renders SVG marker swatches (not flat dots) so
* colorblind users can distinguish shape, not only colour.
* - public/map.js switch handles `case 'hexagon'`.
* - Selected/highlighted state uses an outline RING (no same-colour
* filled overlay) i.e. the highlight path sets fillOpacity:0
* (or 'transparent') and uses a stroke-based ring helper.
*
* Pure-string assertions; no DOM/browser required so this can land
* in the JS-unit-tests step of the CI workflow (fast red).
*/
'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 rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
const liveSrc = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
console.log('\n=== #1293: ROLE_SHAPES single source of truth ===');
// ROLE_SHAPES map declared on window
assert(/window\.ROLE_SHAPES\s*=\s*\{/.test(rolesSrc),
'roles.js declares window.ROLE_SHAPES map');
// Required role → shape pairings (line-order independent)
const shapeBlockMatch = rolesSrc.match(/window\.ROLE_SHAPES\s*=\s*\{([\s\S]*?)\};/);
const shapeBlock = shapeBlockMatch ? shapeBlockMatch[1] : '';
const expectedShapes = {
repeater: 'circle',
companion: 'square',
room: 'hexagon',
sensor: 'triangle',
observer: 'diamond',
};
for (const role of Object.keys(expectedShapes)) {
const re = new RegExp(role + '\\s*:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
assert(re.test(shapeBlock), `ROLE_SHAPES.${role} === '${expectedShapes[role]}'`);
}
// 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
const lineRe = new RegExp(role + '\\s*:[^}]*shape:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
assert(lineRe.test(styleBlock),
`ROLE_STYLE.${role}.shape === '${expectedShapes[role]}' (matches ROLE_SHAPES)`);
}
console.log('\n=== #1293: shared SVG helper covers hexagon ===');
assert(/window\.makeRoleMarkerSVG\s*=\s*function/.test(rolesSrc),
'roles.js exposes window.makeRoleMarkerSVG(role, color, size)');
// Helper string must include a hexagon branch (matches map.js switch)
const helperMatch = rolesSrc.match(/window\.makeRoleMarkerSVG[\s\S]*?\n\s*\};/);
const helperBlock = helperMatch ? helperMatch[0] : '';
assert(/case\s+['\"]hexagon['\"]/.test(helperBlock),
'helper handles case "hexagon" (room role)');
assert(/case\s+['\"]square['\"]/.test(helperBlock),
'helper handles case "square"');
assert(/case\s+['\"]triangle['\"]/.test(helperBlock),
'helper handles case "triangle"');
assert(/case\s+['\"]diamond['\"]/.test(helperBlock),
'helper handles case "diamond"');
console.log('\n=== #1293: map.js switch handles hexagon ===');
assert(/case\s+['\"]hexagon['\"]/.test(mapSrc),
'map.js makeMarkerIcon switch has a "hexagon" branch');
console.log('\n=== #1293: live.js node markers use shape-aware divIcons ===');
// Carve out addNodeMarker body (best-effort) and assert it uses divIcon.
const addNodeIdx = liveSrc.indexOf('function addNodeMarker');
assert(addNodeIdx > 0, 'live.js addNodeMarker function present');
const addNodeBody = liveSrc.slice(addNodeIdx, addNodeIdx + 2500);
assert(/L\.divIcon|window\.makeRoleMarkerSVG|makeRoleMarkerSVG\s*\(/.test(addNodeBody),
'addNodeMarker uses L.divIcon / makeRoleMarkerSVG (not legacy circleMarker)');
assert(!/L\.circleMarker\(\s*\[\s*n\.lat/.test(addNodeBody),
'addNodeMarker no longer creates L.circleMarker for the node itself');
console.log('\n=== #1293: live.js legend renders shape swatches ===');
// The role legend block (id="roleLegendList") must inject SVG, not a
// flat live-dot span only.
const legendIdx = liveSrc.indexOf("getElementById('roleLegendList')");
assert(legendIdx > 0, 'live.js renders roleLegendList');
const legendBody = liveSrc.slice(legendIdx, legendIdx + 1500);
assert(/<svg|makeRoleMarkerSVG/.test(legendBody),
'roleLegendList swatches include SVG shape (not bare colour dot)');
console.log('\n=== #1293: selected/highlight uses outline ring (no same-colour fill overlay) ===');
// New behaviour: marker highlight pulse must NOT recolor marker fill to
// the same packet colour stacked over a same-coloured base. The fix
// uses a stroke ring (fillOpacity 0 / 'transparent') for the overlay.
assert(/highlightNodeRing|RingHighlight|highlightRing/.test(liveSrc) ||
/fillOpacity:\s*0[,\s}]/.test(liveSrc.slice(liveSrc.indexOf('animatePulse') || 0,
(liveSrc.indexOf('animatePulse') || 0) + 1500)),
'highlight path uses a transparent-fill ring (no same-colour concentric fill)');
console.log('\n=== Summary ===');
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
if (failed > 0) { console.error('\n#1293 FAIL'); process.exit(1); }
console.log('\n#1293 PASS');
@@ -1,177 +0,0 @@
/**
* E2E (#1329): Map controls panel on mobile must NOT be capped at 200px
* with internal scroll. Use accordion sections one expanded at a time
* so the visible content always fits without scrolling.
*
* Mobile (375x812):
* - Open Map controls.
* - Panel must have accordion sections (legend acts as toggle, with
* aria-expanded attribute).
* - Default state: at most one section expanded.
* - Panel contents must NOT require internal scroll
* (scrollHeight <= clientHeight + 1).
* - Clicking a different section's legend collapses the previously-open
* section (single-open behavior).
*
* Desktop (1280x800):
* - Existing layout unchanged: all sections visible by default,
* panel position:absolute, modest width.
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
async function run() {
const launchOpts = { args: ['--no-sandbox'] };
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
const browser = await chromium.launch(launchOpts);
// === Mobile: 375x812 ===
const ctx = await browser.newContext({ viewport: { width: 375, height: 812 } });
const page = await ctx.newPage();
await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
await page.waitForSelector('#leaflet-map', { timeout: 10000 });
await page.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 });
await page.waitForTimeout(500);
// Ensure controls panel is expanded (default is collapsed on mobile).
await page.evaluate(() => {
const panel = document.getElementById('mapControls');
const btn = document.getElementById('mapControlsToggle');
if (panel && panel.classList.contains('collapsed')) btn && btn.click();
});
await page.waitForTimeout(300);
await step('mobile: at least one accordion section present with aria-expanded', async () => {
const data = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
// Accordion section markers: legend (or button) carrying aria-expanded
// inside a .mc-section.mc-accordion (or equivalent) descendant.
const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]');
const sections = panel.querySelectorAll('.mc-section');
return {
toggles: toggles.length,
sections: sections.length,
expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length,
};
});
assert(data.toggles >= 1,
'expected ≥1 accordion toggle (aria-expanded), got ' + data.toggles +
' (sections=' + data.sections + ')');
});
await step('mobile: at most one section expanded by default', async () => {
const data = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
const toggles = panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]');
return {
expandedCount: Array.from(toggles).filter(t => t.getAttribute('aria-expanded') === 'true').length,
total: toggles.length,
};
});
assert(data.expandedCount <= 1,
'expected ≤1 section expanded by default, got ' + data.expandedCount + '/' + data.total);
});
await step('mobile: panel content does NOT require internal scroll', async () => {
const data = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
return {
scrollH: panel.scrollHeight,
clientH: panel.clientHeight,
overflowY: getComputedStyle(panel).overflowY,
};
});
// The accordion sections should keep content within viewport — when only
// one section is expanded, panel must not need to scroll internally.
assert(data.scrollH <= data.clientH + 1,
'panel must not require internal scroll (scrollH=' + data.scrollH +
' clientH=' + data.clientH + ')');
});
await step('mobile: clicking a 2nd toggle collapses the first (single-open)', async () => {
const result = await page.evaluate(() => {
const panel = document.getElementById('mapControls');
const toggles = Array.from(panel.querySelectorAll('.mc-section [aria-expanded], .mc-accordion-toggle[aria-expanded]'));
if (toggles.length < 2) return { skip: true, n: toggles.length };
// Find one currently closed and one open; if all closed, open first then click second.
let openIdx = toggles.findIndex(t => t.getAttribute('aria-expanded') === 'true');
if (openIdx === -1) {
toggles[0].click();
openIdx = 0;
}
const otherIdx = openIdx === 0 ? 1 : 0;
toggles[otherIdx].click();
return {
skip: false,
firstNow: toggles[openIdx].getAttribute('aria-expanded'),
otherNow: toggles[otherIdx].getAttribute('aria-expanded'),
};
});
if (result.skip) {
throw new Error('need at least 2 accordion toggles to test single-open (got ' + result.n + ')');
}
assert(result.otherNow === 'true',
'second toggle should be open after click, got ' + result.otherNow);
assert(result.firstNow === 'false',
'first toggle should auto-close (single-open), got ' + result.firstNow);
});
await ctx.close();
// === Desktop: 1280x800 ===
const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const p2 = await ctx2.newPage();
await p2.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
await p2.waitForSelector('#mapControls', { state: 'attached', timeout: 10000 });
await p2.waitForTimeout(300);
await step('desktop (1280px): panel position:absolute, all section contents visible', async () => {
const data = await p2.evaluate(() => {
const panel = document.getElementById('mapControls');
const cs = getComputedStyle(panel);
const rect = panel.getBoundingClientRect();
// Check that section content (e.g., labels) is visible on desktop.
const allInputs = panel.querySelectorAll('input[type=checkbox], select, button');
let visible = 0;
allInputs.forEach(el => {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) visible++;
});
return {
position: cs.position,
width: Math.round(rect.width),
vw: window.innerWidth,
visibleControls: visible,
totalControls: allInputs.length,
};
});
assert(data.position === 'absolute',
'desktop panel must be position:absolute, got ' + data.position);
assert(data.width < data.vw * 0.5,
'desktop panel must be <50% viewport width, got ' + data.width + '/' + data.vw);
// All (or nearly all) controls should be visible on desktop — accordion
// collapse must NOT apply at desktop sizes.
assert(data.visibleControls >= data.totalControls - 2,
'desktop must show all controls (got ' + data.visibleControls + '/' + data.totalControls + ')');
});
await browser.close();
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed' +
(failed ? ', ' + failed + ' failed' : ''));
process.exit(failed > 0 ? 1 : 0);
}
run().catch(err => { console.error('Fatal:', err); process.exit(1); });
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env node
/* Issue #1343 nav-stats hide-band must match JS overflow assumption.
*
* applyNavPriority in public/app.js assumes that at viewport <=1100px
* the CSS hides .nav-stats so the 5 high-priority links + "More ▾"
* actually fit on screen. If the hide band is narrower than 1100px,
* the high-priority links silently clip out of view in the gap.
*
* Cases:
* - 800x800 on /#/observers high-priority links visible, nav-stats hidden
* - 960x800 on /#/observers high-priority links visible, nav-stats hidden
* - 1080x800 on /#/observers high-priority links visible, nav-stats hidden
* - 1200x800 on /#/observers high-priority links visible, nav-stats RE-APPEARS
*
* A link is "visible" iff: clientWidth > 0 AND its bounding rect is
* fully inside the viewport horizontally (left>=0, right<=innerWidth).
*/
'use strict';
const assert = require('assert');
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const HIGH_PRIORITY_HREFS = ['#/home', '#/packets', '#/map', '#/live', '#/nodes'];
const CASES = [
{ w: 800, h: 800, navStatsHidden: true, label: '800px — narrow desktop' },
{ w: 960, h: 800, navStatsHidden: true, label: '960px — operator-reported' },
{ w: 1080, h: 800, navStatsHidden: true, label: '1080px — narrow desktop' },
{ w: 1200, h: 800, navStatsHidden: false, label: '1200px — wide desktop' },
];
async function main() {
let browser;
let failures = 0;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
for (const c of CASES) {
const ctx = await browser.newContext({ viewport: { width: c.w, height: c.h } });
const page = await ctx.newPage();
await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded', timeout: 15000 });
// Wait for nav to be rendered (top-nav appears as part of SPA shell)
await page.waitForSelector('.top-nav .nav-links', { timeout: 10000 });
// Allow nav-priority pass + font ready callback to settle
await page.waitForTimeout(400);
const result = await page.evaluate((hrefs) => {
const navStats = document.querySelector('.nav-stats');
const navStatsW = navStats ? navStats.clientWidth : 0;
const innerW = window.innerWidth;
const links = hrefs.map((href) => {
const a = document.querySelector(`.nav-links a[href="${href}"]`);
if (!a) return { href, present: false, w: 0, left: null, right: null };
const r = a.getBoundingClientRect();
return {
href,
present: true,
w: a.clientWidth,
left: r.left,
right: r.right,
inView: r.left >= 0 && r.right <= innerW && a.clientWidth > 0,
};
});
return { navStatsW, innerW, links };
}, HIGH_PRIORITY_HREFS);
const navStatsOk = c.navStatsHidden
? result.navStatsW === 0
: result.navStatsW > 0;
const allLinksVisible = result.links.every((l) => l.present && l.inView);
const status = navStatsOk && allLinksVisible ? 'PASS' : 'FAIL';
if (status === 'FAIL') failures++;
console.log(`[${status}] ${c.label} — innerW=${result.innerW} navStatsW=${result.navStatsW}`);
for (const l of result.links) {
console.log(` ${l.href}: w=${l.w} left=${l.left} right=${l.right} inView=${l.inView}`);
}
// Hard assertion so CI failure carries an explicit error trace
try {
assert.strictEqual(navStatsOk, true,
`${c.label}: expected nav-stats ${c.navStatsHidden ? 'hidden (clientWidth=0)' : 'visible (clientWidth>0)'}, got clientWidth=${result.navStatsW}`);
assert.strictEqual(allLinksVisible, true,
`${c.label}: expected all 5 high-priority links visible in viewport, got ${result.links.filter(l => !l.inView).map(l => l.href).join(',')} clipped`);
} catch (err) {
console.error(` ASSERT: ${err.message}`);
}
await ctx.close();
}
} finally {
if (browser) await browser.close();
}
// Final assertion — fail the process loudly with a stack
assert.strictEqual(failures, 0, `${failures} viewport case(s) failed`);
console.log('\nAll viewport cases passed');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+13 -9
View File
@@ -208,12 +208,14 @@ async function main() {
if (!overlayPresent) {
fail('(cov1) precondition — overlay did not appear after left swipe');
} else {
await page.evaluate(() => {
// Production stamps data-hash on trace/filter/copy buttons natively
// (issue #1305). Just click — no test-side workaround needed.
await page.evaluate((h) => {
const btn = document.querySelector('.row-action-overlay [data-row-action="trace"]');
if (btn) { btn.click(); }
});
// Production only sets data-hash on the copy button; for the
// trace/filter branch in onClickAction to navigate, the button
// must carry data-hash. Stamp it here from the row's hash so
// the coverage test exercises the real navigation path.
if (btn) { btn.setAttribute('data-hash', h); btn.click(); }
}, r.hash);
await page.waitForTimeout(120);
const state = await page.evaluate(() => ({
hash: location.hash,
@@ -239,11 +241,13 @@ async function main() {
if (!ok) {
fail('(cov2) precondition — filter button not in overlay');
} else {
await page.evaluate(() => {
// Production stamps data-hash on filter button natively (#1305).
await page.evaluate((h) => {
const btn = document.querySelector('.row-action-overlay [data-row-action="filter"]');
if (btn) { btn.click(); }
});
// Same as cov1: production stamps data-hash only on the copy
// button. Stamp it on filter here so onClickAction's hash
// guard passes and we exercise the real navigation branch.
if (btn) { btn.setAttribute('data-hash', h); btn.click(); }
}, r2.hash);
await page.waitForTimeout(120);
const state = await page.evaluate(() => ({
hash: location.hash,