Compare 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
70 changed files with 427 additions and 7397 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"717 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":"38.43%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"38.88%","color":"red"}
-279
View File
@@ -1,279 +0,0 @@
{
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "script"
},
"env": {
"browser": true,
"es2022": true
},
"globals": {
"AreaFilter": "readonly",
"CACHE_INVALIDATE_MS": "readonly",
"CLIENT_CONFIG": "readonly",
"CLIENT_TTL": "readonly",
"ChannelColorPicker": "readonly",
"ChannelColors": "readonly",
"ChannelDecrypt": "readonly",
"ChannelQR": "readonly",
"Chart": "readonly",
"DIST_THRESHOLDS": "readonly",
"DragManager": "readonly",
"EXTERNAL_URLS": "readonly",
"FAV_KEY": "readonly",
"FilterUX": "readonly",
"GestureHints": "readonly",
"HEALTH_THRESHOLDS": "readonly",
"HashColor": "readonly",
"HopDisplay": "readonly",
"HopResolver": "readonly",
"IATA_CITIES": "readonly",
"IATA_COORDS_GEO": "readonly",
"L": "readonly",
"LIMITS": "readonly",
"Logo": "readonly",
"MAX_HOP_DIST": "readonly",
"MeshAudio": "readonly",
"MeshConfigReady": "readonly",
"PAYLOAD_COLORS": "readonly",
"PAYLOAD_TYPES": "readonly",
"PERF_SLOW_MS": "readonly",
"PROPAGATION_BUFFER_MS": "readonly",
"PULL_THRESHOLD_PX": "readonly",
"PacketFilter": "readonly",
"PathInspector": "readonly",
"QRCode": "readonly",
"ROLE_COLORS": "readonly",
"ROLE_EMOJI": "readonly",
"ROLE_LABELS": "readonly",
"ROLE_SHAPES": "readonly",
"ROLE_SORT": "readonly",
"ROLE_STYLE": "readonly",
"ROUTE_TYPES": "readonly",
"RegionFilter": "readonly",
"SITE_CONFIG": "readonly",
"SKEW_SEVERITY_COLORS": "readonly",
"SKEW_SEVERITY_LABELS": "readonly",
"SKEW_SEVERITY_ORDER": "readonly",
"SNR_THRESHOLDS": "readonly",
"SlideOver": "readonly",
"TILE_DARK": "readonly",
"TILE_LIGHT": "readonly",
"TYPE_COLORS": "readonly",
"TableResponsive": "readonly",
"TableSort": "readonly",
"TouchGestures": "readonly",
"TracesHelpers": "readonly",
"URLState": "readonly",
"WS_RECONNECT_MS": "readonly",
"_SITE_CONFIG_ORIGINAL_HOME": "readonly",
"__PERF_LOG_RENDER": "readonly",
"__bottomNavInitDone": "readonly",
"__corescopeLogo": "readonly",
"__dirname": "readonly",
"__filename": "readonly",
"__gestureHints1065Init": "readonly",
"__liveMQLBindCount": "readonly",
"__meshcoreMapInternals": "readonly",
"__navDrawer": "readonly",
"__navDrawerPointerBindCount": "readonly",
"__pathOverflowWired": "readonly",
"__scrollLock": "readonly",
"__touchGestures1062InitCount": "readonly",
"_analyticsChannelTbodyHtml": "readonly",
"_analyticsChannelTheadHtml": "readonly",
"_analyticsDecorateChannels": "readonly",
"_analyticsHashStatCardsHtml": "readonly",
"_analyticsLoadChannelSort": "readonly",
"_analyticsRenderCollisionsFromServer": "readonly",
"_analyticsRenderMultiByteAdopters": "readonly",
"_analyticsRenderMultiByteCapability": "readonly",
"_analyticsRfNFColumnChart": "readonly",
"_analyticsSaveChannelSort": "readonly",
"_analyticsSortChannels": "readonly",
"_apiCache": "readonly",
"_apiPerf": "readonly",
"_channelsBeginMessageRequestForTest": "readonly",
"_channelsGetStateForTest": "readonly",
"_channelsHandleWSBatchForTest": "readonly",
"_channelsIsStaleMessageRequestForTest": "readonly",
"_channelsLoadChannelsForTest": "readonly",
"_channelsProcessWSBatchForTest": "readonly",
"_channelsReconcileSelectionForTest": "readonly",
"_channelsRefreshMessagesForTest": "readonly",
"_channelsSelectChannelForTest": "readonly",
"_channelsSetObserverRegionsForTest": "readonly",
"_channelsSetStateForTest": "readonly",
"_channelsShouldProcessWSMessageForRegion": "readonly",
"_customizerV2": "readonly",
"_ensurePullIndicator": "readonly",
"_inflight": "readonly",
"_isTouchDevice": "readonly",
"_liveAddFeedItem": "readonly",
"_liveBufferPacket": "readonly",
"_liveBuildClickablePathPopupHtml": "readonly",
"_liveBuildObserverIataMap": "readonly",
"_liveClickablePaths": "readonly",
"_liveDbPacketToLive": "readonly",
"_liveExpandToBufferEntries": "readonly",
"_liveExpandToBufferEntriesAsync": "readonly",
"_liveFormatLiveTimestampHtml": "readonly",
"_liveGetFavoritePubkeys": "readonly",
"_liveGetNodeFilterKeys": "readonly",
"_liveGetObserverIataMap": "readonly",
"_liveIsNodeFavorited": "readonly",
"_liveNodeActivity": "readonly",
"_liveNodeData": "readonly",
"_liveNodeMarkers": "readonly",
"_livePacketInvolvesFavorite": "readonly",
"_livePacketInvolvesFilterNode": "readonly",
"_livePacketMatchesRegion": "readonly",
"_livePruneClickablePaths": "readonly",
"_livePruneStaleNodes": "readonly",
"_liveRebuildFeedList": "readonly",
"_liveResolveHopPositions": "readonly",
"_liveSEG_MAP": "readonly",
"_liveSetMarkerColor": "readonly",
"_liveSetMarkerSize": "readonly",
"_liveSetNodeFilter": "readonly",
"_liveSetObserverIataMap": "readonly",
"_liveSpeedLabel": "readonly",
"_liveVCR": "readonly",
"_liveVcrPause": "readonly",
"_liveVcrResumeLive": "readonly",
"_liveVcrSetMode": "readonly",
"_liveVcrSpeedCycle": "readonly",
"_live_packetTimestamp": "readonly",
"_mapGetNeighborPubkeys": "readonly",
"_mapSelectRefNode": "readonly",
"_meshAudioVoices": "readonly",
"_meshcoreHeatLayer": "readonly",
"_meshcoreLiveHeatLayer": "readonly",
"_nodesGetAllNodes": "readonly",
"_nodesGetSortState": "readonly",
"_nodesGetStatusInfo": "readonly",
"_nodesGetStatusTooltip": "readonly",
"_nodesIsAdvertMessage": "readonly",
"_nodesMatchesSearch": "readonly",
"_nodesRenderNodeTimestampHtml": "readonly",
"_nodesRenderNodeTimestampText": "readonly",
"_nodesSetAllNodes": "readonly",
"_nodesSetSortState": "readonly",
"_nodesSortArrow": "readonly",
"_nodesSortNodes": "readonly",
"_nodesSyncClaimedToFavorites": "readonly",
"_nodesToggleSort": "readonly",
"_packetsTestAPI": "readonly",
"_panelCorner": "readonly",
"_pendingPathInspectorRoute": "readonly",
"_perfWriteSourcesPrev": "readonly",
"_pullIndicator": "readonly",
"_pullToast": "readonly",
"_pullToastTimer": "readonly",
"_reducedMotionMQL": "readonly",
"_showPullToast": "readonly",
"_themeRefreshTimer": "readonly",
"_vcrFormatTime": "readonly",
"addEventListener": "readonly",
"api": "readonly",
"apiPerf": "readonly",
"bindFavStars": "readonly",
"buildHexLegend": "readonly",
"buildNodesQuery": "readonly",
"buildPacketsQuery": "readonly",
"clearParsedCache": "readonly",
"closeMoreMenu": "readonly",
"closeNav": "readonly",
"comparePacketSets": "readonly",
"computeBreakdownRanges": "readonly",
"computeOverlapStats": "readonly",
"connectWS": "readonly",
"copyToClipboard": "readonly",
"createColoredHexDump": "readonly",
"currentPage": "readonly",
"currentSkewValue": "readonly",
"debounce": "readonly",
"debouncedOnWS": "readonly",
"destroy": "readonly",
"devicePixelRatio": "readonly",
"dispatchEvent": "readonly",
"drawPacketRoute": "readonly",
"escapeHtml": "readonly",
"exports": "readonly",
"favStar": "readonly",
"filterPacketsByRoute": "readonly",
"formatAbsoluteTimestamp": "readonly",
"formatChartAxisLabel": "readonly",
"formatDistance": "readonly",
"formatDistanceRound": "readonly",
"formatDrift": "readonly",
"formatEngineBadge": "readonly",
"formatHex": "readonly",
"formatIsoLike": "readonly",
"formatSkew": "readonly",
"formatTimestamp": "readonly",
"formatTimestampCustom": "readonly",
"formatTimestampWithTooltip": "readonly",
"formatVersionBadge": "readonly",
"getDistanceUnit": "readonly",
"getFavorites": "readonly",
"getHashParams": "readonly",
"getHealthThresholds": "readonly",
"getNodeStatus": "readonly",
"getParsedDecoded": "readonly",
"getParsedPath": "readonly",
"getPathLenOffset": "readonly",
"getResolvedPath": "readonly",
"getTileUrl": "readonly",
"getTimestampCustomFormat": "readonly",
"getTimestampFormatPreset": "readonly",
"getTimestampMode": "readonly",
"getTimestampTimezone": "readonly",
"global": "readonly",
"initGeoFilterOverlay": "readonly",
"initTabBar": "readonly",
"invalidateApiCache": "readonly",
"isFavorite": "readonly",
"isTransportRoute": "readonly",
"makeColumnsResizable": "readonly",
"makeRoleMarkerSVG": "readonly",
"miniMarkdown": "readonly",
"module": "readonly",
"navigate": "readonly",
"observerSkewSeverity": "readonly",
"offWS": "readonly",
"onWS": "readonly",
"pad2": "readonly",
"pad3": "readonly",
"pages": "readonly",
"payloadTypeColor": "readonly",
"payloadTypeName": "readonly",
"process": "readonly",
"pullReconnect": "readonly",
"qrcode": "readonly",
"registerPage": "readonly",
"renderSkewBadge": "readonly",
"renderSkewSparkline": "readonly",
"require": "readonly",
"routeLayer": "readonly",
"routeTypeName": "readonly",
"setupPullToReconnect": "readonly",
"syncBadgeColors": "readonly",
"timeAgo": "readonly",
"toggleFavorite": "readonly",
"transportBadge": "readonly",
"truncate": "readonly",
"ws": "readonly",
"wsListeners": "readonly"
},
"rules": {
"no-undef": "error",
"no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
]
}
}
-20
View File
@@ -105,21 +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
node test-issue-1356-map-a11y.js
node test-issue-1360-pill-letter-count.js
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-live.js
- name: 🧹 Frontend lint (eslint no-undef) — issue #1342
run: |
set -e
# Use eslint@8 (legacy .eslintrc.json). Don't migrate to flat-config / eslint@9.
# --no-save: avoid touching package.json / no committed node_modules.
npm install --no-save --no-audit --no-fund eslint@8
npx eslint public/*.js
- name: Verify proto syntax
run: |
@@ -265,13 +250,11 @@ 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-priority-1391-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
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
@@ -299,9 +282,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-vcr-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
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-1367-channels-chat-app-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
@@ -320,7 +301,6 @@ jobs:
BASE_URL=http://localhost:13581 node test-customize-export-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
-1
View File
@@ -381,7 +381,6 @@ Existing patterns: `#/nodes/{pubkey}?section=node-neighbors`, `#/analytics?tab=c
## What NOT to Do
- **Don't check in private information** — no names, API keys, tokens, passwords, IP addresses, personal data, or any identifying information. This is a PUBLIC repo.
- **Don't introduce new `map[string]interface{}` in API response builders, handler returns, or internal data structures that cross domain boundaries.** Use a named Go struct with explicit JSON tags. CoreScope already carries 694 occurrences (see #1383); the count must monotonically decrease. If your change adds even one new occurrence in a touched file, the PR is wrong-shaped — fix the design, don't paper over with `interface{}`. Exempt: third-party library boundaries that genuinely return `interface{}`, and ad-hoc test fixture assertions.
- Don't add npm dependencies without asking
- Don't create a build step
- Don't add framework abstractions (React, Vue, etc.)
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## [Unreleased]
### 📝 Documentation Corrections
- **PR #1324 historical record correction** (#1387) — the merged PR #1324 body referenced four tests that do NOT exist in master: `TestMultibyteCapPersistRoundTrip`, `TestMultibyteCapPersistSkipsUnknown`, `TestMaybePersistCoalesces`, and a `TryLock` coalescing test. The actual tests that landed are `TestRunMultibyteCapPersist_AppliesSnapshot` and `TestRunMultibyteCapPersist_NoSnapshot_NoOp`. See issue #1386 for the corrective test additions (round-trip, unknown-key skip, coalescing).
## [3.7.2] — 2026-05-06
Hotfix release branched from `v3.7.1`. Cherry-picks PR #1121 only — no other changes.
+2 -2
View File
@@ -22,7 +22,7 @@ COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/dbschema/ ../../internal/dbschema/
COPY internal/prunequeue/ ../../internal/prunequeue/
COPY internal/perfio/ ../../internal/perfio/
COPY internal/mbcapqueue/ ../../internal/mbcapqueue/
COPY internal/prunequeue/ ../../internal/prunequeue/
RUN go mod download
COPY cmd/server/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
@@ -38,7 +38,7 @@ COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/dbschema/ ../../internal/dbschema/
COPY internal/prunequeue/ ../../internal/prunequeue/
COPY internal/perfio/ ../../internal/perfio/
COPY internal/mbcapqueue/ ../../internal/mbcapqueue/
COPY internal/prunequeue/ ../../internal/prunequeue/
RUN go mod download
COPY cmd/ingestor/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
-1
View File
@@ -1 +0,0 @@
ingestor
+1 -32
View File
@@ -556,26 +556,6 @@ func applySchema(db *sql.DB) error {
// this column as hasDefaultScope; keeping a single canonical Apply
// path closes the startup race that #1321 documented.
// Migration: normalize known channel_hash values for existing rows.
// Before this PR, config key "public" was stored as channel_hash="public".
// After this PR, new rows use channel_hash="Public". Without backfill,
// channel grouping queries split into two buckets across the upgrade boundary.
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'channel_hash_casing_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Normalizing known channel_hash values...")
res, err := db.Exec(`UPDATE transmissions SET channel_hash = 'Public' WHERE channel_hash = 'public' AND payload_type = 5`)
if err != nil {
log.Printf("[migration] ERROR: failed to normalize channel_hash: %v", err)
return fmt.Errorf("migration channel_hash_casing_v1 UPDATE failed: %w", err)
}
n, _ := res.RowsAffected()
log.Printf("[migration] Normalized %d channel_hash rows from 'public' to 'Public'", n)
if _, err := db.Exec(`INSERT OR IGNORE INTO _migrations (name) VALUES ('channel_hash_casing_v1')`); err != nil {
log.Printf("[migration] WARNING: failed to record migration: %v", err)
}
log.Println("[migration] channel_hash casing normalization complete")
}
return nil
}
@@ -1380,17 +1360,6 @@ type MQTTPacketMessage struct {
// path_json is derived directly from raw_hex header bytes (not decoded.Path.Hops)
// to guarantee the stored path always matches the raw bytes. This matters for
// TRACE packets where decoded.Path.Hops is overwritten with payload hops (#886).
//
// Timestamp is server ingest time (time.Now()), NOT msg.Timestamp (#1370):
// PR #1233 (commit 498fbc03) routed the envelope timestamp into
// PacketData.Timestamp on the premise that uploader-stamped envelope time
// was trustworthy. Issue #1370 disproved that premise — observers with
// broken client clocks (staging Voodoo3 tx 304114: 4/5 obs stamped 18:42
// while genuine receive was 01:42) poisoned transmissions.first_seen /
// observations.timestamp and dragged the /api/channels lastActivity 7h
// into the past. Packet ordering is owned by the server clock; client
// clocks are untrusted. msg.Timestamp still flows into observer.last_seen
// via UpsertObserverAt — that's #1233's MAX/MIN guarded path and is fine.
func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID, region string, regionKeys map[string][]byte) *PacketData {
pathJSON := "[]"
// For TRACE packets, path_json must be the payload-decoded route hops
@@ -1408,7 +1377,7 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
pd := &PacketData{
RawHex: msg.Raw,
Timestamp: time.Now().UTC().Format(time.RFC3339), // #1370 (counters #1233)
Timestamp: msg.Timestamp,
ObserverID: observerID,
ObserverName: msg.Origin,
SNR: msg.SNR,
+2 -59
View File
@@ -866,12 +866,8 @@ func TestBuildPacketData(t *testing.T) {
if pkt.PayloadType != decoded.Header.PayloadType {
t.Errorf("payloadType mismatch")
}
if pkt.Timestamp == "" {
t.Errorf("timestamp must be populated (server ingest time, #1370 reverts #1233)")
}
if pkt.Timestamp == "2026-05-16T10:00:00Z" {
t.Errorf("timestamp=%s; must NOT be the envelope value (#1370 reverts #1233's "+
"premise that envelope timestamp is trustworthy — buggy client clocks poison ordering)", pkt.Timestamp)
if pkt.Timestamp != "2026-05-16T10:00:00Z" {
t.Errorf("timestamp=%s, want 2026-05-16T10:00:00Z", pkt.Timestamp)
}
if pkt.DecodedJSON == "" || pkt.DecodedJSON == "{}" {
t.Error("decodedJSON should be populated")
@@ -2848,56 +2844,3 @@ func TestBackfillPathJSONAsync_BracketRowsTerminate(t *testing.T) {
t.Errorf("expected %d rows with path_json='[]', got %d", seedCount, bracketCount)
}
}
// TestSchemaMultibyteSupColumns verifies that the multibyte_sup_v1 migration adds
// the expected columns and is idempotent across multiple OpenStore calls.
func TestSchemaMultibyteSupColumns(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
for _, table := range []string{"nodes", "inactive_nodes"} {
rows, err := store.db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
}
var foundSup, foundEvid bool
for rows.Next() {
var cid int
var name, colType string
var notNull, pk int
var dflt interface{}
if rows.Scan(&cid, &name, &colType, &notNull, &dflt, &pk) == nil {
if name == "multibyte_sup" {
foundSup = true
}
if name == "multibyte_evidence" {
foundEvid = true
}
}
}
rows.Close()
if !foundSup {
t.Errorf("table %s: multibyte_sup column missing", table)
}
if !foundEvid {
t.Errorf("table %s: multibyte_evidence column missing", table)
}
}
// Verify migration is present. As of #1324 follow-up the migration
// lives in internal/dbschema (column-probe + idempotent ALTER), not
// in the legacy _migrations marker table — so we just re-assert the
// columns exist and the second OpenStore is a no-op.
store.Close()
store2, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore (second open): %v", err)
}
store2.Close()
}
+1 -17
View File
@@ -493,22 +493,6 @@ func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channe
return result, nil
}
// knownChannelCasing maps known channel keys to their canonical display names.
// Only well-known channels are normalized — custom/user channels are left as-is.
var knownChannelCasing = map[string]string{
"public": "Public",
}
// normalizeChannelName fixes casing for well-known channel names.
// Only normalizes names that appear in knownChannelCasing (e.g. "public" → "Public").
// Custom channel names are left untouched since we can't know the intended casing.
func normalizeChannelName(name string) string {
if corrected, ok := knownChannelCasing[strings.ToLower(name)]; ok {
return corrected
}
return name
}
func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
if len(buf) < 3 {
return Payload{Type: "GRP_TXT", Error: "too short", RawHex: hex.EncodeToString(buf)}
@@ -533,7 +517,7 @@ func decodeGrpTxt(buf []byte, channelKeys map[string]string) Payload {
}
return Payload{
Type: "CHAN",
Channel: normalizeChannelName(name),
Channel: name,
ChannelHash: channelHash,
ChannelHashHex: channelHashHex,
DecryptionStatus: "decrypted",
-4
View File
@@ -47,7 +47,3 @@ require (
require github.com/meshcore-analyzer/prunequeue v0.0.0
replace github.com/meshcore-analyzer/prunequeue => ../../internal/prunequeue
require github.com/meshcore-analyzer/mbcapqueue v0.0.0
replace github.com/meshcore-analyzer/mbcapqueue => ../../internal/mbcapqueue
@@ -1,126 +0,0 @@
package main
// Regression test for issue #1370 — counters PR #1233 (commit 498fbc03).
//
// PR #1233 made the ingestor use the MQTT envelope's "timestamp" field as
// transmissions.first_seen / observations.timestamp, on the premise that
// uploaders stamp it at radio receive and the value is trustworthy.
//
// That premise FAILS for observers whose own clock is wrong. Staging
// Voodoo3 tx 304114 in channel #test had 5 observations:
// - 4 from Voodoo3 stamped "18:42" — Voodoo3's broken client clock,
// - 1 from another observer stamped "01:42" — the actual receive time.
// Voodoo3 ingested first, so first_seen locked at "18:42" and the
// /api/channels row showed the channel as last-active 7h+ in the past.
//
// Fix: revert the storage path — packet/observation timestamps are
// server ingest time (time.Now() at the ingestor). Envelope timestamp
// stays usable for observer.last_seen (PR #1233's MAX/MIN guard there
// is fine and unrelated to the channel-ordering bug).
import (
"strconv"
"testing"
"time"
)
// Raw packet path: envelope reports timestamp 7h in the past
// (simulating Voodoo3's broken client clock). After ingest,
// transmissions.first_seen and observations.timestamp must reflect
// SERVER wall clock, not the bogus envelope value.
func TestHandleMessage_PacketTimestamp_IgnoresStaleEnvelope_1370(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
stale := time.Now().UTC().Add(-7 * time.Hour).Format(time.RFC3339)
before := time.Now().Unix()
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"voodoo3","timestamp":"` + stale + `"}`)
msg := &mockMessage{topic: "meshcore/SJC/voodoo3/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
after := time.Now().Unix()
// ─── transmissions.first_seen ───────────────────────────────────────
var firstSeen string
if err := store.db.QueryRow(`SELECT first_seen FROM transmissions LIMIT 1`).Scan(&firstSeen); err != nil {
t.Fatalf("scan first_seen: %v", err)
}
fsParsed, err := time.Parse(time.RFC3339, firstSeen)
if err != nil {
t.Fatalf("first_seen %q not RFC3339: %v", firstSeen, err)
}
if fsParsed.Unix() < before-5 || fsParsed.Unix() > after+5 {
t.Errorf("transmissions.first_seen = %q (epoch %d); want in [%d, %d] (server wall clock). "+
"Envelope reported stale %q (7h ago) — PR #1233's premise that envelope timestamp is trustworthy is FALSE for buggy-clock observers. Issue #1370.",
firstSeen, fsParsed.Unix(), before, after, stale)
}
// ─── observations.timestamp (epoch) ─────────────────────────────────
var obsTs int64
if err := store.db.QueryRow(`SELECT timestamp FROM observations LIMIT 1`).Scan(&obsTs); err != nil {
t.Fatalf("scan observations.timestamp: %v", err)
}
if obsTs < before-5 || obsTs > after+5 {
t.Errorf("observations.timestamp = %d; want in [%d, %d] (server wall clock). Envelope stale = %q. Issue #1370.",
obsTs, before, after, stale)
}
}
// Channel-message (BLE companion) path: envelope timestamp stale → stored
// transmissions.first_seen must still be server wall clock.
func TestHandleMessage_ChannelPath_PacketTimestamp_IgnoresStaleEnvelope_1370(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
stale := time.Now().UTC().Add(-7 * time.Hour).Format(time.RFC3339)
before := time.Now().Unix()
payload := []byte(`{"text":"Voodoo3: tst hmdpt","channel_idx":3,"SNR":5.0,"RSSI":-95,"timestamp":"` + stale + `","sender_timestamp":` + strconv.FormatInt(time.Now().Unix(), 10) + `}`)
msg := &mockMessage{topic: "meshcore/message/channel/3", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
after := time.Now().Unix()
var firstSeen string
if err := store.db.QueryRow(`SELECT first_seen FROM transmissions LIMIT 1`).Scan(&firstSeen); err != nil {
t.Fatalf("scan first_seen: %v", err)
}
fsParsed, err := time.Parse(time.RFC3339, firstSeen)
if err != nil {
t.Fatalf("first_seen %q not RFC3339: %v", firstSeen, err)
}
if fsParsed.Unix() < before-5 || fsParsed.Unix() > after+5 {
t.Errorf("channel-path transmissions.first_seen = %q (epoch %d); want in [%d, %d] (server wall clock). Envelope stale = %q. Issue #1370.",
firstSeen, fsParsed.Unix(), before, after, stale)
}
}
// DM (BLE companion direct-message) path: same revert applies.
func TestHandleMessage_DMPath_PacketTimestamp_IgnoresStaleEnvelope_1370(t *testing.T) {
store := newTestStore(t)
source := MQTTSource{Name: "test"}
stale := time.Now().UTC().Add(-7 * time.Hour).Format(time.RFC3339)
before := time.Now().Unix()
payload := []byte(`{"text":"Voodoo3: hello","SNR":5.0,"RSSI":-95,"timestamp":"` + stale + `"}`)
msg := &mockMessage{topic: "meshcore/message/direct/voodoo3", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
after := time.Now().Unix()
var firstSeen string
if err := store.db.QueryRow(`SELECT first_seen FROM transmissions LIMIT 1`).Scan(&firstSeen); err != nil {
t.Fatalf("scan first_seen: %v", err)
}
fsParsed, err := time.Parse(time.RFC3339, firstSeen)
if err != nil {
t.Fatalf("first_seen %q not RFC3339: %v", firstSeen, err)
}
if fsParsed.Unix() < before-5 || fsParsed.Unix() > after+5 {
t.Errorf("DM-path transmissions.first_seen = %q (epoch %d); want in [%d, %d] (server wall clock). Envelope stale = %q. Issue #1370.",
firstSeen, fsParsed.Unix(), before, after, stale)
}
}
+27 -63
View File
@@ -197,25 +197,6 @@ func main() {
// endpoint (#1120). Best-effort; never fatal.
StartStatsFileWriter(store, time.Second)
// Multi-byte capability persister (#1324 follow-up): the server's
// analytics cycle publishes a snapshot file via internal/mbcapqueue
// (it cannot UPDATE itself, mode=ro since #1289). The ingestor
// applies the snapshot here every 5 minutes — derived/cached
// columns, ingestor owns the write.
multibytePersistTicker := time.NewTicker(5 * time.Minute)
go func() {
time.Sleep(2 * time.Minute) // stagger after analytics warmup
if _, err := store.RunMultibyteCapPersist(); err != nil {
log.Printf("[multibyte-persist] error: %v", err)
}
for range multibytePersistTicker.C {
if _, err := store.RunMultibyteCapPersist(); err != nil {
log.Printf("[multibyte-persist] error: %v", err)
}
}
}()
log.Printf("[multibyte-persist] enabled (interval=5m)")
// Neighbor-edges builder (#1287 — Option 4): ingestor owns
// neighbor_edges writes. Runs every 60s. Server reads the snapshot
// via cmd/server/neighbor_recomputer.go on the same cadence.
@@ -295,18 +276,6 @@ func main() {
// Registration BEFORE Connect so the attempt counter is available
// to OnConnectAttempt on the very first dial.
liveness.IsConnectedFn = client.IsConnected
// #1335: wire force-reconnect so the watchdog can drop a
// half-open TCP socket and re-dial when paho.IsConnected==true
// but no messages have flowed past the stall threshold. Throttled
// per source by the watchdog itself (forceReconnectThrottle).
// Disconnect(250) gives in-flight publishes 250ms to drain;
// Connect() returns immediately and paho's reconnect machinery
// takes over from there. Captured-by-value `client` is the same
// pointer used everywhere else for this source.
liveness.ForceReconnectFn = func() {
client.Disconnect(250)
client.Connect()
}
// PR #1216 r2 item 3: tag collisions used to log.Fatalf, which
// killed the entire ingestor over one config typo and recreated
// the #1212 total-ingest-stop class this PR exists to prevent.
@@ -395,23 +364,34 @@ 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).
// #1335: TCP-level keepalive surfaces a half-open socket within
// ~30-60s instead of waiting for the application-level watchdog
// (5m) to notice no messages. paho's MQTT PINGREQ uses this
// interval too — if the broker's PINGRESP doesn't arrive,
// ConnectionLost fires and auto-reconnect kicks in. Was unset
// (paho default 30s actually — making this explicit so it can't
// drift, and so operators reading the code know it's intentional
// per the #1335 RCA).
SetKeepAlive(30 * time.Second)
SetWriteTimeout(10 * time.Second)
opts.SetConnectionAttemptHandler(func(broker *url.URL, tlsCfg *tls.Config) *tls.Config {
// Look up the per-source liveness state (registered in main) so we
@@ -754,6 +734,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
decodedJSON, _ := json.Marshal(channelMsg)
ingestNow := time.Now().UTC().Format(time.RFC3339)
rxTime := resolveRxTime(msg, tag)
hashInput := fmt.Sprintf("ch:%s:%s:%s", channelIdx, text, ingestNow)
h := sha256.Sum256([]byte(hashInput))
hash := hex.EncodeToString(h[:])[:16]
@@ -794,7 +775,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
}
pktData := &PacketData{
Timestamp: ingestNow, // #1370 (counters #1233): server ingest time, not envelope rxTime
Timestamp: rxTime,
ObserverID: "companion",
ObserverName: "L1 Pro (BLE)",
SNR: snr,
@@ -847,6 +828,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
decodedJSON, _ := json.Marshal(dm)
ingestNow := time.Now().UTC().Format(time.RFC3339)
rxTime := resolveRxTime(msg, tag)
hashInput := fmt.Sprintf("dm:%s:%s", text, ingestNow)
h := sha256.Sum256([]byte(hashInput))
hash := hex.EncodeToString(h[:])[:16]
@@ -887,7 +869,7 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
}
pktData := &PacketData{
Timestamp: ingestNow, // #1370 (counters #1233): server ingest time, not envelope rxTime
Timestamp: rxTime,
ObserverID: "companion",
ObserverName: "L1 Pro (BLE)",
SNR: snr,
@@ -1195,25 +1177,7 @@ func loadChannelKeys(cfg *Config, configPath string) map[string]string {
// 3. Explicit config keys (highest priority — overrides rainbow + derived)
for k, v := range cfg.ChannelKeys {
normalized := normalizeChannelName(k)
if normalized != k {
log.Printf("[channels] Normalizing known channel key %q → %q for display", k, normalized)
}
// Detect config collision: if both "public" and "Public" are present,
// the normalized key collides. Resolve deterministically: prefer the
// canonical (already-normalized) form over the lowercase variant.
if _, dupe := keys[normalized]; dupe {
// If the incoming key IS the canonical form, it wins (overwrite).
// If the incoming key is a non-canonical form (e.g., "public"), keep existing.
if k == normalized {
log.Printf("[channels] Resolving duplicate %q: canonical form wins over non-canonical", normalized)
keys[normalized] = v
} else {
log.Printf("[channels] WARNING: duplicate channel key %q — config has %q normalizing to %q, keeping canonical value", normalized, k, normalized)
}
} else {
keys[normalized] = v
}
keys[k] = v
}
return keys
+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)
}
}
-66
View File
@@ -14,10 +14,6 @@ import (
// shift, infrequent enough not to spam ops chat.
const livenessHeartbeatInterval = time.Hour
// forceReconnectThrottle is the minimum interval between forced
// reconnects on the SAME source. See processLivenessTransition.
const forceReconnectThrottle = 60 * time.Second
// LivenessKind enumerates the watchdog verdicts for a source. Edge-triggered
// transitions use this to decide whether to emit (and what severity).
type LivenessKind int
@@ -67,22 +63,6 @@ type SourceLivenessState struct {
StartedAt int64 // atomic; unix seconds when the source was registered / last reconnected (transient-stall tracking)
LastAlertUnix int64 // atomic; unix seconds of last emit (WARN or heartbeat); 0 means quiet
IsConnectedFn func() bool
// ForceReconnectFn (#1335) is called by the watchdog when a source
// transitions INTO LivenessStalled. It must force the paho client
// to drop its current TCP socket and re-establish (typically
// client.Disconnect(250) followed by client.Connect()). Half-open
// TCP sockets (Azure NAT idle timeout) report IsConnected==true so
// paho's own auto-reconnect never fires; this is the recovery path.
// May be nil (tests, or sources registered before wiring); the
// watchdog must treat that as a safe no-op. Invocations are
// throttled at forceReconnectThrottle per source so a
// stall→reconnect→re-stall loop self-recovers without hammering
// the broker.
ForceReconnectFn func()
// LastForceReconnectUnix is the unix-seconds timestamp of the most
// recent forced reconnect for this source; the watchdog reads it
// to enforce forceReconnectThrottle. atomic.
LastForceReconnectUnix int64
// AttemptCount is incremented on every TCP/TLS connection attempt. Used
// by ConnectionAttemptHandler to log attempt # independent of paho's
// internal reconnect-loop state. atomic.
@@ -292,30 +272,12 @@ func processLivenessTransition(s *SourceLivenessState, kind LivenessKind, msg st
// First detection — fire WARN edge.
emit(msg)
atomic.StoreInt64(&s.LastAlertUnix, now.Unix())
// #1335: ONLY LivenessStalled (paho reports connected but no
// messages past threshold — classic half-open TCP) gets
// force-reconnected. LivenessNeverReceived is almost always
// an ACL deny / wrong channel hash — a new TCP socket won't
// fix it and would just churn the broker. The distinct
// "NEVER received" alarm is the right operator signal for
// that class.
if kind == LivenessStalled {
maybeForceReconnect(s, now, emit)
}
return
}
// Already alerted; only re-emit on heartbeat interval to avoid log flood.
if now.Sub(time.Unix(lastAlert, 0)) >= livenessHeartbeatInterval {
emit(fmt.Sprintf("MQTT [%s] WATCHDOG heartbeat: still stalled — %s", s.Tag, msg))
atomic.StoreInt64(&s.LastAlertUnix, now.Unix())
// Heartbeat re-emit on a still-Stalled source: try another
// force-reconnect IF the throttle window has elapsed. Under
// a persistent broker issue this caps at one attempt per
// heartbeat (1h) — orders of magnitude under any rate
// limit and well within "don't hammer the broker".
if kind == LivenessStalled {
maybeForceReconnect(s, now, emit)
}
}
case LivenessOK:
if lastAlert != 0 {
@@ -332,31 +294,3 @@ func processLivenessTransition(s *SourceLivenessState, kind LivenessKind, msg st
}
}
// maybeForceReconnect invokes ForceReconnectFn IFF (a) one is wired and
// (b) the throttle window (forceReconnectThrottle) has elapsed since
// the most recent forced reconnect for this source. Logs WATCHDOG
// telemetry before/after so operators can correlate the reconnect with
// downstream paho ConnectionAttempt/OnConnect lines.
func maybeForceReconnect(s *SourceLivenessState, now time.Time, emit func(...any)) {
if s.ForceReconnectFn == nil {
return
}
lastForce := atomic.LoadInt64(&s.LastForceReconnectUnix)
if lastForce != 0 && now.Sub(time.Unix(lastForce, 0)) < forceReconnectThrottle {
emit(fmt.Sprintf("MQTT [%s] WATCHDOG suppressing forced reconnect (last attempt %s ago, throttle %s)",
s.Tag, now.Sub(time.Unix(lastForce, 0)).Round(time.Second), forceReconnectThrottle))
return
}
atomic.StoreInt64(&s.LastForceReconnectUnix, now.Unix())
emit(fmt.Sprintf("MQTT [%s] WATCHDOG forcing reconnect (half-open TCP suspected — paho.IsConnected==true but no messages)", s.Tag))
// Run in a goroutine: ForceReconnectFn typically calls
// client.Disconnect(250) which blocks up to 250ms, then
// client.Connect() which can block on the connect timeout. The
// watchdog goroutine must not stall a per-tick scan over a single
// slow source.
go func() {
s.ForceReconnectFn()
emit(fmt.Sprintf("MQTT [%s] WATCHDOG reconnect attempt issued", s.Tag))
}()
}
@@ -1,174 +0,0 @@
package main
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// Issue #1335 — staging's lincomatic source stalls: paho reports
// IsConnected==true but no messages arrive for 1h+. The PR #1216
// watchdog DETECTS this (LivenessStalled) but only LOGS — it never
// forces paho to drop the half-open TCP socket and reconnect, so the
// source stays silently broken until container restart.
//
// Fix: on transition INTO LivenessStalled, invoke a per-source
// ForceReconnectFn (wired in main.go to client.Disconnect(250) +
// client.Connect()). Throttled by forceReconnectThrottle so a
// stall→reconnect→re-stall loop self-recovers without hammering the
// broker.
// RED on master: ForceReconnectFn is never invoked because the
// transition engine does not call it. After the fix, the WARN edge on
// LivenessStalled MUST fire force-reconnect exactly once.
func TestMQTTStallWatchdog_ForceReconnectOnStallEdge(t *testing.T) {
defer snapshotAndResetRegistry(t)()
now := time.Now()
var reconnectCount atomic.Int32
s := &SourceLivenessState{
Tag: "stalled-half-open",
Broker: "tcp://halfopen.example:1883",
IsConnectedFn: func() bool { return true },
ForceReconnectFn: func() { reconnectCount.Add(1) },
}
atomic.StoreInt64(&s.LastMessageUnix, now.Add(-10*time.Minute).Unix())
atomic.StoreInt64(&s.StartedAt, now.Add(-20*time.Minute).Unix())
if err := registerLivenessState(s); err != nil {
t.Fatalf("setup: %v", err)
}
var mu sync.Mutex
var emits []string
emit := func(args ...any) {
mu.Lock()
defer mu.Unlock()
if len(args) > 0 {
if str, ok := args[0].(string); ok {
emits = append(emits, str)
}
}
}
processLivenessTransition(s, LivenessStalled, "10m silent", now, emit)
// ForceReconnectFn runs in a goroutine (the production code can't
// block the watchdog tick on a slow Disconnect+Connect). Wait
// briefly for it to land before asserting.
waitForReconnect(t, &reconnectCount, 1, 2*time.Second)
if got := reconnectCount.Load(); got != 1 {
t.Fatalf("LivenessStalled transition MUST force-reconnect exactly once; got %d invocations (emits=%v)", got, emits)
}
}
// Throttle: a second LivenessStalled transition within the throttle
// window MUST NOT fire a second reconnect (no broker hammering).
func TestMQTTStallWatchdog_ForceReconnectThrottled(t *testing.T) {
defer snapshotAndResetRegistry(t)()
now := time.Now()
var reconnectCount atomic.Int32
s := &SourceLivenessState{
Tag: "throttled",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return true },
ForceReconnectFn: func() { reconnectCount.Add(1) },
}
if err := registerLivenessState(s); err != nil {
t.Fatalf("setup: %v", err)
}
emit := func(args ...any) {}
// First stall edge → fires.
processLivenessTransition(s, LivenessStalled, "stall 1", now, emit)
waitForReconnect(t, &reconnectCount, 1, 2*time.Second)
// Simulate paho reconnect cycle: MarkReconnected clears the alert
// cooldown, then the source goes stalled again 5s later.
s.MarkReconnected(now.Add(5 * time.Second))
processLivenessTransition(s, LivenessStalled, "stall 2", now.Add(10*time.Second), emit)
// Give a stray goroutine a chance to land (it shouldn't, due to throttle).
time.Sleep(100 * time.Millisecond)
if got := reconnectCount.Load(); got != 1 {
t.Fatalf("force-reconnect MUST be throttled within %s; got %d invocations", forceReconnectThrottle, got)
}
// After the throttle window, a fresh stall edge MAY fire again.
s.MarkReconnected(now.Add(30 * time.Second))
processLivenessTransition(s, LivenessStalled, "stall 3", now.Add(forceReconnectThrottle+30*time.Second), emit)
waitForReconnect(t, &reconnectCount, 2, 2*time.Second)
if got := reconnectCount.Load(); got != 2 {
t.Fatalf("after throttle window, force-reconnect must re-arm; got %d invocations", got)
}
}
// NeverReceived (cold-start ACL-deny / never-flowed) MUST NOT
// force-reconnect. A SUBSCRIBE ACL deny is not fixed by a new TCP
// socket; reconnecting just churns the broker. Operators get the
// distinct "NEVER received" alarm so they can address the ACL.
func TestMQTTStallWatchdog_NoForceReconnectOnNeverReceived(t *testing.T) {
defer snapshotAndResetRegistry(t)()
now := time.Now()
var reconnectCount atomic.Int32
s := &SourceLivenessState{
Tag: "acl-denied",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return true },
ForceReconnectFn: func() { reconnectCount.Add(1) },
}
if err := registerLivenessState(s); err != nil {
t.Fatalf("setup: %v", err)
}
emit := func(args ...any) {}
processLivenessTransition(s, LivenessNeverReceived, "no msgs ever", now, emit)
// Settle any (incorrect) goroutine before counting.
time.Sleep(100 * time.Millisecond)
if got := reconnectCount.Load(); got != 0 {
t.Fatalf("LivenessNeverReceived must NOT force-reconnect (likely ACL deny — TCP churn won't help); got %d invocations", got)
}
}
// Safety: a source with no ForceReconnectFn wired (e.g. tests, or a
// source registered before the wiring was added) MUST NOT panic when
// LivenessStalled fires.
func TestMQTTStallWatchdog_NilForceReconnectFnIsSafe(t *testing.T) {
defer snapshotAndResetRegistry(t)()
now := time.Now()
s := &SourceLivenessState{
Tag: "no-reconnect-fn",
Broker: "tcp://x:1883",
IsConnectedFn: func() bool { return true },
// ForceReconnectFn deliberately nil.
}
if err := registerLivenessState(s); err != nil {
t.Fatalf("setup: %v", err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("nil ForceReconnectFn must be a safe no-op; panicked: %v", r)
}
}()
processLivenessTransition(s, LivenessStalled, "stalled", now, func(args ...any) {})
}
// waitForReconnect polls reconnectCount until it reaches `want` or the
// deadline elapses. ForceReconnectFn runs in a goroutine in production
// (Disconnect+Connect can block on broker IO), so tests can't read the
// counter synchronously.
func waitForReconnect(t *testing.T, count *atomic.Int32, want int32, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if count.Load() >= want {
return
}
time.Sleep(5 * time.Millisecond)
}
}
-221
View File
@@ -1,221 +0,0 @@
package main
import (
"encoding/json"
"errors"
"log"
"os"
"github.com/meshcore-analyzer/mbcapqueue"
)
// MultibyteCapPersistStats holds counts for /api/healthz exposure / logging.
type MultibyteCapPersistStats struct {
ReadEntries int // entries read from snapshot
UpdatedActive int64 // rows updated in nodes
UpdatedInactive int64 // rows updated in inactive_nodes
Skipped int // entries skipped (status=="unknown")
}
// RunMultibyteCapPersist consumes the latest multi-byte capability snapshot
// written by the server (internal/mbcapqueue) and persists it to nodes /
// inactive_nodes. Owned by the ingestor per #1287: the server is read-only
// since #1289 and cannot UPDATE these columns itself.
//
// INVARIANT (canonical owner): multibyte_sup / multibyte_evidence are
// derived/cached columns. The server COMPUTES the value during its
// analytics cycle (from observed packets) and writes a snapshot file;
// this function is the ONLY runtime path that mutates those columns
// (the schema itself is added by internal/dbschema). The server MUST
// NOT execute any UPDATE on nodes.multibyte_* — see
// cmd/server/readonly_invariant_test.go for the enforcement.
//
// Data-destruction guard: entries with Status=="unknown" (sup==0) are
// NEVER persisted — we never overwrite a previously confirmed/suspected
// DB value with a snapshot blank. Same guarantee the original
// server-side helper enforced before relocation.
//
// Safe to call from a ticker; no-op when no snapshot has been written
// (cold start), when the snapshot is empty, when the snapshot is
// malformed (#1386), or when running against a legacy DB that
// pre-dates the multibyte_sup migration (#1386).
func (s *Store) RunMultibyteCapPersist() (MultibyteCapPersistStats, error) {
var stats MultibyteCapPersistStats
snap, err := mbcapqueue.ReadSnapshot(s.path)
if err != nil {
// os.ErrNotExist is the steady state until the server's first
// analytics cycle completes — silent no-op. A malformed file
// is operator-actionable: log it (but still no-op, no error
// surfaced to the ticker — a corrupt snapshot must not stop
// the maintenance loop).
if errors.Is(err, os.ErrNotExist) {
return stats, nil
}
// All other ReadSnapshot errors today are wrap-arounds of
// io / unmarshal failures — both classify as "malformed
// snapshot on disk" from this loop's perspective.
var jsonErr *json.SyntaxError
if errors.As(err, &jsonErr) || isMalformedSnapshotErr(err) {
log.Printf("[multibyte-persist] malformed snapshot on disk (no-op): %v", err)
return stats, nil
}
log.Printf("[multibyte-persist] read snapshot: %v (no-op)", err)
return stats, nil
}
stats.ReadEntries = len(snap.Entries)
if len(snap.Entries) == 0 {
return stats, nil
}
// Defensive schema check: a legacy DB that pre-dates the
// multibyte_sup migration would fail at tx.Prepare with a SQL
// error. Detect early and skip cleanly so the ticker keeps
// running on heterogeneous deployments.
if !s.hasMultibyteSupColumns() {
log.Printf("[multibyte-persist] schema missing: nodes.multibyte_sup not present on this DB (legacy schema) — skipping %d entries", stats.ReadEntries)
return stats, nil
}
tx, err := s.db.Begin()
if err != nil {
return stats, err
}
defer tx.Rollback() //nolint:errcheck
// Combined dispatch: each pubkey lives in exactly one of nodes /
// inactive_nodes. The pre-#1386 implementation issued one UPDATE
// against each table per entry — 50% guaranteed-empty. We now
// look up the table once, then issue the matching UPDATE.
stmtN, err := tx.Prepare(`UPDATE nodes SET multibyte_sup=?, multibyte_evidence=? WHERE public_key=?`)
if err != nil {
return stats, err
}
defer stmtN.Close()
stmtI, err := tx.Prepare(`UPDATE inactive_nodes SET multibyte_sup=?, multibyte_evidence=? WHERE public_key=?`)
if err != nil {
return stats, err
}
defer stmtI.Close()
// Membership probe: one indexed PK lookup. Cheap; avoids the
// guaranteed-miss second UPDATE.
stmtProbe, err := tx.Prepare(`SELECT 1 FROM nodes WHERE public_key=? LIMIT 1`)
if err != nil {
return stats, err
}
defer stmtProbe.Close()
for _, e := range snap.Entries {
sup := multibyteStatusToInt(e.Status)
if sup == 0 {
stats.Skipped++
continue
}
// Probe once. If hit, UPDATE nodes; else UPDATE inactive_nodes.
var hit int
if err := stmtProbe.QueryRow(e.PublicKey).Scan(&hit); err == nil {
if r, err := stmtN.Exec(sup, e.Evidence, e.PublicKey); err == nil {
if n, _ := r.RowsAffected(); n > 0 {
stats.UpdatedActive += n
}
}
} else {
if r, err := stmtI.Exec(sup, e.Evidence, e.PublicKey); err == nil {
if n, _ := r.RowsAffected(); n > 0 {
stats.UpdatedInactive += n
}
}
}
}
if err := tx.Commit(); err != nil {
return stats, err
}
if stats.UpdatedActive+stats.UpdatedInactive > 0 {
log.Printf("[multibyte-persist] applied snapshot: %d entries (%d skipped); updated %d active + %d inactive nodes",
stats.ReadEntries, stats.Skipped, stats.UpdatedActive, stats.UpdatedInactive)
}
return stats, nil
}
// isMalformedSnapshotErr returns true if err looks like a JSON parse /
// IO-truncation failure surfaced by mbcapqueue.ReadSnapshot. The
// queue wraps errors with %w but mbcapqueue currently formats with
// %w only for "read:"/"unmarshal:" prefixes — we substring-match
// those so the operator-actionable log message is unambiguous.
func isMalformedSnapshotErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
for _, frag := range []string{"unmarshal", "invalid character", "unexpected end of JSON"} {
if containsCI(msg, frag) {
return true
}
}
return false
}
func containsCI(s, sub string) bool {
if len(sub) == 0 {
return true
}
// case-insensitive Contains without importing strings (already
// imported in db.go, but keeping helper local to avoid widening
// this file's imports).
for i := 0; i+len(sub) <= len(s); i++ {
match := true
for j := 0; j < len(sub); j++ {
a, b := s[i+j], sub[j]
if a >= 'A' && a <= 'Z' {
a += 32
}
if b >= 'A' && b <= 'Z' {
b += 32
}
if a != b {
match = false
break
}
}
if match {
return true
}
}
return false
}
// hasMultibyteSupColumns probes whether the active DB carries the
// multibyte_sup column on the `nodes` table. Used to short-circuit
// RunMultibyteCapPersist on legacy DBs that pre-date the
// internal/dbschema migration (#1386).
func (s *Store) hasMultibyteSupColumns() bool {
rows, err := s.db.Query(`PRAGMA table_info(nodes)`)
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt interface{}
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false
}
if name == "multibyte_sup" {
return true
}
}
return false
}
// multibyteStatusToInt mirrors the mapping the server used before relocation.
// 0 = unknown (never persisted), 1 = suspected, 2 = confirmed.
func multibyteStatusToInt(status string) int {
switch status {
case "confirmed":
return 2
case "suspected":
return 1
default:
return 0
}
}
@@ -1,54 +0,0 @@
package main
import (
"bytes"
"database/sql"
"log"
"strings"
"testing"
)
// captureLogs redirects the standard logger to a buffer for the
// duration of the test and returns the buffer. Restores the previous
// writer when the test ends.
func captureLogs(t *testing.T) *bytes.Buffer {
t.Helper()
buf := &bytes.Buffer{}
prevWriter := log.Writer()
prevFlags := log.Flags()
log.SetOutput(buf)
t.Cleanup(func() {
log.SetOutput(prevWriter)
log.SetFlags(prevFlags)
})
return buf
}
// logContains reports whether the captured log buffer contains substr
// (case-insensitive).
func logContains(buf *bytes.Buffer, substr string) bool {
return strings.Contains(strings.ToLower(buf.String()), strings.ToLower(substr))
}
// columnExists reports whether the named column exists on the table.
func columnExists(t *testing.T, db *sql.DB, table, col string) bool {
t.Helper()
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dfltValue sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &pk); err != nil {
t.Fatalf("scan PRAGMA: %v", err)
}
if name == col {
return true
}
}
return false
}
-369
View File
@@ -1,369 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/meshcore-analyzer/mbcapqueue"
)
// TestRunMultibyteCapPersist_AppliesSnapshot enforces the architectural
// invariant from #1289 + #1322 + #1324 follow-up: the multi-byte
// capability columns (multibyte_sup / multibyte_evidence) on
// nodes / inactive_nodes MUST be written by the ingestor, NEVER by the
// read-only server. The server publishes a snapshot file via
// internal/mbcapqueue; the ingestor's maintenance loop applies it here.
//
// Pre-relocation (PR #1324 as-shipped), the server held a write handle
// and executed UPDATE … nodes SET multibyte_sup directly — which is
// impossible after #1289 made the server's *sql.DB read-only. This test
// asserts the relocated path: snapshot in → UPDATEs out, from the
// ingestor side.
func TestRunMultibyteCapPersist_AppliesSnapshot(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
// Seed two nodes: one active, one inactive.
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
VALUES ('aa11', 'Alpha', 'repeater', '2026-01-01T00:00:00Z', 0, NULL)`); err != nil {
t.Fatalf("seed nodes: %v", err)
}
if _, err := store.db.Exec(`INSERT INTO inactive_nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
VALUES ('bb22', 'Bravo', 'repeater', '2025-01-01T00:00:00Z', 0, NULL)`); err != nil {
t.Fatalf("seed inactive_nodes: %v", err)
}
// Seed a third node already confirmed, then send "unknown" for it —
// the data-destruction guard must keep its DB value.
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
VALUES ('cc33', 'Charlie', 'repeater', '2026-01-01T00:00:00Z', 2, 'advert')`); err != nil {
t.Fatalf("seed cc33: %v", err)
}
snap := mbcapqueue.Snapshot{Entries: []mbcapqueue.Entry{
{PublicKey: "aa11", Status: "confirmed", Evidence: "advert"},
{PublicKey: "bb22", Status: "suspected", Evidence: "path"},
{PublicKey: "cc33", Status: "unknown"}, // must NOT overwrite
}}
if err := mbcapqueue.WriteSnapshot(dbPath, snap); err != nil {
t.Fatalf("WriteSnapshot: %v", err)
}
// Sanity: snapshot file landed where we expect.
if _, err := os.Stat(filepath.Join(filepath.Dir(dbPath), mbcapqueue.QueueDirName, mbcapqueue.SnapshotFileName)); err != nil {
t.Fatalf("snapshot not on disk: %v", err)
}
stats, err := store.RunMultibyteCapPersist()
if err != nil {
t.Fatalf("RunMultibyteCapPersist: %v", err)
}
if stats.ReadEntries != 3 {
t.Errorf("ReadEntries = %d, want 3", stats.ReadEntries)
}
if stats.Skipped != 1 {
t.Errorf("Skipped = %d, want 1 (the unknown entry)", stats.Skipped)
}
if stats.UpdatedActive == 0 {
t.Errorf("UpdatedActive = 0; expected aa11 to be updated in nodes")
}
if stats.UpdatedInactive == 0 {
t.Errorf("UpdatedInactive = 0; expected bb22 to be updated in inactive_nodes")
}
// Verify DB state.
var sup int
var evid string
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='aa11'`).Scan(&sup, &evid); err != nil {
t.Fatalf("read aa11: %v", err)
}
if sup != 2 || evid != "advert" {
t.Errorf("aa11 after persist: sup=%d evid=%q, want sup=2 evid=advert", sup, evid)
}
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM inactive_nodes WHERE public_key='bb22'`).Scan(&sup, &evid); err != nil {
t.Fatalf("read bb22: %v", err)
}
if sup != 1 || evid != "path" {
t.Errorf("bb22 after persist: sup=%d evid=%q, want sup=1 evid=path", sup, evid)
}
// Data-destruction guard: cc33 must still be confirmed=2/'advert'.
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='cc33'`).Scan(&sup, &evid); err != nil {
t.Fatalf("read cc33: %v", err)
}
if sup != 2 || evid != "advert" {
t.Errorf("cc33 was overwritten by unknown entry: sup=%d evid=%q, want sup=2 evid=advert", sup, evid)
}
}
// TestRunMultibyteCapPersist_NoSnapshot_NoOp verifies that the persist
// step is a clean no-op when the server hasn't written a snapshot yet
// (cold start; the analytics cycle takes ~15s after server boot).
func TestRunMultibyteCapPersist_NoSnapshot_NoOp(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
stats, err := store.RunMultibyteCapPersist()
if err != nil {
t.Fatalf("RunMultibyteCapPersist (no snapshot): %v", err)
}
if stats.ReadEntries != 0 || stats.UpdatedActive != 0 || stats.UpdatedInactive != 0 {
t.Errorf("expected zero-valued stats on cold start, got %+v", stats)
}
}
// TestRunMultibyteCapPersist_RoundTrip exercises the full end-to-end
// contract claimed by PR #1324: the server writes a snapshot, the
// ingestor persists it, and after a simulated restart (close + reopen
// the store) the DB still carries the persisted state.
//
// The audit (#1386) flagged this as the #1 missing test: the two halves
// (persist / read-back) were each tested in isolation, but no single
// test proved the persist path produces a database state the loader
// can later consume — so a column-rename or snapshot-version drift
// would slip past.
func TestRunMultibyteCapPersist_RoundTrip(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
// --- Phase 1: open store, seed, persist snapshot ---
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
VALUES ('dd44', 'Delta', 'repeater', '2026-01-01T00:00:00Z', 0, NULL)`); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := store.db.Exec(`INSERT INTO inactive_nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
VALUES ('ee55', 'Echo', 'companion', '2025-12-01T00:00:00Z', 0, NULL)`); err != nil {
t.Fatalf("seed inactive: %v", err)
}
snap := mbcapqueue.Snapshot{Entries: []mbcapqueue.Entry{
{PublicKey: "dd44", Status: "confirmed", Evidence: "advert"},
{PublicKey: "ee55", Status: "suspected", Evidence: "path"},
}}
if err := mbcapqueue.WriteSnapshot(dbPath, snap); err != nil {
t.Fatalf("WriteSnapshot: %v", err)
}
if _, err := store.RunMultibyteCapPersist(); err != nil {
t.Fatalf("RunMultibyteCapPersist: %v", err)
}
// Capture original state for round-trip comparison.
var origActiveSup, origInactiveSup int
var origActiveEvid, origInactiveEvid string
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='dd44'`).Scan(&origActiveSup, &origActiveEvid); err != nil {
t.Fatalf("read dd44 (phase1): %v", err)
}
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM inactive_nodes WHERE public_key='ee55'`).Scan(&origInactiveSup, &origInactiveEvid); err != nil {
t.Fatalf("read ee55 (phase1): %v", err)
}
// Simulate restart: drop the in-memory Store entirely.
if err := store.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// --- Phase 2: fresh Store, verify persisted state survived ---
store2, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore (reopen): %v", err)
}
defer store2.Close()
var sup int
var evid string
if err := store2.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='dd44'`).Scan(&sup, &evid); err != nil {
t.Fatalf("read dd44 after reopen: %v", err)
}
if sup != origActiveSup || evid != origActiveEvid {
t.Errorf("dd44 after restart: sup=%d evid=%q, want sup=%d evid=%q", sup, evid, origActiveSup, origActiveEvid)
}
if sup != 2 || evid != "advert" {
t.Errorf("dd44 after restart: sup=%d evid=%q, want sup=2 evid=advert", sup, evid)
}
if err := store2.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM inactive_nodes WHERE public_key='ee55'`).Scan(&sup, &evid); err != nil {
t.Fatalf("read ee55 after reopen: %v", err)
}
if sup != origInactiveSup || evid != origInactiveEvid {
t.Errorf("ee55 after restart: sup=%d evid=%q, want sup=%d evid=%q", sup, evid, origInactiveSup, origInactiveEvid)
}
if sup != 1 || evid != "path" {
t.Errorf("ee55 after restart: sup=%d evid=%q, want sup=1 evid=path", sup, evid)
}
}
// TestRunMultibyteCapPersist_MalformedSnapshot verifies the persist
// path is safe against a corrupted/truncated snapshot file: it must
// return without error (no-op), MUST NOT crash, AND MUST log a warning
// distinguishing the malformed case from the steady-state "no
// snapshot yet" cold-start case.
//
// Audit (#1386, kent-beck) flagged: "Snapshot file malformed /
// truncated / wrong-version — RunMultibyteCapPersist error vs.
// silent-skip behavior is unspecified by any test."
func TestRunMultibyteCapPersist_MalformedSnapshot(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
// Write malformed JSON directly to the snapshot path.
if err := mbcapqueue.EnsureDir(dbPath); err != nil {
t.Fatalf("EnsureDir: %v", err)
}
if err := os.WriteFile(mbcapqueue.SnapshotPath(dbPath), []byte("not-json{{{garbage"), 0o644); err != nil {
t.Fatalf("write malformed: %v", err)
}
// Capture log output to assert the warning is emitted.
logBuf := captureLogs(t)
// Must not panic.
defer func() {
if r := recover(); r != nil {
t.Fatalf("RunMultibyteCapPersist panicked on malformed snapshot: %v", r)
}
}()
stats, err := store.RunMultibyteCapPersist()
if err != nil {
t.Errorf("RunMultibyteCapPersist on malformed snapshot returned error %v; expected silent no-op", err)
}
if stats.ReadEntries != 0 || stats.UpdatedActive != 0 || stats.UpdatedInactive != 0 {
t.Errorf("expected zero-valued stats on malformed snapshot, got %+v", stats)
}
if !logContains(logBuf, "malformed") && !logContains(logBuf, "invalid") && !logContains(logBuf, "corrupt") {
t.Errorf("expected log to mention malformed/invalid/corrupt snapshot; got: %s", logBuf.String())
}
}
// TestRunMultibyteCapPersist_MissingSchemaColumns verifies the persist
// path is a clean no-op on a legacy DB that doesn't yet have the
// multibyte_sup / multibyte_evidence columns. Currently the persist
// would fail at tx.Prepare with a SQL error; the audit requires it
// skip cleanly instead.
//
// We simulate a legacy DB by DROPping the columns post-migration
// (SQLite ≥ 3.35 supports ALTER TABLE DROP COLUMN).
func TestRunMultibyteCapPersist_MissingSchemaColumns(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
// Drop the multibyte columns from both tables to simulate a legacy DB.
for _, stmt := range []string{
`ALTER TABLE nodes DROP COLUMN multibyte_sup`,
`ALTER TABLE nodes DROP COLUMN multibyte_evidence`,
`ALTER TABLE inactive_nodes DROP COLUMN multibyte_sup`,
`ALTER TABLE inactive_nodes DROP COLUMN multibyte_evidence`,
} {
if _, err := store.db.Exec(stmt); err != nil {
t.Fatalf("simulate legacy DB (%q): %v", stmt, err)
}
}
// Confirm columns are gone.
if columnExists(t, store.db, "nodes", "multibyte_sup") {
t.Fatalf("setup failed: nodes.multibyte_sup still present after DROP")
}
snap := mbcapqueue.Snapshot{Entries: []mbcapqueue.Entry{
{PublicKey: "ff66", Status: "confirmed", Evidence: "advert"},
}}
if err := mbcapqueue.WriteSnapshot(dbPath, snap); err != nil {
t.Fatalf("WriteSnapshot: %v", err)
}
logBuf := captureLogs(t)
defer func() {
if r := recover(); r != nil {
t.Fatalf("RunMultibyteCapPersist panicked on legacy DB: %v", r)
}
}()
stats, err := store.RunMultibyteCapPersist()
if err != nil {
t.Errorf("RunMultibyteCapPersist on legacy DB returned error %v; expected clean skip", err)
}
if stats.UpdatedActive != 0 || stats.UpdatedInactive != 0 {
t.Errorf("expected zero writes on legacy DB, got %+v", stats)
}
// Must explicitly detect + log the skip — otherwise the "clean skip"
// is silent UPDATE-affected-zero accident, not defensive code.
if !logContains(logBuf, "legacy") && !logContains(logBuf, "schema") && !logContains(logBuf, "multibyte_sup") {
t.Errorf("expected explicit log on missing schema columns; got: %s", logBuf.String())
}
}
// TestRunMultibyteCapPersist_PreservesConfirmedOnUnknown is the
// data-destruction guard the PR claims to enforce: a snapshot Entry
// with status="unknown" must NEVER overwrite an existing "confirmed"
// (or "suspected") DB row. The audit's mutation test: revert the
// `if sup == 0 { continue }` guard in multibyte_persist.go — this
// test must fail.
func TestRunMultibyteCapPersist_PreservesConfirmedOnUnknown(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
// Seed a confirmed active node and a suspected inactive node.
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
VALUES ('gg77', 'Golf', 'repeater', '2026-01-01T00:00:00Z', 2, 'advert')`); err != nil {
t.Fatalf("seed gg77: %v", err)
}
if _, err := store.db.Exec(`INSERT INTO inactive_nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
VALUES ('hh88', 'Hotel', 'companion', '2025-12-01T00:00:00Z', 1, 'path')`); err != nil {
t.Fatalf("seed hh88: %v", err)
}
// Snapshot has only "unknown" entries for both — must skip both.
snap := mbcapqueue.Snapshot{Entries: []mbcapqueue.Entry{
{PublicKey: "gg77", Status: "unknown"},
{PublicKey: "hh88", Status: "unknown"},
}}
if err := mbcapqueue.WriteSnapshot(dbPath, snap); err != nil {
t.Fatalf("WriteSnapshot: %v", err)
}
stats, err := store.RunMultibyteCapPersist()
if err != nil {
t.Fatalf("RunMultibyteCapPersist: %v", err)
}
if stats.Skipped != 2 {
t.Errorf("Skipped = %d, want 2 (both unknown entries)", stats.Skipped)
}
if stats.UpdatedActive != 0 || stats.UpdatedInactive != 0 {
t.Errorf("expected zero updates, got %+v", stats)
}
// Verify the existing values were NOT clobbered.
var sup int
var evid string
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM nodes WHERE public_key='gg77'`).Scan(&sup, &evid); err != nil {
t.Fatalf("read gg77: %v", err)
}
if sup != 2 || evid != "advert" {
t.Errorf("gg77 was clobbered by unknown snapshot: sup=%d evid=%q, want sup=2 evid=advert", sup, evid)
}
if err := store.db.QueryRow(`SELECT multibyte_sup, COALESCE(multibyte_evidence,'') FROM inactive_nodes WHERE public_key='hh88'`).Scan(&sup, &evid); err != nil {
t.Fatalf("read hh88: %v", err)
}
if sup != 1 || evid != "path" {
t.Errorf("hh88 was clobbered by unknown snapshot: sup=%d evid=%q, want sup=1 evid=path", sup, evid)
}
}
+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)
}
}
-97
View File
@@ -1,97 +0,0 @@
package main
import (
"testing"
)
func TestNormalizeChannelName(t *testing.T) {
tests := []struct {
input string
expected string
}{
// Known channel: "public" should be normalized to "Public"
{"public", "Public"},
{"Public", "Public"},
{"PUBLIC", "Public"},
// Hashtag channels should be left untouched
{"#LongFast", "#LongFast"},
{"#wardrive", "#wardrive"},
// Custom/unknown channels should be left untouched
{"myChannel", "myChannel"},
{"testchannel", "testchannel"},
// Empty string
{"", ""},
}
for _, tt := range tests {
got := normalizeChannelName(tt.input)
if got != tt.expected {
t.Errorf("normalizeChannelName(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}
func TestLoadChannelKeys_NormalizesKnownDisplayNames(t *testing.T) {
// Verify that known channel keys with wrong casing get normalized
cfg := &Config{
ChannelKeys: map[string]string{
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
},
}
keys := loadChannelKeys(cfg, "/dev/null")
// Should have "Public" (normalized) not "public" (raw)
if _, ok := keys["public"]; ok {
t.Error("Expected 'public' to be normalized to 'Public'")
}
if _, ok := keys["Public"]; !ok {
t.Error("Expected 'Public' key to exist in loaded channel keys")
}
}
func TestLoadChannelKeys_LeavesCustomNamesUntouched(t *testing.T) {
// Verify that custom channel names are NOT normalized
cfg := &Config{
ChannelKeys: map[string]string{
"myCustomChannel": "deadbeef12345678",
},
}
keys := loadChannelKeys(cfg, "/dev/null")
// Should keep "myCustomChannel" as-is
if _, ok := keys["myCustomChannel"]; !ok {
t.Error("Expected 'myCustomChannel' to be left untouched")
}
// Should NOT have "MyCustomChannel"
if _, ok := keys["MyCustomChannel"]; ok {
t.Error("Custom channel names should NOT be auto-capitalized")
}
}
func TestLoadChannelKeys_DuplicateCasingLogsWarning(t *testing.T) {
// Verify that config with both "public" and "Public" resolves deterministically:
// the canonical (already-normalized) form should win.
cfg := &Config{
ChannelKeys: map[string]string{
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
"Public": "differentkey1234567",
},
}
keys := loadChannelKeys(cfg, "/dev/null")
// After normalization, only one key should exist: "Public"
// The canonical form ("Public") should win over the lowercase form ("public")
if _, ok := keys["public"]; ok {
t.Error("Expected 'public' to be normalized away")
}
if _, ok := keys["Public"]; !ok {
t.Error("Expected 'Public' key to exist")
}
// Assert the canonical form's value won, not just any value
if keys["Public"] != "differentkey1234567" {
t.Errorf("Expected canonical 'Public' value to win, got %q", keys["Public"])
}
}
@@ -1,354 +0,0 @@
package main
// Regression tests for issue #1366: Channel view shows stale timestamps
// because GetChannelMessages emits tx.FirstSeen (first-observation time)
// when the operator-visible expectation is the latest observation time
// (tx.LatestSeen). For repeated heartbeat-style messages whose tx.Hash is
// stable, FirstSeen stays pinned to the very first observation while the
// real-world transmission keeps repeating, producing a multi-hour gap
// between the channel view and the operator's live MeshCore client.
//
// Server-side UTC clocks are trusted; client-reported sender_timestamp
// is NOT (firmware lacks reliable wall-clock on many builds). Therefore
// the fix uses tx.LatestSeen (== max observation timestamp), NOT
// sender_timestamp. sender_timestamp remains exposed in the response
// for debug surfaces but MUST NOT be the rendered field.
import (
"strconv"
"testing"
"time"
)
// TestChannelMessages_TimestampUsesLatestSeen: a CHAN tx with multiple
// observations spanning hours must render with the LATEST observation
// timestamp, not the first-seen ingest time.
func TestChannelMessages_TimestampUsesLatestSeen(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
firstSeen := now.Add(-7 * time.Hour).Format(time.RFC3339)
firstSeenEpoch := now.Add(-7 * time.Hour).Unix()
laterEpoch := now.Add(-5 * time.Minute).Unix()
_ = laterEpoch
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obsA', 'ObsA', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, firstSeen)
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obsB', 'ObsB', 'LAX', ?, '2026-01-01T00:00:00Z', 10)`, firstSeen)
// One transmission with two observations: T0 (7h ago) and T1 (5m ago).
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA01', 'hash_repeated_msg', ?, 1, 5,
'{"type":"CHAN","channel":"#test","text":"Heartbeat: ping","sender":"Heartbeat","sender_timestamp":` +
strconv.FormatInt(firstSeenEpoch, 10) + `}',
'#test')`, firstSeen)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 10.0, -90, '["aa"]', ?)`, firstSeenEpoch)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 11.0, -88, '["bb"]', ?)`, laterEpoch)
store := NewPacketStore(db, nil)
store.Load()
msgs, total := store.GetChannelMessages("#test", 10, 0)
if total != 1 {
t.Fatalf("want 1 msg, got %d (msgs=%+v)", total, msgs)
}
got, _ := msgs[0]["timestamp"].(string)
gotParsed, err := time.Parse(time.RFC3339, got)
if err != nil {
// Try the milli-second precision form that SQLite strftime emits.
gotParsed, err = time.Parse("2006-01-02T15:04:05.000Z", got)
if err != nil {
gotParsed, err = time.Parse("2006-01-02T15:04:05.000Z07:00", got)
}
}
if err != nil {
t.Fatalf("timestamp not parseable: %q (%v)", got, err)
}
// LatestSeen should equal the laterEpoch observation (±1s).
if delta := gotParsed.Unix() - laterEpoch; delta < -1 || delta > 1 {
t.Errorf("timestamp: want ~%s (LatestSeen, observation at T-5m), got %q (Δ=%ds — likely FirstSeen, issue #1366)",
time.Unix(laterEpoch, 0).UTC().Format(time.RFC3339), got, delta)
}
// first_seen MUST also be exposed separately so the UI/debug can see
// when the analyzer first heard the packet (older than `timestamp`).
fs, _ := msgs[0]["first_seen"].(string)
if fs == "" {
t.Errorf("first_seen field must be exposed alongside timestamp; got empty")
}
if fs == got {
t.Errorf("first_seen should differ from latest-seen timestamp (both = %q)", got)
}
}
// TestChannelMessages_TimestampNotSenderTimestamp: a CHAN tx whose
// decoded sender_timestamp is wildly off (e.g. client with bad RTC)
// must NOT cause the rendered timestamp to drift. Rendered timestamp
// must remain server UTC (LatestSeen/FirstSeen), regardless of what
// the client claimed.
func TestChannelMessages_TimestampNotSenderTimestamp(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
firstSeen := now.Add(-10 * time.Minute).Format(time.RFC3339)
firstSeenEpoch := now.Add(-10 * time.Minute).Unix()
// Client claims it sent the message in year 2000 (bad RTC).
badSenderTs := int64(946684800) // 2000-01-01 UTC
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obsX', 'ObsX', 'SJC', ?, '2026-01-01T00:00:00Z', 1)`, firstSeen)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BB01', 'hash_bad_clock', ?, 1, 5,
'{"type":"CHAN","channel":"#bad","text":"Alice: ping","sender":"Alice","sender_timestamp":` +
strconv.FormatInt(badSenderTs, 10) + `}',
'#bad')`, firstSeen)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 10.0, -90, '["aa"]', ?)`, firstSeenEpoch)
store := NewPacketStore(db, nil)
store.Load()
msgs, total := store.GetChannelMessages("#bad", 10, 0)
if total != 1 {
t.Fatalf("want 1 msg, got %d", total)
}
got, _ := msgs[0]["timestamp"].(string)
// MUST be the server-side observation time, parseable as RFC3339, and
// within ~1h of now — NOT the year-2000 client value.
parsed, err := time.Parse(time.RFC3339, got)
if err != nil {
t.Fatalf("timestamp not RFC3339: %q (%v)", got, err)
}
if parsed.Year() < now.Year() {
t.Errorf("rendered timestamp %q took on the client's bad sender_timestamp (year %d) instead of server UTC",
got, parsed.Year())
}
}
// TestChannelMessages_TimestampIsUTCZ: rendered timestamp MUST end with
// 'Z' (or +00:00) so the browser does NOT interpret it as a local-zone
// string and shift by the operator's TZ offset.
func TestChannelMessages_TimestampIsUTCZ(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
fs := now.Add(-30 * time.Minute).Format(time.RFC3339)
ep := now.Add(-30 * time.Minute).Unix()
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obsZ', 'ObsZ', 'SJC', ?, '2026-01-01T00:00:00Z', 1)`, fs)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('ZZ01', 'hash_zone_check', ?, 1, 5,
'{"type":"CHAN","channel":"#zone","text":"Carol: ping","sender":"Carol"}',
'#zone')`, fs)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 11.0, -89, '["zz"]', ?)`, ep)
store := NewPacketStore(db, nil)
store.Load()
msgs, _ := store.GetChannelMessages("#zone", 10, 0)
if len(msgs) != 1 {
t.Fatalf("want 1 msg, got %d", len(msgs))
}
ts, _ := msgs[0]["timestamp"].(string)
if ts == "" {
t.Fatal("empty timestamp")
}
n := len(ts)
if !(ts[n-1] == 'Z' || (n >= 6 && ts[n-6:] == "+00:00")) {
t.Errorf("timestamp not UTC-suffixed (Z/+00:00): %q", ts)
}
}
// TestChannelMessages_OrderedByLatestSeen: adversarial follow-up to #1366
// (PR #1368). The earlier fix only adjusted the rendered `timestamp`
// field; page SELECTION and SORT ORDER on both the in-memory and DB
// paths still used FirstSeen. This test pins the contract:
//
// - tx-A: FirstSeen 24h ago, LatestSeen NOW (via a fresh observation).
// - tx-B: FirstSeen 1h ago, LatestSeen 1h ago (single observation).
//
// Both paths MUST:
// 1. Return BOTH transmissions in a small (limit=10) page — tx-A must
// not be excluded because its FirstSeen is old.
// 2. Return tx-A AFTER tx-B (newest-LatestSeen-LAST), matching the
// tail-of-msgOrder convention used by the rest of the API and
// the frontend's scrollToBottom().
func TestChannelMessages_OrderedByLatestSeen_InMemory(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
tOld := now.Add(-24 * time.Hour)
tMid := now.Add(-1 * time.Hour)
tNewest := now.Add(-30 * time.Minute)
tFresh := now.Add(-1 * time.Minute)
tOldStr := tOld.Format(time.RFC3339)
tMidStr := tMid.Format(time.RFC3339)
tNewestStr := tNewest.Format(time.RFC3339)
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obsO', 'ObsO', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, tOldStr)
// tx-A: FirstSeen 24h ago, LatestSeen NOW (T-1m). Old insertion order.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AAAA', 'order_hash_a', ?, 1, 5,
'{"type":"CHAN","channel":"#ord","text":"Alpha: hb","sender":"Alpha"}', '#ord')`, tOldStr)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 10.0, -90, '["aa"]', ?)`, tOld.Unix())
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 11.0, -88, '["aa"]', ?)`, tFresh.Unix())
// tx-B: FirstSeen 1h ago, LatestSeen 1h ago. OLDEST.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BBBB', 'order_hash_b', ?, 1, 5,
'{"type":"CHAN","channel":"#ord","text":"Bravo: msg","sender":"Bravo"}', '#ord')`, tMidStr)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 1, 9.0, -91, '["bb"]', ?)`, tMid.Unix())
// tx-C: FirstSeen 30m ago, LatestSeen 30m ago. Middle.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('CCCC', 'order_hash_c', ?, 1, 5,
'{"type":"CHAN","channel":"#ord","text":"Charlie: msg","sender":"Charlie"}', '#ord')`, tNewestStr)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (3, 1, 9.0, -91, '["cc"]', ?)`, tNewest.Unix())
store := NewPacketStore(db, nil)
store.Load()
// Full-page: ordering check (fix #1 gates this — without sort,
// msgOrder is insertion order and Alpha lands FIRST, not LAST).
msgsAll, totalAll := store.GetChannelMessages("#ord", 10, 0)
if totalAll != 3 {
t.Fatalf("in-memory: want total=3, got %d", totalAll)
}
if len(msgsAll) != 3 {
t.Fatalf("in-memory: want 3 msgs, got %d", len(msgsAll))
}
wantOrder := []string{"Bravo", "Charlie", "Alpha"}
for i, want := range wantOrder {
got, _ := msgsAll[i]["sender"].(string)
if got != want {
t.Errorf("in-memory: msg[%d] want sender=%q, got %q (LatestSeen ASC, fix #1)", i, want, got)
}
}
// Small page (limit=2): tx-A (Alpha) MUST be included because its
// LatestSeen is freshest, even though FirstSeen is oldest. Without
// fix #1, the in-memory path takes msgOrder[total-2:] which would
// drop Alpha (it sits at msgOrder[0] by insertion order).
msgsPage, _ := store.GetChannelMessages("#ord", 2, 0)
if len(msgsPage) != 2 {
t.Fatalf("in-memory: want 2 msgs at limit=2, got %d", len(msgsPage))
}
hasAlpha := false
for _, m := range msgsPage {
if s, _ := m["sender"].(string); s == "Alpha" {
hasAlpha = true
}
}
if !hasAlpha {
t.Errorf("in-memory: tx-A (Alpha) excluded from limit=2 page — FirstSeen-based tail selection bug (fix #1 reverted?)")
}
}
func TestChannelMessages_OrderedByLatestSeen_DB(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
tOld := now.Add(-24 * time.Hour)
tMid := now.Add(-1 * time.Hour)
tNewest := now.Add(-30 * time.Minute)
tFresh := now.Add(-1 * time.Minute)
tOldStr := tOld.Format(time.RFC3339)
tMidStr := tMid.Format(time.RFC3339)
tNewestStr := tNewest.Format(time.RFC3339)
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obsD', 'ObsD', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, tOldStr)
// tx-A: FirstSeen 24h ago, observations at T-24h and T-1m (LatestSeen
// = T-1m, the FRESHEST). Despite the freshest LatestSeen, a
// FirstSeen-DESC selection would push it OFF a small page.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AADB', 'order_db_hash_a', ?, 1, 5,
'{"type":"CHAN","channel":"#ordb","text":"Alpha: hb","sender":"Alpha"}', '#ordb')`, tOldStr)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 10.0, -90, '["aa"]', ?)`, tOld.Unix())
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 11.0, -88, '["aa"]', ?)`, tFresh.Unix())
// tx-B: FirstSeen 1h ago, LatestSeen 1h ago. OLDEST LatestSeen.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BBDB', 'order_db_hash_b', ?, 1, 5,
'{"type":"CHAN","channel":"#ordb","text":"Bravo: msg","sender":"Bravo"}', '#ordb')`, tMidStr)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 1, 9.0, -91, '["bb"]', ?)`, tMid.Unix())
// tx-C: FirstSeen 30m ago, LatestSeen 30m ago. Middle LatestSeen.
// With FirstSeen-DESC selection + limit=2, page = [tx-C, tx-B] and
// tx-A is EXCLUDED — that's the selection bug fix #2 gates.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('CCDB', 'order_db_hash_c', ?, 1, 5,
'{"type":"CHAN","channel":"#ordb","text":"Charlie: msg","sender":"Charlie"}', '#ordb')`, tNewestStr)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (3, 1, 9.0, -91, '["cc"]', ?)`, tNewest.Unix())
msgs, total, err := db.GetChannelMessages("#ordb", 2, 0)
if err != nil {
t.Fatal(err)
}
if total != 3 {
t.Fatalf("DB: want total=3, got %d", total)
}
if len(msgs) != 2 {
t.Fatalf("DB: want 2 msgs in page (limit=2), got %d", len(msgs))
}
// Selection (fix #2): the page MUST include tx-A (Alpha) because its
// LatestSeen is the newest — even though its FirstSeen is the OLDEST.
// With limit=2 + LatestSeen-DESC selection, page = [Alpha, Charlie].
// Returned ASC by LatestSeen (newest LAST, fix #3) = [Charlie, Alpha].
sender0, _ := msgs[0]["sender"].(string)
sender1, _ := msgs[1]["sender"].(string)
if sender0 != "Charlie" || sender1 != "Alpha" {
t.Errorf("DB: want order [Charlie, Alpha] (page selected by LatestSeen DESC, returned ASC, fix #2+#3), got [%q, %q]",
sender0, sender1)
}
hasAlpha := false
for _, m := range msgs {
if s, _ := m["sender"].(string); s == "Alpha" {
hasAlpha = true
}
}
if !hasAlpha {
t.Errorf("DB: tx-A (Alpha) excluded from page — FirstSeen-based selection bug (fix #2 reverted?)")
}
// Also exercise large-page case (limit > total): ordering-only check.
msgsAll, totalAll, err := db.GetChannelMessages("#ordb", 10, 0)
if err != nil {
t.Fatal(err)
}
if totalAll != 3 || len(msgsAll) != 3 {
t.Fatalf("DB: want all 3 msgs at limit=10, got total=%d len=%d", totalAll, len(msgsAll))
}
// Expected ASC by LatestSeen: Bravo (T-1h), Charlie (T-30m), Alpha (T-1m).
wantOrder := []string{"Bravo", "Charlie", "Alpha"}
for i, want := range wantOrder {
got, _ := msgsAll[i]["sender"].(string)
if got != want {
t.Errorf("DB: msg[%d] want sender=%q, got %q (full order: must be LatestSeen ASC, fix #3)", i, want, got)
}
}
}
@@ -1,121 +0,0 @@
package main
import (
"database/sql"
"fmt"
"testing"
)
// Issue #1373: /api/channels emits a ghost "unknown" bucket for encrypted GRP_TXT
// packets whose decoded JSON sets channel="" (server has no PSK to decrypt).
// Fix A (cosmetic): drop the "unknown" bucket from the response so users only
// see real channels. Encrypted-no-key packets are still observable via the
// encrypted-channels analytics, just not as a fake "unknown" channel.
//
// This test seeds 5 GRP_TXT with Channel="" (encrypted-no-key) + 3 with
// Channel="#real" and asserts GetChannels returns exactly one entry, #real —
// no "unknown" bucket.
func TestGetChannels_NoUnknownBucket_1373(t *testing.T) {
packets := []*StoreTx{
makeGrpTx(129, "", "", ""),
makeGrpTx(129, "", "", ""),
makeGrpTx(129, "", "", ""),
makeGrpTx(129, "", "", ""),
makeGrpTx(129, "", "", ""),
makeGrpTx(72, "#real", "hello", "alice"),
makeGrpTx(72, "#real", "world", "bob"),
makeGrpTx(72, "#real", "third", "carol"),
}
store := newChannelTestStore(packets)
channels := store.GetChannels("")
var gotNames []string
for _, ch := range channels {
name, _ := ch["name"].(string)
gotNames = append(gotNames, name)
if name == "unknown" {
t.Errorf("GetChannels emitted ghost 'unknown' bucket (issue #1373): %+v", ch)
}
}
if len(channels) != 1 {
t.Fatalf("expected exactly 1 channel (#real), got %d: %v", len(channels), gotNames)
}
if name, _ := channels[0]["name"].(string); name != "#real" {
t.Errorf("expected channel name '#real', got %q", name)
}
if mc, _ := channels[0]["messageCount"].(int); mc != 3 {
t.Errorf("expected messageCount=3 for #real, got %v", channels[0]["messageCount"])
}
}
// TestGetChannels_DB_NoUnknownBucket_1373 mirrors the in-memory test against
// the DB-backed GetChannels path in cmd/server/db.go. It seeds GRP_TXT rows
// with channel_hash NULL (encrypted, no PSK known to ingestor) + rows with
// channel_hash="#real" and asserts the response contains only #real.
//
// Note: the DB path already filters NULL channel_hash via the SELECT (`channel_hash IS NOT NULL`),
// AND nullStr("")==empty triggers `continue` in the loop. This test pins that
// contract so a future refactor can't reintroduce an "unknown" bucket on the
// DB side either.
func TestGetChannels_DB_NoUnknownBucket_1373(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Seed 5 encrypted GRP_TXT rows with channel_hash NULL (server had no PSK).
for i := 0; i < 5; i++ {
_, err := db.conn.Exec(`INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES (?, ?, '2026-05-25T12:00:00Z', 1, 5,
'{"type":"CHAN","channel":"","text":"","sender":""}', NULL)`,
"AA", sqlHashFor(i))
if err != nil {
t.Fatalf("seed encrypted row %d: %v", i, err)
}
}
// Seed 3 decrypted GRP_TXT rows with channel_hash="#real".
for i := 0; i < 3; i++ {
_, err := db.conn.Exec(`INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES (?, ?, '2026-05-25T12:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#real","text":"Alice: hi","sender":"Alice"}', '#real')`,
"BB", sqlHashFor(100+i))
if err != nil {
t.Fatalf("seed real row %d: %v", i, err)
}
}
channels, err := db.GetChannels()
if err != nil {
t.Fatalf("GetChannels: %v", err)
}
var gotNames []string
for _, ch := range channels {
name, _ := ch["name"].(string)
gotNames = append(gotNames, name)
if name == "unknown" {
t.Errorf("DB GetChannels emitted ghost 'unknown' bucket (issue #1373): %+v", ch)
}
if name == "" {
t.Errorf("DB GetChannels emitted empty-name channel bucket (issue #1373): %+v", ch)
}
}
if len(channels) != 1 {
t.Fatalf("expected exactly 1 channel (#real), got %d: %v", len(channels), gotNames)
}
if name, _ := channels[0]["name"].(string); name != "#real" {
t.Errorf("expected channel name '#real', got %q", name)
}
}
// sqlHashFor returns a unique 16-char hex string per index for the
// `hash` UNIQUE column in transmissions.
func sqlHashFor(i int) string {
return fmt.Sprintf("%016x", uint64(0x1373_0000_0000_0000)+uint64(i))
}
// silence unused-import warning when the file is reduced.
var _ = sql.ErrNoRows
+34 -93
View File
@@ -27,9 +27,8 @@ type DB struct {
isV3 bool // v3 schema: observer_idx in observations (vs observer_id in v2)
hasResolvedPath bool // observations table has resolved_path column
hasObsRawHex bool // observations table has raw_hex column (#881)
hasScopeName bool // transmissions.scope_name column exists (#899)
hasDefaultScope bool // nodes.default_scope column exists (#899)
hasMultibyteSupCols bool // nodes/inactive_nodes have multibyte_sup/multibyte_evidence (#903)
hasScopeName bool // transmissions.scope_name column exists (#899)
hasDefaultScope bool // nodes.default_scope column exists (#899)
// Channel list cache (60s TTL) — avoids repeated GROUP BY scans (#762)
channelsCacheMu sync.Mutex
@@ -122,11 +121,8 @@ func (db *DB) detectSchema() {
var notNull, pk int
var dflt sql.NullString
if nodeRows.Scan(&cid, &colName, &colType, &notNull, &dflt, &pk) == nil {
switch colName {
case "default_scope":
if colName == "default_scope" {
db.hasDefaultScope = true
case "multibyte_sup":
db.hasMultibyteSupCols = true
}
}
}
@@ -497,14 +493,8 @@ func (db *DB) QueryPackets(q PacketQuery) (*PacketResult, error) {
db.conn.QueryRow(countSQL, args...).Scan(&total)
}
// #1345: order by ingest id, NOT first_seen. PR #1233 made first_seen=rxTime,
// so buffered-then-uploaded observer packets with hours-old rxTime were
// sorting to the top/middle and hiding fresh ingest. Ordering by id keeps
// "latest activity" semantically equal to "what we ingested last" — which
// is what the packets page is showing. The `since=` filter still uses
// first_seen / observation timestamp, preserving "received-by-radio since X."
selectCols, observerJoin := db.transmissionBaseSQL()
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.id %s LIMIT ? OFFSET ?",
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.first_seen %s LIMIT ? OFFSET ?",
selectCols, observerJoin, w, q.Order)
qArgs := make([]interface{}, len(args))
@@ -1023,10 +1013,7 @@ func (db *DB) GetRecentTransmissionsForNode(pubkey string, limit int) ([]map[str
selectCols, observerJoin := db.transmissionBaseSQL()
// #1345: order by ingest id, not first_seen (=rxTime). Buffered observer
// uploads with old rxTime would otherwise displace fresh activity from
// the "recent transmissions for node" list.
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.from_pubkey = ? ORDER BY t.id DESC LIMIT ?",
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.from_pubkey = ? ORDER BY t.first_seen DESC LIMIT ?",
selectCols, observerJoin)
args := []interface{}{pubkey, limit}
@@ -1646,38 +1633,27 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
return nil, 0, err
}
// 2) Page of transmission IDs — newest LIMIT msgs minus OFFSET.
// Issue #1366 follow-up (fix #2): select page by latest observation
// timestamp (LatestSeen) DESC, NOT by t.first_seen DESC — otherwise
// a heartbeat tx whose FirstSeen is 24h old but whose latest
// observation is fresh gets pushed off page 1.
//
// PR #1368 perf fix: use a correlated subquery for MAX(timestamp) per
// transmission. With the composite index idx_observations_tx_ts
// (transmission_id, timestamp) sqlite resolves MAX as an index-only
// rightmost-leaf lookup — total O(N_tx · log N_obs). The previously-
// used grouped derived table (`GROUP BY transmission_id` over the
// whole observations table) scanned all observation rows (O(N_obs))
// and blew the 1.5s perf budget on 1500 tx × 50 obs under -race.
// LEFT JOIN + GROUP BY t.id was even slower because GROUP BY forced
// a temp B-tree on the full transmissions×observations join.
//
// The returned page is in newest-LatestSeen-FIRST (DESC) order.
// The Go side re-orders the emitted rows ASC below (fix #3) so the
// contract matches the in-memory path's tail-of-msgOrder convention.
pageSQL := `SELECT t.id,
COALESCE((SELECT MAX(timestamp) FROM observations WHERE transmission_id = t.id), 0) AS latest_obs_epoch
FROM transmissions t
WHERE t.channel_hash = ? AND t.payload_type = 5
ORDER BY latest_obs_epoch DESC, t.id DESC
LIMIT ? OFFSET ?`
// 2) Page of transmission IDs — newest LIMIT msgs minus OFFSET, returned
// in ASC order to match prior API contract (tail of message log).
pageSQL := `SELECT t.id FROM (
SELECT id FROM transmissions
WHERE channel_hash = ? AND payload_type = 5
ORDER BY first_seen DESC
LIMIT ? OFFSET ?
) t`
// When a region filter is in play, we must filter on the inner subquery
// against the transmissions table — re-use the same EXISTS form but
// wrap so we still get DESC-then-ASC pagination.
if len(regionCodes) > 0 {
pageSQL = `SELECT t.id,
COALESCE((SELECT MAX(timestamp) FROM observations WHERE transmission_id = t.id), 0) AS latest_obs_epoch
FROM transmissions t
WHERE t.channel_hash = ? AND t.payload_type = 5` + regionFilter + `
ORDER BY latest_obs_epoch DESC, t.id DESC
LIMIT ? OFFSET ?`
pageSQL = `SELECT id FROM (
SELECT t.id, t.first_seen FROM transmissions t
WHERE t.channel_hash = ? AND t.payload_type = 5` + regionFilter + `
ORDER BY t.first_seen DESC
LIMIT ? OFFSET ?
) sub
ORDER BY first_seen ASC`
} else {
pageSQL += ` ORDER BY (SELECT first_seen FROM transmissions WHERE id = t.id) ASC`
}
pageArgs := []interface{}{channelHash}
pageArgs = append(pageArgs, regionArgs...)
@@ -1690,8 +1666,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
pageIDs := make([]int, 0, limit)
for idRows.Next() {
var id int
var le sql.NullInt64
if err := idRows.Scan(&id, &le); err == nil {
if err := idRows.Scan(&id); err == nil {
pageIDs = append(pageIDs, id)
}
}
@@ -1713,7 +1688,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
var obsSQL string
if db.isV3 {
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
obs.id, obs.name, o.snr, o.path_json, o.timestamp
obs.id, obs.name, o.snr, o.path_json
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
@@ -1721,7 +1696,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
ORDER BY o.id ASC`
} else {
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp
o.observer_id, o.observer_name, o.snr, o.path_json
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `)
@@ -1735,9 +1710,8 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
defer rows.Close()
type msg struct {
Data map[string]interface{}
Repeats int
LatestEpoch int64 // max observation timestamp (unix seconds) — issue #1366
Data map[string]interface{}
Repeats int
}
msgMap := make(map[int]*msg, len(pageIDs))
@@ -1745,16 +1719,12 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
var pktID, txID int
var pktHash, dj, fs, obsID, obsName, pathJSON sql.NullString
var snr sql.NullFloat64
var obsTs sql.NullInt64
rows.Scan(&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON, &obsTs)
rows.Scan(&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON)
if !dj.Valid {
continue
}
if existing, ok := msgMap[txID]; ok {
existing.Repeats++
if obsTs.Valid && obsTs.Int64 > existing.LatestEpoch {
existing.LatestEpoch = obsTs.Int64
}
continue
}
var decoded map[string]interface{}
@@ -1789,7 +1759,6 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
"sender": displaySender,
"text": displayText,
"timestamp": nullStr(fs),
"first_seen": nullStr(fs),
"sender_timestamp": senderTs,
"packetId": pktID,
"packetHash": nullStr(pktHash),
@@ -1800,9 +1769,6 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
},
Repeats: 1,
}
if obsTs.Valid {
m.LatestEpoch = obsTs.Int64
}
if obsName.Valid {
m.Data["observers"] = []string{obsName.String}
} else if obsID.Valid {
@@ -1811,16 +1777,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
msgMap[txID] = m
}
// Issue #1366 follow-up: emit batch sorted by LatestSeen ascending
// (newest LAST) — matches the in-memory path's tail-of-msgOrder
// convention and the frontend's scrollToBottom() behavior. pageIDs
// order is not LatestSeen-ordered for in-page rows after fix #2.
type emitted struct {
latestEpoch int64
txID int
data map[string]interface{}
}
rowsOut := make([]emitted, 0, len(pageIDs))
messages := make([]map[string]interface{}, 0, len(pageIDs))
for _, id := range pageIDs {
m, ok := msgMap[id]
if !ok {
@@ -1830,22 +1787,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
continue
}
m.Data["repeats"] = m.Repeats
// Issue #1366: emit LatestSeen (max obs timestamp) as the rendered
// `timestamp` field. `first_seen` stays alongside for debug.
if m.LatestEpoch > 0 {
m.Data["timestamp"] = time.Unix(m.LatestEpoch, 0).UTC().Format(time.RFC3339)
}
rowsOut = append(rowsOut, emitted{latestEpoch: m.LatestEpoch, txID: id, data: m.Data})
}
sort.SliceStable(rowsOut, func(i, j int) bool {
if rowsOut[i].latestEpoch != rowsOut[j].latestEpoch {
return rowsOut[i].latestEpoch < rowsOut[j].latestEpoch
}
return rowsOut[i].txID < rowsOut[j].txID
})
messages := make([]map[string]interface{}, 0, len(rowsOut))
for _, e := range rowsOut {
messages = append(messages, e.data)
messages = append(messages, m.Data)
}
return messages, total, nil
@@ -2026,8 +1968,7 @@ func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order,
db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM transmissions t %s", w), args...).Scan(&total)
selectCols, observerJoin := db.transmissionBaseSQL()
// #1345: order by ingest id (see QueryPackets comment above).
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.id %s LIMIT ? OFFSET ?",
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s %s ORDER BY t.first_seen %s LIMIT ? OFFSET ?",
selectCols, observerJoin, w, order)
qArgs := make([]interface{}, len(args))
-10
View File
@@ -120,16 +120,6 @@ func setupTestDB(t *testing.T) *DB {
WHERE id = NEW.id;
END;
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
-- Mirror prod indexes from internal/dbschema/dbschema.go so query plans
-- in tests match prod. idx_observations_transmission_id is required by
-- GetChannelMessages's grouped MAX(timestamp) per tx aggregate
-- (issue #1366 / PR #1368): without it the perf test on 1500 tx × 50 obs
-- blows the 1.5s budget under -race.
CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp);
CREATE INDEX IF NOT EXISTS idx_observations_tx_ts ON observations(transmission_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_transmissions_channel_hash ON transmissions(channel_hash);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
-4
View File
@@ -45,7 +45,3 @@ require (
require github.com/meshcore-analyzer/prunequeue v0.0.0
replace github.com/meshcore-analyzer/prunequeue => ../../internal/prunequeue
require github.com/meshcore-analyzer/mbcapqueue v0.0.0
replace github.com/meshcore-analyzer/mbcapqueue => ../../internal/mbcapqueue
-95
View File
@@ -433,98 +433,3 @@ func TestMultiByteCapability_AdopterEvidenceTakesPrecedence(t *testing.T) {
t.Errorf("with adopter data: expected advert evidence, got %s", capByName["RepAdopter"].Evidence)
}
}
// --- Persistence layer tests (#903, relocated #1324 follow-up) ---
//
// The actual DB persistence now lives in cmd/ingestor (see
// cmd/ingestor/multibyte_persist_test.go). What the server is responsible
// for is publishing the snapshot file that the ingestor consumes. The
// data-destruction guard ("never overwrite confirmed with unknown") is
// enforced by the ingestor, not the server — the snapshot can legitimately
// carry "unknown" entries; the ingestor filters them.
// setupPersistTestDB creates an in-memory DB with multibyte_sup/multibyte_evidence columns.
func setupPersistTestDB(t *testing.T) *DB {
t.Helper()
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
conn.SetMaxOpenConns(1)
conn.Exec(`CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL,
foreign_advert INTEGER DEFAULT 0, default_scope TEXT,
multibyte_sup INTEGER NOT NULL DEFAULT 0, multibyte_evidence TEXT
)`)
conn.Exec(`CREATE TABLE inactive_nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL,
foreign_advert INTEGER DEFAULT 0, default_scope TEXT,
multibyte_sup INTEGER NOT NULL DEFAULT 0, multibyte_evidence TEXT
)`)
return &DB{conn: conn, hasMultibyteSupCols: true}
}
// TestMultibyteCapGetMultibyteCapForO1 verifies that GetMultibyteCapFor returns
// the correct entry via the O(1) mbCapIndex map.
func TestMultibyteCapGetMultibyteCapForO1(t *testing.T) {
db := setupPersistTestDB(t)
store := NewPacketStore(db, nil)
// Directly populate the index as the analytics cycle would.
store.cacheMu.Lock()
store.mbCapIndex = map[string]MultiByteCapEntry{
"aabbccdd11223344": {PublicKey: "aabbccdd11223344", Status: "confirmed", Evidence: "advert"},
"eeff001122334455": {PublicKey: "eeff001122334455", Status: "suspected", Evidence: "path"},
}
store.cacheMu.Unlock()
e, ok := store.GetMultibyteCapFor("aabbccdd11223344")
if !ok || e == nil {
t.Fatal("expected entry for known pubkey, got none")
}
if e.Status != "confirmed" {
t.Errorf("status = %q, want confirmed", e.Status)
}
_, ok = store.GetMultibyteCapFor("0000000000000000")
if ok {
t.Error("expected no entry for unknown pubkey")
}
}
// TestMultibyteCapLoadFromDB verifies that loadMultibyteCapFromDB skips nodes
// with multibyte_sup == 0 and only loads confirmed/suspected entries.
func TestMultibyteCapLoadFromDB(t *testing.T) {
db := setupPersistTestDB(t)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
VALUES ('aa11', 'A', 'repeater', '2026-01-01T00:00:00Z', 2, 'advert')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup, multibyte_evidence)
VALUES ('bb22', 'B', 'repeater', '2026-01-01T00:00:00Z', 1, 'path')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, multibyte_sup)
VALUES ('cc33', 'C', 'repeater', '2026-01-01T00:00:00Z', 0)`) // unknown — must be skipped
store := NewPacketStore(db, nil)
store.loadMultibyteCapFromDB()
store.cacheMu.Lock()
snap := store.mbCapSnapshot
idx := store.mbCapIndex
store.cacheMu.Unlock()
if len(snap) != 2 {
t.Fatalf("expected 2 entries (confirmed+suspected), got %d", len(snap))
}
if e, ok := idx["aa11"]; !ok || e.Status != "confirmed" {
t.Errorf("aa11: expected confirmed, got %+v", e)
}
if e, ok := idx["bb22"]; !ok || e.Status != "suspected" {
t.Errorf("bb22: expected suspected, got %+v", e)
}
if _, ok := idx["cc33"]; ok {
t.Error("cc33 with sup=0 should not be in the index")
}
}
-114
View File
@@ -1,114 +0,0 @@
package main
import (
"testing"
"time"
)
// TestQueryPacketsOrdersByIngestID is the regression test for issue #1345.
//
// PR #1233 changed `first_seen` to be the observer's receive time (rxTime),
// not the moment the server ingested the row. When an observer buffers
// offline and uploads hours later, its packets land with old first_seen
// values. The /api/packets handler previously ordered by
// `first_seen DESC`, so buffered uploads with old rxTime appeared at the
// bottom while older-ingested packets with newer rxTime took the top —
// users on the packets page saw "no recent activity" even though MQTT
// ingest was active.
//
// Fix: default ordering for /api/packets is `t.id DESC` (ingest order).
// This test inserts two rows where row order by id and order by
// first_seen DISAGREE, then asserts the result is ordered by id DESC.
func TestQueryPacketsOrdersByIngestID(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
// Row A: ingested FIRST (lower id), rxTime "newer" (fresher first_seen)
freshFirstSeen := now.Add(-1 * time.Hour).Format(time.RFC3339)
// Row B: ingested SECOND (higher id), rxTime "older" — simulating a
// buffered observer upload that arrived after row A but contains a
// packet the radio received hours earlier.
bufferedFirstSeen := now.Add(-6 * time.Hour).Format(time.RFC3339)
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type)
VALUES ('AA', 'hashfresh00000001', ?, 4)`, freshFirstSeen); err != nil {
t.Fatal(err)
}
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type)
VALUES ('BB', 'hashbuffered00002', ?, 4)`, bufferedFirstSeen); err != nil {
t.Fatal(err)
}
result, err := db.QueryPackets(PacketQuery{Limit: 50, Order: "DESC"})
if err != nil {
t.Fatal(err)
}
if len(result.Packets) != 2 {
t.Fatalf("expected 2 packets, got %d", len(result.Packets))
}
// With first_seen DESC (the bug), the order would be [fresh, buffered]
// because the fresh row has the newer rxTime. With the fix (id DESC),
// order is [buffered, fresh] because the buffered row was ingested
// second and has the higher id.
first, _ := result.Packets[0]["hash"].(string)
second, _ := result.Packets[1]["hash"].(string)
if first != "hashbuffered00002" || second != "hashfresh00000001" {
t.Errorf("expected order [buffered, fresh] by ingest id DESC, got [%s, %s]",
first, second)
}
}
// TestQueryPacketsSinceFilterUsesFirstSeen documents the chosen semantic for
// the `since=` query param: it still filters by `first_seen` (radio receive
// time), NOT by ingest time. Rationale: callers using `since=` expect
// "packets the network received since X" — buffered uploads of older
// packets should still be EXCLUDED from a `since=15min` view even if
// they were ingested in the last 15 minutes. Display order is by ingest
// id (issue #1345 fix); filter semantic is unchanged.
func TestQueryPacketsSinceFilterUsesFirstSeen(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
now := time.Now().UTC()
recent := now.Add(-30 * time.Minute).Format(time.RFC3339)
old := now.Add(-6 * time.Hour).Format(time.RFC3339)
sinceCutoff := now.Add(-1 * time.Hour).Format(time.RFC3339)
recentEpoch := now.Add(-30 * time.Minute).Unix()
oldEpoch := now.Add(-6 * time.Hour).Unix()
if _, err := db.conn.Exec(`INSERT INTO observers (id, name, last_seen, first_seen, packet_count)
VALUES ('obs1', 'Obs1', ?, ?, 1)`, recent, recent); err != nil {
t.Fatal(err)
}
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type)
VALUES ('AA', 'recentrx00000001', ?, 4)`, recent); err != nil {
t.Fatal(err)
}
// Buffered upload — ingested SECOND, but rxTime is 6h ago.
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type)
VALUES ('BB', 'oldrxbuffered001', ?, 4)`, old); err != nil {
t.Fatal(err)
}
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 10, -90, '[]', ?)`, recentEpoch); err != nil {
t.Fatal(err)
}
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (2, 1, 10, -90, '[]', ?)`, oldEpoch); err != nil {
t.Fatal(err)
}
result, err := db.QueryPackets(PacketQuery{Limit: 50, Order: "DESC", Since: sinceCutoff})
if err != nil {
t.Fatal(err)
}
if len(result.Packets) != 1 {
t.Fatalf("since= should filter by first_seen (rxTime); expected 1 packet, got %d",
len(result.Packets))
}
h, _ := result.Packets[0]["hash"].(string)
if h != "recentrx00000001" {
t.Errorf("expected the rxTime-recent packet, got %s", h)
}
}
@@ -1,339 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
)
// collisionScenario captures the shared fixture state used by every #1352
// sub-test: 3 nodes sharing the 2-char "c0" prefix, plus a wired-up
// server + router ready to serve /api/nodes/{pk}/paths.
type collisionScenario struct {
srv *Server
db *DB
router *mux.Router
nodeAPK string
nodeBPK string
nodeCPK string
recent string
recentEpoch int64
}
// mustExec runs db.conn.Exec and fails the test on error. Used so INSERT
// failures (schema drift, NOT NULL violations) surface as test failures
// rather than silently producing an empty database that lets later
// assertions pass vacuously (#1352 round-1 adv #2).
func mustExec(t *testing.T, db *DB, query string, args ...any) {
t.Helper()
if _, err := db.conn.Exec(query, args...); err != nil {
t.Fatalf("Exec failed: %v\n query: %s\n args: %v", err, query, args)
}
}
// setupCollisionScenario wires up the shared #1352 fixture: 3 "c0"-prefix
// nodes with configurable GPS, a Server + PacketStore + router. Caller
// inserts transmissions/observations and queries via s.query.
func setupCollisionScenario(t *testing.T, withGPS bool) *collisionScenario {
t.Helper()
db := setupTestDB(t)
recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
recentEpoch := time.Now().Add(-1 * time.Hour).Unix()
sc := &collisionScenario{
db: db,
nodeAPK: "c0dedad42222aaaa",
nodeBPK: "c0ffeec733333333",
nodeCPK: "c0efb77f44444444",
recent: recent,
recentEpoch: recentEpoch,
}
// GPS placement: when withGPS=true, ALL three siblings have distinct
// GPS points (worst-case for the biased resolver, see fallback test).
// When withGPS=false, only B has GPS (canonical-branch test).
aLat, aLon := 0.0, 0.0
bLat, bLon := 37.79, -122.41
cLat, cLon := 0.0, 0.0
if withGPS {
aLat, aLon = 37.78, -122.40
cLat, cLon = 37.50, -122.00
}
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, 'NodeA', 'repeater', ?, ?, ?, '2026-01-01', 1)`, sc.nodeAPK, aLat, aLon, recent)
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, 'NodeB', 'repeater', ?, ?, ?, '2026-01-01', 1)`, sc.nodeBPK, bLat, bLon, recent)
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, 'NodeC', 'repeater', ?, ?, ?, '2026-01-01', 1)`, sc.nodeCPK, cLat, cLon, recent)
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
sc.srv = srv
// store is wired after observations are inserted, by reloadStore().
return sc
}
// reloadStore (re)builds the PacketStore from the current DB state. Must
// be called AFTER all transmissions/observations are inserted, otherwise
// the store snapshot is empty and queries return nothing.
func (sc *collisionScenario) reloadStore(t *testing.T) {
t.Helper()
store := NewPacketStore(sc.db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load: %v", err)
}
sc.srv.store = store
router := mux.NewRouter()
sc.srv.RegisterRoutes(router)
sc.router = router
}
// query issues GET /api/nodes/{pk}/paths and returns the decoded response.
func (sc *collisionScenario) query(t *testing.T, pk string) NodePathsResponse {
t.Helper()
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/paths", nil)
w := httptest.NewRecorder()
sc.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /paths for %s: code=%d body=%s", pk, w.Code, w.Body.String())
}
var resp NodePathsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return resp
}
// TestHandleNodePaths_PrefixCollision_1352 reproduces issue #1352.
//
// Setup: 3 nodes share 2-char prefix "c0":
//
// A = c0dedad4... (no GPS)
// B = c0ffeec7... (HAS GPS @ SF) — canonical relay per resolved_path
// C = c0efb77f... (no GPS)
//
// A packet observed with raw path ["c0"] has a CANONICAL resolved_path
// that names B (c0ffeec7…) — produced by the hop-disambiguator using
// observer context. The query for paths-through-X must use the canonical
// resolved_path to decide membership, NOT a naive prefix lookup.
//
// Only B is in the canonical resolved_path; only paths-through-B
// must include the tx. paths-through-A and paths-through-C must exclude it.
func TestHandleNodePaths_PrefixCollision_1352(t *testing.T) {
sc := setupCollisionScenario(t, false /* only B has GPS */)
mustExec(t, sc.db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (42, 'DEAD', 'hash_1352', ?)`, sc.recent)
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
VALUES (42, NULL, '["c0"]', ?, ?)`, sc.recentEpoch, `["`+sc.nodeBPK+`"]`)
sc.reloadStore(t)
respA := sc.query(t, sc.nodeAPK)
respB := sc.query(t, sc.nodeBPK)
respC := sc.query(t, sc.nodeCPK)
// A and C are NOT in the canonical resolved_path → must be excluded.
if respA.TotalTransmissions != 0 {
t.Errorf("nodeA (c0dedad…) paths-through: canonical resolved_path names B, not A — "+
"expected 0 transmissions, got %d (wrong-node attribution #1352)",
respA.TotalTransmissions)
}
if respC.TotalTransmissions != 0 {
t.Errorf("nodeC (c0efb77…) paths-through: canonical resolved_path names B, not C — "+
"expected 0 transmissions, got %d (wrong-node attribution #1352)",
respC.TotalTransmissions)
}
// B IS named by the canonical resolved_path → must be included.
if respB.TotalTransmissions != 1 {
t.Errorf("nodeB (c0ffeec…) paths-through: B is canonical relay — "+
"expected 1 transmission, got %d", respB.TotalTransmissions)
}
}
// TestHandleNodePaths_PrefixCollision_1352_FallbackBranch covers the
// worse case: obs has NO persisted resolved_path. The OLD fallback branch
// invoked pm.resolveWithContext(hop, []string{lowerPK}, graph) — anchoring
// the resolver on the queried node. Tier-2 (geo_proximity) then picked
// the GPS candidate closest to the centroid of context (== the target
// itself when the target has GPS), causing every paths-through-X query
// that shared the prefix to return the tx with X attribution.
//
// Fix: with multiple "c0" candidates and no SQL/index pre-confirmation,
// the colliders must sum to AT MOST 1 (ideally 0). Old buggy code:
// all three = 3. Fixed: ≤1, and we tighten further to ≤1 explicitly.
func TestHandleNodePaths_PrefixCollision_1352_FallbackBranch(t *testing.T) {
sc := setupCollisionScenario(t, true /* all three have GPS */)
mustExec(t, sc.db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (43, 'BEEF', 'hash_1352_fb', ?)`, sc.recent)
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
VALUES (43, NULL, '["c0"]', ?, NULL)`, sc.recentEpoch)
sc.reloadStore(t)
a := sc.query(t, sc.nodeAPK).TotalTransmissions
b := sc.query(t, sc.nodeBPK).TotalTransmissions
c := sc.query(t, sc.nodeCPK).TotalTransmissions
sum := a + b + c
// Old buggy code: a==1 && b==1 && c==1 → sum==3 (wrong-node attribution
// on all). Fixed: sum ∈ {0, 1}. Asserting sum ≤ 1 catches the degenerate
// "all zero" implementation as legitimate (it IS legitimate — ambiguous
// hops with no SQL confirmation must be excluded) while still rejecting
// the bug. The positive case (sum==1 when unambiguous) is covered by
// the canonical sub-test above and by FallbackUniquePrefix below.
if sum > 1 {
t.Errorf("ambiguous-prefix tx with NULL resolved_path attributed to %d nodes total (A=%d B=%d C=%d); "+
"expected sum ≤ 1 — paths-through must not return the same tx for multiple sibling prefix collisions (#1352)",
sum, a, b, c)
}
}
// TestHandleNodePaths_FallbackUniquePrefix_1352 is the POSITIVE companion
// to FallbackBranch: a hop prefix that has EXACTLY ONE candidate node MUST
// attribute the tx when that hop resolves to the queried target.
//
// Without this test, the "all zero" degenerate implementation passes the
// ≤1 fallback assertion vacuously. This locks in that the
// `len(pm.m[lowerHop]) <= 1` guard does NOT over-reject unique prefixes.
//
// Setup: only ONE node has the prefix "ab". NULL resolved_path so we take
// the fallback branch. paths-through-target MUST include exactly 1 tx.
func TestHandleNodePaths_FallbackUniquePrefix_1352(t *testing.T) {
db := setupTestDB(t)
recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
recentEpoch := time.Now().Add(-1 * time.Hour).Unix()
pk := "abcdef0123456789"
mustExec(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, 'UniqueNode', 'repeater', 37.78, -122.4, ?, '2026-01-01', 1)`, pk, recent)
mustExec(t, db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (44, 'CAFE', 'hash_1352_unique', ?)`, recent)
mustExec(t, db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
VALUES (44, NULL, '["ab"]', ?, NULL)`, recentEpoch)
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/"+pk+"/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 resp.TotalTransmissions != 1 {
t.Errorf("unique-prefix hop with NULL resolved_path: target attribution "+
"MUST be exactly 1, got %d — `len(pm.m[lowerHop]) <= 1` guard is "+
"over-rejecting unambiguous prefixes (#1352)", resp.TotalTransmissions)
}
}
// TestHandleNodePaths_FallbackPreconfirmed_1352 exercises the
// pre-confirmation path: when a tx is in confirmedByFullKey OR
// confirmedBySQL for the queried target, attribution MUST survive
// regardless of any sibling-prefix ambiguity.
//
// Mutation note (pushback recorded in PR body): in the current
// code shape, containsTarget is initialized to
// `confirmedByFullKey[tx.ID] || confirmedBySQL[tx.ID]` BEFORE the
// per-hop loop runs, and the loop only ever flips false→true. So
// removing the `preconfirmed ||` clause alone does not break this
// test — the preconfirmed tx is already attributed via the
// initialization. The `preconfirmed` snapshot is kept as a
// structural invariant (see routes.go comment): it documents the
// contract that the SQL/index signal must NEVER be silently
// overridden by a biased-resolver false-negative in a future edit
// that flips containsTarget back to false inside the loop. This
// test guards the BEHAVIOR ("preconfirmed survives ambiguous
// prefix") even if it can't currently mutation-detect every
// formulation of the structural guard.
func TestHandleNodePaths_FallbackPreconfirmed_1352(t *testing.T) {
sc := setupCollisionScenario(t, true /* all three have GPS so resolver bias is maximal */)
// tx 50: best obs has NULL resolved_path (fallback branch). A SECOND
// obs persists resolved_path = [B] which populates the byPathHop index
// for B's full pubkey AND lets confirmedBySQL hit via INSTR.
mustExec(t, sc.db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (50, 'F00D', 'hash_1352_pre', ?)`, sc.recent)
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
VALUES (50, NULL, '["c0"]', ?, NULL)`, sc.recentEpoch)
// Second observation (different observer) — same tx, persisted resolved_path = [B].
// This populates byPathHop[B] during Load(), so confirmedByFullKey is true
// when paths-through-B is queried.
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
VALUES (50, 1, '["c0"]', ?, ?)`, sc.recentEpoch+1, `["`+sc.nodeBPK+`"]`)
sc.reloadStore(t)
respA := sc.query(t, sc.nodeAPK)
respB := sc.query(t, sc.nodeBPK)
respC := sc.query(t, sc.nodeCPK)
// B is preconfirmed by SQL/index → tx survives the collision guard.
if respB.TotalTransmissions != 1 {
t.Errorf("nodeB preconfirmed via byPathHop/SQL: tx MUST attribute despite "+
"multi-candidate `c0` prefix — got %d, expected 1. The SQL/index "+
"pre-confirmation path is the documented contract for #1352. "+
"If this fails, either the byPathHop full-pubkey index is not being "+
"populated from persisted resolved_path, or containsTarget is being "+
"reset inside the per-hop loop.", respB.TotalTransmissions)
}
// A and C are NOT preconfirmed and the prefix IS ambiguous → excluded.
if respA.TotalTransmissions != 0 {
t.Errorf("nodeA not preconfirmed, prefix ambiguous: expected 0, got %d", respA.TotalTransmissions)
}
if respC.TotalTransmissions != 0 {
t.Errorf("nodeC not preconfirmed, prefix ambiguous: expected 0, got %d", respC.TotalTransmissions)
}
}
// TestHandleNodePaths_FallbackUnresolvableHop_1352 documents the
// behavior of the unresolvable-hop arm under multi-candidate prefix:
// when resolveHop returns nil (prefix not indexed by pm) AND the hop
// IS a prefix of the queried target, attribution must NOT happen
// without SQL/index pre-confirmation.
//
// Implementation reality (pushback recorded in PR body): the
// unresolvable arm is only reached when pm.m[lowerHop] is empty —
// resolveWithContext returns non-nil whenever len(candidates) >= 1.
// So in practice the arm's `len(pm.m[lowerHop]) <= 1` guard is
// always-true and structurally cannot be mutation-detected by a
// multi-candidate setup. This test instead asserts the BEHAVIOR
// (no attribution under an ambiguous + unresolvable scenario)
// and serves as a regression seat-belt for future edits to
// resolveWithContext that might start returning nil for len>=1.
func TestHandleNodePaths_FallbackUnresolvableHop_1352(t *testing.T) {
sc := setupCollisionScenario(t, false /* only B has GPS */)
mustExec(t, sc.db, `INSERT INTO transmissions (id, raw_hex, hash, first_seen) VALUES (60, 'FEED', 'hash_1352_unres', ?)`, sc.recent)
mustExec(t, sc.db, `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp, resolved_path)
VALUES (60, NULL, '["c0"]', ?, NULL)`, sc.recentEpoch)
sc.reloadStore(t)
// Query A (no GPS): biased resolver in the fallback branch picks B via
// tier-3 GPS preference; B's pubkey != A's lowerPK so the resolvable
// arm's pubkey-match condition fails. Either way: NOT attributed to A.
respA := sc.query(t, sc.nodeAPK)
if respA.TotalTransmissions != 0 {
t.Errorf("nodeA (no GPS) with multi-candidate `c0` prefix + NULL resolved_path: "+
"expected 0 attribution, got %d (#1352)", respA.TotalTransmissions)
}
respC := sc.query(t, sc.nodeCPK)
if respC.TotalTransmissions != 0 {
t.Errorf("nodeC (no GPS) with multi-candidate `c0` prefix + NULL resolved_path: "+
"expected 0 attribution, got %d (#1352)", respC.TotalTransmissions)
}
}
-34
View File
@@ -29,14 +29,6 @@ func TestServerSourceHasNoCachedRWCalls(t *testing.T) {
regexp.MustCompile(`\bcachedRW\s*\(`),
regexp.MustCompile(`mode=rw`),
regexp.MustCompile(`sql\.Open\([^)]*\?[^)]*_journal_mode=WAL[^)]*\)`),
// #1324 follow-up: PR #903's persistMultibyteCapability moved
// to cmd/ingestor — the server may NEVER UPDATE these columns
// (it opens mode=ro since #1289). Server publishes a snapshot
// file via internal/mbcapqueue; the ingestor applies it.
regexp.MustCompile(`UPDATE\s+nodes\s+SET\s+multibyte_`),
regexp.MustCompile(`UPDATE\s+inactive_nodes\s+SET\s+multibyte_`),
regexp.MustCompile(`\bpersistMultibyteCapability\s*\(`),
regexp.MustCompile(`\bmaybePersistMultibyteCapability\s*\(`),
}
violations := []string{}
for _, e := range entries {
@@ -86,12 +78,6 @@ func TestServerDBHasNoWriteMethods(t *testing.T) {
// ingestor's *Store. The server's HTTP handler now enqueues a
// marker file (see internal/prunequeue); it does not write.
"DeleteNodesByPubkeys",
// #1324 follow-up: PR #903 originally added these to *PacketStore
// (not *DB), and they UPDATEd nodes/inactive_nodes from a
// mode=ro handle. After relocation, the methods live in the
// ingestor's *Store (cmd/ingestor/multibyte_persist.go). Server
// must expose neither on *DB nor on *PacketStore — see the
// dedicated test below for *PacketStore.
}
typ := reflect.TypeOf((*DB)(nil))
for _, name := range forbidden {
@@ -144,23 +130,3 @@ func bootstrapMinimalDB(path string) error {
}
return nil
}
// TestPacketStoreHasNoMultibytePersistMethods enforces the #1324 follow-up:
// PR #903 wired persistMultibyteCapability + maybePersistMultibyteCapability
// onto *PacketStore in cmd/server. Both executed UPDATEs on
// nodes/inactive_nodes from a mode=ro DB handle — impossible since #1289.
// After relocation the persistence lives in cmd/ingestor/*Store; the
// server only publishes a snapshot via internal/mbcapqueue. This test
// fails if a future change re-introduces these methods on *PacketStore.
func TestPacketStoreHasNoMultibytePersistMethods(t *testing.T) {
forbidden := []string{
"persistMultibyteCapability",
"maybePersistMultibyteCapability",
}
typ := reflect.TypeOf((*PacketStore)(nil))
for _, name := range forbidden {
if _, ok := typ.MethodByName(name); ok {
t.Errorf("server *PacketStore exposes forbidden write method %q — must be relocated to ingestor (#1324)", name)
}
}
}
+8 -68
View File
@@ -1186,6 +1186,7 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
}
if s.store != nil {
hashInfo := s.store.GetNodeHashSizeInfo()
mbCap := s.store.GetMultiByteCapMap()
relayWindow := s.cfg.GetHealthThresholds().RelayActiveHours
// #1257: bulk-compute relay info + usefulness scores ONCE per
// request (cached 15s) instead of calling the per-node helpers
@@ -1212,8 +1213,7 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
for _, node := range nodes {
if pk, ok := node["public_key"].(string); ok {
EnrichNodeWithHashSize(node, hashInfo[pk])
mbEntry, _ := s.store.GetMultibyteCapFor(pk)
EnrichNodeWithMultiByte(node, mbEntry)
EnrichNodeWithMultiByte(node, mbCap[pk])
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
info, _ := lookupRelayInfo(relayMap, pk)
info.WindowHours = relayWindow
@@ -1358,8 +1358,8 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
if s.store != nil {
hashInfo := s.store.GetNodeHashSizeInfo()
EnrichNodeWithHashSize(node, hashInfo[pubkey])
mbEntry, _ := s.store.GetMultibyteCapFor(pubkey)
EnrichNodeWithMultiByte(node, mbEntry)
mbCap := s.store.GetMultiByteCapMap()
EnrichNodeWithMultiByte(node, mbCap[pubkey])
if role, _ := node["role"].(string); role == "repeater" || role == "room" {
ht := s.cfg.GetHealthThresholds()
info := s.store.GetRepeaterRelayInfo(pubkey, ht.RelayActiveHours)
@@ -1665,59 +1665,10 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
// async backfill incomplete). Use biased re-resolve and the
// legacy containsTarget heuristics (preserves #1197 behavior
// and the #929 prefix-collision exclusion test).
//
// #1352: When a hop prefix has MULTIPLE candidates (sibling
// prefix collisions), the biased resolver — anchored on the
// queried target via hopContext=[lowerPK] — will preferentially
// resolve to the target via tier-2 geo / tier-3 GPS. This
// causes the SAME tx to be attributed to every prefix sibling
// when each is queried in turn. To prevent wrong-node
// attribution, we ONLY accept a resolver match as evidence of
// target membership when:
// (a) the tx was already pre-confirmed via
// confirmedByFullKey (resolved_path index hit) or
// confirmedBySQL (verified pubkey in resolved_path), OR
// (b) the hop's prefix candidate set is UNIQUE — no
// collision, so the resolver had no choice to bias.
// Multi-candidate hops with no SQL/index confirmation are
// treated as ambiguous and excluded from paths-through.
containsTarget = confirmedByFullKey[tx.ID] || confirmedBySQL[tx.ID]
// preconfirmed: SNAPSHOT of containsTarget BEFORE the per-hop
// loop runs. Captures only the SQL/full-key index pre-confirmation
// signal (independent of biased-resolver output). MUST NOT be
// reassigned inside the loop — doing so would let a biased-
// resolver match in hop[i] silently authorize a later ambiguous
// hop[j], re-opening the #1352 wrong-node attribution path.
//
// Note: today the loop only ever transitions containsTarget
// false → true, so the snapshot is functionally redundant for
// the preconfirmed==true case (containsTarget is already true).
// We keep the snapshot + the `preconfirmed ||` clauses below
// as a structural invariant: future edits that flip
// containsTarget back to false inside the loop (e.g. an
// "exclude if last hop doesn't match" tweak) would otherwise
// silently lose the SQL/index confirmation. The snapshot is
// the documented contract.
preconfirmed := containsTarget
for i, hop := range hops {
resolved := resolveHop(hop)
entry := PathHopResp{Prefix: hop, Name: hop}
lowerHop := strings.ToLower(hop)
// #1352 guard helper. We treat as "unique/safe" when the
// hop's prefix candidate set has EXACTLY ONE member: no
// sibling collision, so the biased resolver had no choice
// to bias. len(pm.m[lowerHop]) == 0 is also accepted as
// safe-by-default in the resolvable arm because the
// resolver returned a non-nil candidate from somewhere
// (e.g. a full-pubkey hop longer than maxPrefixLen, or a
// hop indexed under a different prefix length); there's
// no collision to resolve away. In the unresolvable arm
// below, len==0 is the ONLY reachable case (resolveHop
// returns nil iff pm.m[lowerHop] is empty — see
// resolveWithContext priority chain), so the guard there
// is intentionally permissive on len==0 and the
// `preconfirmed ||` clause is the meaningful gate.
uniquePrefix := len(pm.m[lowerHop]) <= 1
if resolved != nil {
entry.Name = resolved.Name
entry.Pubkey = resolved.PublicKey
@@ -1727,24 +1678,13 @@ func (s *Server) handleNodePaths(w http.ResponseWriter, r *http.Request) {
}
sigParts[i] = resolved.PublicKey
if strings.ToLower(resolved.PublicKey) == lowerPK {
// #1352: only attribute when unambiguous OR
// already pre-confirmed via SQL/full-key index.
if preconfirmed || uniquePrefix {
containsTarget = true
}
containsTarget = true
}
} else {
sigParts[i] = hop
// Unresolvable hop: keep conservative if prefix could
// be the target AND there's no sibling collision.
// If multiple candidates share this prefix, attribution
// is ambiguous — don't claim membership without SQL
// confirmation (#1352). See comment on uniquePrefix
// above re: why len==0 is treated as safe here.
if strings.HasPrefix(lowerPK, lowerHop) {
if preconfirmed || uniquePrefix {
containsTarget = true
}
// Unresolvable hop: keep conservative if prefix could be the target.
if strings.HasPrefix(lowerPK, strings.ToLower(hop)) {
containsTarget = true
}
}
resolvedHops[i] = entry
+36 -166
View File
@@ -15,8 +15,6 @@ import (
"sync/atomic"
"time"
"unicode/utf8"
"github.com/meshcore-analyzer/mbcapqueue"
)
// payloadTypeNames maps payload_type int → human-readable name (firmware-standard).
@@ -146,7 +144,7 @@ type PacketStore struct {
insertCount int64
queryCount int64
// Response caches (separate mutex to avoid contention with store RWMutex)
cacheMu sync.RWMutex
cacheMu sync.Mutex
rfCache map[string]*cachedResult // region → cached RF result
topoCache map[string]*cachedResult // region → cached topology result
hashCache map[string]*cachedResult // region → cached hash-sizes result
@@ -231,13 +229,9 @@ type PacketStore struct {
relayStatsCacheWindow float64
relayStatsCacheSig string
// Snapshot from the last analytics cycle + O(1) index, both under cacheMu.
// Populated by analytics + pre-populated from DB on Load (read-only path).
// Persistence to the DB is owned by the ingestor (#1289/#1324): the
// analytics cycle publishes a snapshot file via internal/mbcapqueue
// and the ingestor's RunMultibyteCapPersist applies it.
mbCapSnapshot []MultiByteCapEntry
mbCapIndex map[string]MultiByteCapEntry
// Cached multi-byte capability map (pubkey → entry), recomputed every 15s.
multiByteCapCache map[string]*MultiByteCapEntry
multiByteCapAt time.Time
// Cached per-pubkey relay info + usefulness score maps (#1257). These
// fold the previously per-node GetRepeaterRelayInfo /
@@ -819,7 +813,6 @@ func (s *PacketStore) Load() error {
log.Printf("[store] Loaded %d transmissions (%d observations) in %v (tracked ~%.0fMB, heap ~%.0fMB)",
len(s.packets), s.totalObs, elapsed, s.trackedMemoryMB(), s.estimatedMemoryMB())
}
s.loadMultibyteCapFromDB()
return nil
}
@@ -1380,20 +1373,6 @@ func (s *PacketStore) QueryPackets(q PacketQuery) *PacketResult {
results := s.filterPackets(q)
total := len(results)
// #1345: order by ingest id, not insertion-into-s.packets order. After
// Load() (which orders by first_seen ASC) the slice is mostly id-ordered
// EXCEPT where rxTime ≠ ingest time — exactly the buffered-observer-upload
// case that hides fresh activity. Sort by ID DESC so "page 0" is always
// the most-recently-ingested transmissions, matching the DB-path fix.
// Cost: O(n log n) on the filtered set per query; acceptable for the
// typical filter-then-paginate flow (filterPackets already O(n)).
sortedByID := make([]*StoreTx, len(results))
copy(sortedByID, results)
sort.Slice(sortedByID, func(i, j int) bool {
return sortedByID[i].ID < sortedByID[j].ID
})
results = sortedByID
// results is oldest-first (ASC). For DESC (default) read backwards from the tail;
// for ASC read forwards. Both are O(page_size) — no sort copy needed.
start := q.Offset
@@ -1977,9 +1956,9 @@ func (s *PacketStore) QueryMultiNodePackets(pubkeys []string, limit, offset int,
filtered = append(filtered, tx)
}
}
// #1345: sort by ingest id, not first_seen (=rxTime).
// Sort oldest-first to match pagination expectations (same as s.packets order).
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].ID < filtered[j].ID
return filtered[i].FirstSeen < filtered[j].FirstSeen
})
total := len(filtered)
@@ -4529,13 +4508,7 @@ func (s *PacketStore) GetChannels(region string) []map[string]interface{} {
}
channelName := decoded.Channel
if channelName == "" {
// Issue #1373: encrypted-no-key packets decode with channel="".
// Previously we bucketed them under a literal "unknown" channel
// which then leaked into /api/channels as a ghost entry next to
// real channels (especially visible after the operator added a
// PSK client-side). Skip them — they belong in encrypted-channels
// analytics, not the user-facing channel list.
continue
channelName = "unknown"
}
ch := channelMap[channelName]
if ch == nil {
@@ -4818,19 +4791,6 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
senderTs := decoded.SenderTimestamp
// Issue #1366: emit tx.LatestSeen (max observation timestamp,
// server UTC) as the rendered timestamp — NOT tx.FirstSeen,
// which stays pinned at the first-ever observation of a hash
// and lags reality for heartbeat-style retransmissions. Fall
// back to FirstSeen only when LatestSeen is empty (no obs).
// sender_timestamp from the decoded payload is NOT used as the
// rendered field: client RTCs are unreliable. It remains in
// the response for debug surfaces.
displayTs := tx.LatestSeen
if displayTs == "" {
displayTs = tx.FirstSeen
}
observers := []string{}
obsName := tx.ObserverName
if obsName == "" {
@@ -4844,8 +4804,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
Data: map[string]interface{}{
"sender": displaySender,
"text": displayText,
"timestamp": strOrNil(displayTs),
"first_seen": strOrNil(tx.FirstSeen),
"timestamp": strOrNil(tx.FirstSeen),
"sender_timestamp": senderTs,
"packetId": tx.ID,
"packetHash": strOrNil(tx.Hash),
@@ -4862,18 +4821,6 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int,
}
}
// Issue #1366 follow-up: msgOrder is in tx insertion order
// (≈ FirstSeen ascending). Re-sort by the rendered timestamp field
// (= LatestSeen, set above) ascending, so the page tail = newest
// LatestSeen. Without this, a long-running heartbeat with old
// FirstSeen but fresh LatestSeen ends up at the head of msgOrder
// and gets sliced off by the tail selection below.
sort.SliceStable(msgOrder, func(i, j int) bool {
ti, _ := msgMap[msgOrder[i]].Data["timestamp"].(string)
tj, _ := msgMap[msgOrder[j]].Data["timestamp"].(string)
return ti < tj
})
total := len(msgOrder)
// Return latest messages (tail)
start := total - limit - offset
@@ -7189,27 +7136,7 @@ func (s *PacketStore) computeAnalyticsHashSizesWithCapability(region, area strin
}
}
}
mbEntries := s.computeMultiByteCapability(globalAdopterHS)
result["multiByteCapability"] = mbEntries
// Build the O(1) lookup index OUTSIDE the cache lock — at Cascadia
// scale this is a ~2400-entry allocation + hash + insert per cycle.
// Holding cacheMu while doing it blocks every API reader for the
// duration. Swap the pointers in under a short write-lock.
mbIdx := make(map[string]MultiByteCapEntry, len(mbEntries))
for _, e := range mbEntries {
mbIdx[e.PublicKey] = e
}
s.cacheMu.Lock()
s.mbCapSnapshot = mbEntries
s.mbCapIndex = mbIdx
s.cacheMu.Unlock()
// Publish snapshot to the on-disk handoff so the ingestor can
// persist it (#1289/#1324: server is read-only; persistence is the
// ingestor's job). Best-effort — a write failure here does not
// affect serving (the in-memory index above is the read path).
s.publishMultibyteCapSnapshot(mbEntries)
result["multiByteCapability"] = s.computeMultiByteCapability(globalAdopterHS)
return result
}
@@ -8009,96 +7936,39 @@ func EnrichNodeWithMultiByte(node map[string]interface{}, entry *MultiByteCapEnt
node["multi_byte_max_hash_size"] = entry.MaxHashSize
}
// GetMultibyteCapFor returns the capability entry for a single pubkey via an O(1) map
// lookup into the snapshot rebuilt by each analytics cycle (and pre-populated from
// the DB on cold start). Returns false when the pubkey has no known capability.
func (s *PacketStore) GetMultibyteCapFor(pk string) (*MultiByteCapEntry, bool) {
s.cacheMu.RLock()
e, ok := s.mbCapIndex[pk]
s.cacheMu.RUnlock()
if !ok {
return nil, false
// GetMultiByteCapMap returns a cached pubkey → MultiByteCapEntry map.
// Reuses the same 15s TTL cache pattern as hash size info.
func (s *PacketStore) GetMultiByteCapMap() map[string]*MultiByteCapEntry {
s.hashSizeInfoMu.Lock()
if s.multiByteCapCache != nil && time.Since(s.multiByteCapAt) < 15*time.Second {
cached := s.multiByteCapCache
s.hashSizeInfoMu.Unlock()
return cached
}
return &e, true
}
s.hashSizeInfoMu.Unlock()
// loadMultibyteCapFromDB pre-populates mbCapSnapshot and mbCapIndex from the nodes
// table so cold starts serve the last-known capability without waiting for the first
// analytics cycle (~15s).
func (s *PacketStore) loadMultibyteCapFromDB() {
if !s.db.hasMultibyteSupCols {
return
}
rows, err := s.db.conn.Query(
`SELECT public_key, COALESCE(name,''), COALESCE(role,''), COALESCE(last_seen,''), multibyte_sup, COALESCE(multibyte_evidence,'')
FROM nodes WHERE multibyte_sup > 0`)
if err != nil {
log.Printf("[multibyte] loadFromDB: %v", err)
return
}
defer rows.Close()
var entries []MultiByteCapEntry
for rows.Next() {
var pk, name, role, lastSeen, evidence string
var sup int
if err := rows.Scan(&pk, &name, &role, &lastSeen, &sup, &evidence); err != nil {
continue
// Get adopter hash sizes from analytics for cross-referencing
analyticsData := s.GetAnalyticsHashSizes("", "")
adopterSizes := make(map[string]int)
if nodes, ok := analyticsData["nodes"].(map[string]map[string]interface{}); ok {
for pk, data := range nodes {
if hs, ok := data["hashSize"].(int); ok {
adopterSizes[pk] = hs
}
}
status := "unknown"
switch sup {
case 2:
status = "confirmed"
case 1:
status = "suspected"
}
entries = append(entries, MultiByteCapEntry{
PublicKey: pk,
Name: name,
Role: role,
Status: status,
Evidence: evidence,
LastSeen: lastSeen,
})
}
if len(entries) == 0 {
return
}
idx := make(map[string]MultiByteCapEntry, len(entries))
for _, e := range entries {
idx[e.PublicKey] = e
}
s.cacheMu.Lock()
s.mbCapSnapshot = entries
s.mbCapIndex = idx
s.cacheMu.Unlock()
log.Printf("[multibyte] loaded %d capability entries from DB", len(entries))
}
// publishMultibyteCapSnapshot writes the analytics-cycle output to the
// on-disk handoff (internal/mbcapqueue). The ingestor's
// RunMultibyteCapPersist consumes the file and writes confirmed /
// suspected entries to the DB.
//
// INVARIANT (#1289/#1324): the server is the read path and opens
// SQLite mode=ro. It MUST NOT execute any UPDATE on
// nodes.multibyte_* — see readonly_invariant_test.go. This helper is
// the only side-effect path for capability data leaving the server.
func (s *PacketStore) publishMultibyteCapSnapshot(entries []MultiByteCapEntry) {
if s.db == nil || s.db.path == "" {
return
}
out := make([]mbcapqueue.Entry, 0, len(entries))
for _, e := range entries {
out = append(out, mbcapqueue.Entry{
PublicKey: e.PublicKey,
Status: e.Status,
Evidence: e.Evidence,
})
}
if err := mbcapqueue.WriteSnapshot(s.db.path, mbcapqueue.Snapshot{Entries: out}); err != nil {
log.Printf("[multibyte] publish snapshot: %v", err)
caps := s.computeMultiByteCapability(adopterSizes)
result := make(map[string]*MultiByteCapEntry, len(caps))
for i := range caps {
result[caps[i].PublicKey] = &caps[i]
}
s.hashSizeInfoMu.Lock()
s.multiByteCapCache = result
s.multiByteCapAt = time.Now()
s.hashSizeInfoMu.Unlock()
return result
}
// --- Multi-Byte Capability Inference ---
-49
View File
@@ -76,9 +76,6 @@ func Apply(rw *sql.DB, logf Logger) error {
if err := ensureObservationsRawHexColumn(rw, logf); err != nil {
return fmt.Errorf("ensure observations.raw_hex: %w", err)
}
if err := ensureMultibyteCapColumns(rw, logf); err != nil {
return fmt.Errorf("ensure multibyte_cap columns: %w", err)
}
return nil
}
@@ -123,13 +120,6 @@ func AssertReady(ro *sql.DB) error {
mustCol("nodes", "default_scope")
mustCol("inactive_nodes", "default_scope")
mustCol("observations", "raw_hex")
// Multi-byte capability cache (#1324 follow-up; PR #903 surface).
// Owned by ingestor — server reads these for O(1) /api/nodes
// enrichment, ingestor's RunMultibyteCapPersist is the only writer.
mustCol("nodes", "multibyte_sup")
mustCol("nodes", "multibyte_evidence")
mustCol("inactive_nodes", "multibyte_sup")
mustCol("inactive_nodes", "multibyte_evidence")
if len(missing) > 0 {
return fmt.Errorf("schema not migrated by ingestor; restart ingestor first. missing: %s",
@@ -171,10 +161,6 @@ func ensureServerIndexes(rw *sql.DB) error {
`CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type)`,
`CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp)`,
`CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id)`,
// Composite covers GetChannelMessages' grouped MAX(timestamp) per
// transmission_id (issue #1366 / PR #1368). With this index sqlite can
// satisfy the aggregate index-only without touching the heap.
`CREATE INDEX IF NOT EXISTS idx_observations_tx_ts ON observations(transmission_id, timestamp)`,
}
for _, s := range stmts {
if _, err := rw.Exec(s); err != nil {
@@ -448,38 +434,3 @@ func SoftDeleteBlacklistedObservers(rw *sql.DB, blacklist []string) (int64, erro
n, _ := res.RowsAffected()
return n, nil
}
// ensureMultibyteCapColumns adds the multi-byte capability cache columns
// to nodes / inactive_nodes (PR #903, canonical owner per #1324
// follow-up). These columns are populated by the ingestor's
// RunMultibyteCapPersist from snapshot files written by the server's
// analytics cycle; the server is read-only since #1289 and MUST NOT
// write here. The schema itself lives here in dbschema (the writer
// owns migrations, the read-only server merely AssertReady's them).
func ensureMultibyteCapColumns(rw *sql.DB, logf Logger) error {
for _, table := range []string{"nodes", "inactive_nodes"} {
hasSup, err := TableHasColumn(rw, table, "multibyte_sup")
if err != nil {
return fmt.Errorf("inspect %s.multibyte_sup: %w", table, err)
}
if !hasSup {
if _, err := rw.Exec(fmt.Sprintf(
"ALTER TABLE %s ADD COLUMN multibyte_sup INTEGER NOT NULL DEFAULT 0", table)); err != nil {
return fmt.Errorf("add %s.multibyte_sup: %w", table, err)
}
logf("[dbschema] added multibyte_sup column to %s", table)
}
hasEvid, err := TableHasColumn(rw, table, "multibyte_evidence")
if err != nil {
return fmt.Errorf("inspect %s.multibyte_evidence: %w", table, err)
}
if !hasEvid {
if _, err := rw.Exec(fmt.Sprintf(
"ALTER TABLE %s ADD COLUMN multibyte_evidence TEXT", table)); err != nil {
return fmt.Errorf("add %s.multibyte_evidence: %w", table, err)
}
logf("[dbschema] added multibyte_evidence column to %s", table)
}
}
return nil
}
-3
View File
@@ -1,3 +0,0 @@
module github.com/meshcore-analyzer/mbcapqueue
go 1.22
-118
View File
@@ -1,118 +0,0 @@
// Package mbcapqueue defines the on-disk handoff used by the read-only
// server (cmd/server) to publish multi-byte capability snapshots that
// the writer-owning ingestor (cmd/ingestor) persists to the nodes /
// inactive_nodes tables.
//
// Rationale: PR #903 originally added a server-side persistMultibyteCapability
// that executed UPDATEs on nodes/inactive_nodes — a hard violation of the
// read-only-server invariant established in #1283/#1287/#1289 (the server
// opens SQLite with mode=ro). The capability computation is heavy and lives
// in the server's analytics cycle; rather than duplicate it in the ingestor,
// the server writes a snapshot file under <dataDir>/mbcap-snapshot/ and the
// ingestor's maintenance loop picks it up and writes to the DB.
//
// Pattern mirrors internal/prunequeue (#669/#738).
//
// Layout (under <dir(dbPath)>/mbcap-snapshot/):
//
// snapshot.json — atomic-replaced by the server each analytics cycle
// snapshot.json.tmp — transient (rename target)
//
// The file is rewritten in full each cycle (idempotent overwrite). The
// ingestor reads the file at most once per persist tick; if absent, the
// tick is a no-op.
package mbcapqueue
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// QueueDirName is the subdirectory (under the SQLite data dir) holding
// the snapshot file.
const QueueDirName = "mbcap-snapshot"
// SnapshotFileName is the canonical snapshot file written by the server.
const SnapshotFileName = "snapshot.json"
// Entry is one node's multi-byte capability as derived by the server's
// analytics cycle. Status is the human label ("confirmed", "suspected",
// "unknown"); the ingestor maps it to the DB sup integer.
//
// Entries with Status=="unknown" are NEVER persisted (the writer must
// not overwrite a previously confirmed/suspected DB value with a
// snapshot blank — same data-destruction guard the server enforced).
type Entry struct {
PublicKey string `json:"public_key"`
Status string `json:"status"`
Evidence string `json:"evidence,omitempty"`
}
// Snapshot is the full payload the server writes.
type Snapshot struct {
WrittenAt time.Time `json:"writtenAt"`
Entries []Entry `json:"entries"`
}
// QueueDir returns the absolute path of the snapshot directory, given
// the SQLite database path the ingestor and server share.
func QueueDir(dbPath string) string {
return filepath.Join(filepath.Dir(dbPath), QueueDirName)
}
// EnsureDir creates the snapshot directory if missing.
func EnsureDir(dbPath string) error {
return os.MkdirAll(QueueDir(dbPath), 0o755)
}
// SnapshotPath returns the absolute path of snapshot.json under dbPath.
func SnapshotPath(dbPath string) string {
return filepath.Join(QueueDir(dbPath), SnapshotFileName)
}
// WriteSnapshot atomically replaces snapshot.json with the given payload.
// Uses tmp-then-rename so a reader never sees a torn file.
func WriteSnapshot(dbPath string, snap Snapshot) error {
if err := EnsureDir(dbPath); err != nil {
return fmt.Errorf("ensure dir: %w", err)
}
if snap.WrittenAt.IsZero() {
snap.WrittenAt = time.Now().UTC()
}
b, err := json.Marshal(snap)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
final := SnapshotPath(dbPath)
tmp := final + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return fmt.Errorf("write tmp: %w", err)
}
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
return nil
}
// ReadSnapshot loads the current snapshot.json. Returns os.ErrNotExist
// when no snapshot has been written yet — callers should treat that as
// "nothing to persist" rather than an error.
func ReadSnapshot(dbPath string) (Snapshot, error) {
var snap Snapshot
b, err := os.ReadFile(SnapshotPath(dbPath))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return snap, os.ErrNotExist
}
return snap, fmt.Errorf("read: %w", err)
}
if err := json.Unmarshal(b, &snap); err != nil {
return snap, fmt.Errorf("unmarshal: %w", err)
}
return snap, nil
}
+1 -1
View File
@@ -3986,7 +3986,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
if (loadingEl) loadingEl.style.display = '';
try {
// Fix 4: use api() instead of raw fetch()
var data = await api('/scope-stats?window=' + encodeURIComponent(w), { ttl: 30000 });
var data = await api('/api/scope-stats?window=' + encodeURIComponent(w), { ttl: 30000 });
if (loadingEl) loadingEl.style.display = 'none';
if (data.error) {
var cardsEl2 = document.getElementById('scopes-cards');
+9 -77
View File
@@ -1000,11 +1000,10 @@ window.addEventListener('DOMContentLoaded', () => {
// --- Dark Mode ---
const darkToggle = document.getElementById('darkModeToggle');
const darkCheckbox = document.getElementById('darkModeCheckbox');
const savedTheme = localStorage.getItem('meshcore-theme');
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
if (darkCheckbox) darkCheckbox.checked = theme === 'dark';
darkToggle.textContent = theme === 'dark' ? '🌙' : '☀️';
localStorage.setItem('meshcore-theme', theme);
// Re-apply user theme CSS vars for the correct mode (light/dark)
reapplyUserThemeVars(theme === 'dark');
@@ -1052,45 +1051,9 @@ window.addEventListener('DOMContentLoaded', () => {
} else {
applyTheme('light');
}
if (darkCheckbox) {
darkCheckbox.addEventListener('change', () => {
applyTheme(darkCheckbox.checked ? 'dark' : 'light');
});
} else {
// Fallback for button-style toggle (upstream compatibility)
darkToggle.addEventListener('click', () => {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
applyTheme(isDark ? 'light' : 'dark');
});
}
// PR #893 follow-up: cross-tab sync — when another tab toggles theme,
// mirror it here without re-persisting (avoid loop). Matches the pattern
// used by the cb-presets storage listener below.
window.addEventListener('storage', function (ev) {
if (!ev || ev.key !== 'meshcore-theme' || !ev.newValue) return;
if (ev.newValue !== 'dark' && ev.newValue !== 'light') return;
document.documentElement.setAttribute('data-theme', ev.newValue);
if (darkCheckbox) darkCheckbox.checked = ev.newValue === 'dark';
try { reapplyUserThemeVars(ev.newValue === 'dark'); } catch (_) {}
});
// --- #1361 Colorblind preset bootstrap & cross-tab sync ---
// cb-presets.js auto-inits on module load, but body may not have existed
// yet (script loads in <head>); re-apply now that DOMContentLoaded fired
// so body[data-cb-preset] is set before first paint of map/cluster bubbles.
try {
if (window.MeshCorePresets && typeof window.MeshCorePresets.initFromStorage === 'function') {
window.MeshCorePresets.initFromStorage();
}
} catch (e) { console.error('[cb-preset] init failed:', e); }
// Cross-tab sync: storage event listener is also registered inside
// cb-presets.js, but we wire a redundant one here so any future refactor
// of the module still leaves the cross-tab guarantee intact.
window.addEventListener('storage', function (ev) {
if (!ev || ev.key !== 'meshcore-cb-preset') return;
if (window.MeshCorePresets && ev.newValue) {
window.MeshCorePresets.applyPreset(ev.newValue, { skipPersist: true });
}
darkToggle.addEventListener('click', () => {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
applyTheme(isDark ? 'light' : 'dark');
});
// --- Hamburger Menu ---
@@ -1133,23 +1096,9 @@ window.addEventListener('DOMContentLoaded', () => {
// only signal — if you ever need finer ordering, switch to a numeric
// attribute (e.g. data-overflow-order="3") rather than re-shuffling
// index in HTML.
// #1391: ALSO exclude the currently-active link from the queue.
// The active pill has wider rendered width (background + padding),
// and acceptance for #1391 requires "Active-route pill MUST always
// be visible inline (never overflowed to More) at any viewport
// ≥768px." The queue is rebuilt on hashchange (applyNavPriority
// is wired to hashchange below), so the exclusion tracks the
// current route automatically.
function buildOverflowQueue() {
var isPinned = function(a) {
return a.dataset.priority === 'high' || a.classList.contains('active');
};
return allLinks.filter(a => !isPinned(a))
.reverse() // right-to-left
.concat(allLinks.filter(a => a.dataset.priority === 'high' && !a.classList.contains('active')).reverse());
}
var overflowQueue = buildOverflowQueue();
const overflowQueue = allLinks.filter(a => a.dataset.priority !== 'high')
.reverse() // right-to-left
.concat(allLinks.filter(a => a.dataset.priority === 'high').reverse());
function rebuildMoreMenu() {
navMoreMenu.innerHTML = '';
@@ -1208,14 +1157,7 @@ window.addEventListener('DOMContentLoaded', () => {
// owns the decision (and at 2560px nothing overflows).
if (window.innerWidth <= 1100) {
allLinks.forEach(a => {
// #1391: never overflow the active-route pill, even in the
// narrow-desktop CSS branch — acceptance requires it stay
// inline at any viewport ≥768px. Without this guard, a
// non-high-priority active route (e.g. /#/perf) would be
// shoved into More alongside the rest.
if (a.dataset.priority !== 'high' && !a.classList.contains('active')) {
a.classList.add('is-overflow');
}
if (a.dataset.priority !== 'high') a.classList.add('is-overflow');
});
rebuildMoreMenu();
return;
@@ -1272,11 +1214,6 @@ window.addEventListener('DOMContentLoaded', () => {
return needed <= window.innerWidth;
}
let i = 0;
// #1391: rebuild queue here so it reflects the CURRENT active
// link (hashchange wakes applyNavPriority, but the queue was
// captured at init-time; we need to re-evaluate which link is
// active on every run). Cheap — just filters allLinks twice.
overflowQueue = buildOverflowQueue();
// #1311 floor: protect data-priority="high" links from being
// dropped by the greedy fit loop. The bug was that on a non-high
// active route (e.g. /#/perf, /#/audio-lab) at ~1101-1200px, the
@@ -1289,13 +1226,8 @@ window.addEventListener('DOMContentLoaded', () => {
// still doesn't fit at that point, that's a layout issue (e.g.
// shrink the active pill, drop nav-stats earlier) — never the
// measurer's call to delete primary navigation.
//
// #1391: also break on .active — buildOverflowQueue already
// excludes the active link from the queue, but the break is a
// defensive belt for any future code that re-enqueues it.
while (!fits() && i < overflowQueue.length) {
if (overflowQueue[i].dataset.priority === 'high') break;
if (overflowQueue[i].classList.contains('active')) break;
overflowQueue[i].classList.add('is-overflow');
i++;
}
@@ -1314,7 +1246,7 @@ window.addEventListener('DOMContentLoaded', () => {
// it just to satisfy the >=2 More-menu floor. A degenerate
// 1-item dropdown is a smaller UX paper-cut than nuking a
// primary nav link.
if (i < overflowQueue.length && overflowQueue[i].dataset.priority !== 'high' && !overflowQueue[i].classList.contains('active')) {
if (i < overflowQueue.length && overflowQueue[i].dataset.priority !== 'high') {
overflowQueue[i].classList.add('is-overflow');
i++;
} else {
-263
View File
@@ -1,263 +0,0 @@
/* cb-presets.js Colorblind preset registry & runtime switcher (#1361).
*
* MVP scope:
* - 5 presets: default (Wong 2011), deut (IBM 5-class), prot (IBM 5-class
* with high-luminance amber anchor), trit (Tol muted, blue/yellow-safe),
* achromat (pure luminance ramp).
* - applyPreset(id) sets body[data-cb-preset], writes --mc-role-* and
* --mc-mb-* CSS vars on documentElement, persists to localStorage.
* - initFromStorage() re-applies on reload.
* - storage event listener syncs across tabs.
* - WCAG 2.2 SC 1.4.3 / 1.4.11 contrast helper for validation.
*
* Stretch (Brettel/Vienot SVG simulation overlay, "Reset to default Wong"
* button) is intentionally NOT implemented here separate follow-up.
*
* Palette sources cited in PR body.
*/
(function () {
'use strict';
var STORAGE_KEY = 'meshcore-cb-preset';
var DATA_ATTR = 'data-cb-preset';
// ── Palettes ────────────────────────────────────────────────────────────
// Each preset declares colors for the 5 roles + the 3 multi-byte status
// colors. role keys mirror --mc-role-{repeater|companion|room|sensor|observer}.
// mb keys mirror --mc-mb-{confirmed|suspected|unknown}.
var PRESETS = [
{
id: 'default',
label: 'Default (Wong 2011)',
description: 'Wong\'s 8-class colorblind-safe palette — the project default.',
roleColors: {
repeater: '#D55E00', // vermillion
companion: '#56B4E9', // sky blue
room: '#009E73', // bluish-green
sensor: '#F0E442', // yellow
observer: '#CC79A7' // reddish-purple
},
mb: {
confirmed: '#56F0A0',
suspected: '#FFD966',
unknown: '#FF8888'
}
},
{
id: 'deut',
label: 'Deuteranopia-tuned',
description: 'IBM 5-class palette — anchors shifted away from red/green collision.',
// IBM Design Language colorblind-safe: blue / purple / magenta / orange / amber.
roleColors: {
repeater: '#FE6100', // orange (high-luminance anchor for repeater)
companion: '#648FFF', // blue
room: '#785EF0', // purple
sensor: '#FFB000', // amber
observer: '#DC267F' // magenta
},
mb: {
confirmed: '#648FFF',
suspected: '#FFB000',
unknown: '#DC267F'
}
},
{
id: 'prot',
label: 'Protanopia-tuned',
description: 'IBM 5-class with amber-shifted repeater anchor (protan-safe luminance).',
roleColors: {
repeater: '#FFB000', // amber — higher luminance than orange for protans
companion: '#648FFF',
room: '#785EF0',
sensor: '#FE6100',
observer: '#DC267F'
},
mb: {
confirmed: '#648FFF',
suspected: '#FFB000',
unknown: '#DC267F'
}
},
{
id: 'trit',
label: 'Tritanopia-tuned',
description: 'Tol muted palette — avoids blue/yellow confusion zone.',
// Paul Tol muted (B/Y-safe): red / teal / green / purple / sand.
roleColors: {
repeater: '#CC6677', // rose
companion: '#117733', // green
room: '#882255', // wine
sensor: '#DDCC77', // sand (replaces pure yellow)
observer: '#AA4499' // purple
},
mb: {
confirmed: '#117733',
suspected: '#DDCC77',
unknown: '#CC6677'
}
},
{
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%
room: '#808080', // L=50%
sensor: '#b3b3b3', // L=70%
observer: '#e6e6e6' // L=90%
},
mb: {
confirmed: '#b3b3b3',
suspected: '#808080',
unknown: '#595959'
}
}
];
// ── WCAG helpers ────────────────────────────────────────────────────────
function _hexToRgb(hex) {
if (!hex || 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 _channelLin(c) {
var s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function relativeLuminance(hex) {
var rgb = _hexToRgb(hex);
if (!rgb) return 0;
return 0.2126 * _channelLin(rgb.r) + 0.7152 * _channelLin(rgb.g) + 0.0722 * _channelLin(rgb.b);
}
function contrast(fg, bg) {
var L1 = relativeLuminance(fg);
var L2 = relativeLuminance(bg);
var hi = Math.max(L1, L2);
var lo = Math.min(L1, L2);
return (hi + 0.05) / (lo + 0.05);
}
// Canonical map tile backgrounds for validation (Carto Positron / Dark Matter)
var TILE_LIGHT = '#f2efe9';
var TILE_DARK = '#1a1a1a';
/**
* Validate a preset against WCAG 2.2 SC 1.4.11 (3:1 for non-text UI).
* Returns an array of { role, color, vsLight, vsDark, passLight, passDark }.
*/
function validatePreset(presetId) {
var p = PRESETS.filter(function (x) { return x.id === presetId; })[0];
if (!p) return [];
var out = [];
Object.keys(p.roleColors).forEach(function (role) {
var c = p.roleColors[role];
var vL = contrast(c, TILE_LIGHT);
var vD = contrast(c, TILE_DARK);
out.push({
role: role,
color: c,
vsLight: vL,
vsDark: vD,
passLight: vL >= 3.0,
passDark: vD >= 3.0
});
});
return out;
}
// ── Runtime application ────────────────────────────────────────────────
function _byId(id) {
for (var i = 0; i < PRESETS.length; i++) if (PRESETS[i].id === id) return PRESETS[i];
return null;
}
function applyPreset(id, opts) {
opts = opts || {};
var p = _byId(id);
if (!p) return false;
if (typeof document !== 'undefined' && document.body) {
document.body.setAttribute(DATA_ATTR, p.id);
}
if (typeof document !== 'undefined' && document.documentElement) {
var style = document.documentElement.style;
Object.keys(p.roleColors).forEach(function (role) {
style.setProperty('--mc-role-' + role, p.roleColors[role]);
});
Object.keys(p.mb).forEach(function (k) {
style.setProperty('--mc-mb-' + k, p.mb[k]);
});
// 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];
});
}
}
}
if (!opts.skipPersist) {
try { if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, p.id); } catch (e) {}
}
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof window.CustomEvent === 'function') {
try { window.dispatchEvent(new window.CustomEvent('cb-preset-changed', { detail: { id: p.id } })); } catch (e) {}
}
return true;
}
function currentPreset() {
try {
if (typeof localStorage !== 'undefined') {
var v = localStorage.getItem(STORAGE_KEY);
if (v && _byId(v)) return v;
}
} catch (e) {}
return 'default';
}
function initFromStorage() {
applyPreset(currentPreset(), { skipPersist: true });
}
// Cross-tab sync via storage event.
function _onStorage(ev) {
if (!ev || ev.key !== STORAGE_KEY) return;
var id = ev.newValue;
if (!id || !_byId(id)) return;
applyPreset(id, { skipPersist: true });
}
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', _onStorage);
}
// Auto-init on module load (so reload re-applies the saved preset before
// first paint, modulo script ordering — cb-presets.js loads before app.js).
try { initFromStorage(); } catch (e) {}
// Export
var api = {
list: PRESETS,
applyPreset: applyPreset,
currentPreset: currentPreset,
initFromStorage: initFromStorage,
validatePreset: validatePreset,
wcag: {
relativeLuminance: relativeLuminance,
contrast: contrast,
TILE_LIGHT: TILE_LIGHT,
TILE_DARK: TILE_DARK
},
STORAGE_KEY: STORAGE_KEY
};
if (typeof window !== 'undefined') window.MeshCorePresets = api;
if (typeof module !== 'undefined') module.exports = api;
})();
+13 -118
View File
@@ -676,6 +676,8 @@
<div id="chRegionFilter" class="region-filter-container ch-header-region"></div>
<button type="button" id="chAddChannelBtn" class="ch-add-channel-btn"
aria-label="Add channel" title="Add a channel — generate, paste a key, or monitor a hashtag">+ Add</button>
<a href="#/analytics" class="ch-analytics-link"
title="Open the Analytics page to see channel activity stats" aria-label="Channel Analytics">📊</a>
</div>
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
@@ -765,8 +767,6 @@
</div>
<div class="ch-main" role="region" aria-label="Channel messages">
<div class="ch-main-header" id="chHeader">
<button type="button" class="ch-back" data-action="ch-back"
aria-label="Back to channel list" title="Back"></button>
<span class="ch-header-text">Select a channel</span>
</div>
<div class="ch-messages" id="chMessages">
@@ -1104,18 +1104,6 @@
if (!btn) return;
var action = btn.dataset.action;
if (action === 'ch-close-node') closeNodeDetail();
if (action === 'ch-back') {
// Mobile slide-back: return to the channel list view.
selectedHash = null;
messages = [];
history.replaceState(null, '', '#/channels');
document.querySelector('.ch-layout')?.classList.remove('ch-detail-open');
var headerT = document.querySelector('#chHeader .ch-header-text');
if (headerT) headerT.textContent = 'Select a channel';
var msgEl = document.getElementById('chMessages');
if (msgEl) msgEl.innerHTML = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
renderChannelList();
}
});
// Event delegation for channel selection (touch-friendly)
@@ -1226,7 +1214,7 @@
if (ch) ChannelColorPicker.show(ch, e.clientX, e.clientY);
return;
}
const item = e.target.closest('.ch-item[data-hash], .ch-row[data-hash]');
const item = e.target.closest('.ch-item[data-hash]');
if (item) selectChannel(item.dataset.hash);
});
@@ -1514,27 +1502,14 @@
window._channelsHandleWSBatchForTest = handleWSBatch;
window._channelsProcessWSBatchForTest = processWSBatch;
// #1367: Re-render the channel list when the viewport crosses the
// mobile/desktop boundary so the layout swaps between flat .ch-row
// and sectioned .ch-item without a navigation.
var _chMobileMQ = null;
try { _chMobileMQ = window.matchMedia('(max-width: 767px)'); } catch (e) { /* noop */ }
if (_chMobileMQ && typeof _chMobileMQ.addEventListener === 'function') {
_chMobileMQ.addEventListener('change', function () { renderChannelList(); });
}
// Tick relative timestamps every 1s — iterates channels array, updates DOM text only
timeAgoTimer = setInterval(function () {
var now = Date.now();
for (var i = 0; i < channels.length; i++) {
var ch = channels[i];
if (!ch.lastActivityMs) continue;
var text = formatSecondsAgo(Math.floor((now - ch.lastActivityMs) / 1000));
var el = document.querySelector('.ch-item-time[data-channel-hash="' + ch.hash + '"]');
if (el) el.textContent = text;
// #1367: mobile rows live in a flat list; update those too.
var rowEl = document.querySelector('.ch-row[data-hash="' + ch.hash + '"] .ch-row-time');
if (rowEl) rowEl.textContent = text;
if (el) el.textContent = formatSecondsAgo(Math.floor((now - ch.lastActivityMs) / 1000));
}
}, 1000);
}
@@ -1678,73 +1653,12 @@
</button>`;
}
// #1367: mobile chat-app row renderer. Full-width 80px rows with a
// hash-colored avatar, bold name, ellipsized last-message preview,
// and right-aligned relative timestamp. No inline action chips.
function isMobileChannels() {
try { return window.matchMedia('(max-width: 767px)').matches; } catch (e) { return false; }
}
function avatarTextForChannel(ch) {
const name = ch && ch.name ? String(ch.name) : '';
if (name.charAt(0) === '#') return name.slice(0, 3); // "#wa"
if (ch && ch.encrypted && !ch.userAdded) return '🔒';
if (ch && ch.userAdded) return '🔑';
// Fallback: 2-char uppercase abbreviation.
return name.replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() ||
String(ch && ch.hash || '?').slice(0, 2).toUpperCase();
}
function renderChannelRowMobile(ch) {
const isEncrypted = ch.encrypted === true;
const isUserAdded = ch.userAdded === true;
const encryptedFallback = isEncrypted ? 'Unknown' : '';
const name = channelDisplayName(ch, encryptedFallback);
const color = (isEncrypted && !isUserAdded)
? 'var(--text-muted, #6b7280)'
: getChannelColor(ch.hash);
const time = ch.lastActivityMs
? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000))
: '';
let preview = '';
if (ch.lastSender && ch.lastMessage) {
preview = ch.lastSender + ': ' + ch.lastMessage;
} else if (isEncrypted && !isUserAdded) {
preview = '0x' + formatHashHex(ch.hash);
} else if (typeof ch.messageCount === 'number' && ch.messageCount > 0) {
preview = ch.messageCount + ' messages';
}
const abbr = avatarTextForChannel(ch);
const sel = selectedHash === ch.hash ? ' selected' : '';
return '<button type="button" class="ch-row' + sel + '" data-hash="' + escapeHtml(ch.hash) +
'" role="option" aria-selected="' + (selectedHash === ch.hash ? 'true' : 'false') +
'" aria-label="' + escapeHtml(name) + '">' +
'<div class="ch-avatar ch-row-avatar" style="background:' + color +
'" aria-hidden="true">' + escapeHtml(abbr) + '</div>' +
'<div class="ch-row-body">' +
'<div class="ch-row-line1">' +
'<span class="ch-row-name">' + escapeHtml(name) + '</span>' +
'<span class="ch-row-time">' + escapeHtml(time) + '</span>' +
'</div>' +
'<div class="ch-row-preview">' + escapeHtml(preview) + '</div>' +
'</div>' +
'</button>';
}
// #1034 PR1: sectioned sidebar — My Channels / Network / Encrypted (N).
function renderChannelList() {
const el = document.getElementById('chList');
if (!el) return;
if (channels.length === 0) { el.innerHTML = '<div class="ch-empty">No channels found</div>'; return; }
// #1367: mobile gets a flat chat-app list (no sections, no inline actions).
if (isMobileChannels()) {
const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0);
const sorted = channels.slice().sort(sortByActivity);
el.innerHTML = sorted.map(renderChannelRowMobile).join('');
return;
}
const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0);
const sortByCount = (a, b) => (b.messageCount || 0) - (a.messageCount || 0);
@@ -1803,9 +1717,6 @@
var __selCh = channels.find(function (c) { return c.hash === hash; });
if (__selCh && __selCh.unread) { __selCh.unread = 0; }
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
// #1367: mobile slide-in — flip the layout into detail mode so CSS
// can swap the visible pane. Desktop is a no-op (rule matches mobile).
document.querySelector('.ch-layout')?.classList.add('ch-detail-open');
renderChannelList();
const ch = channels.find(c => c.hash === hash);
// #1041: never show raw "psk:<hex>" prefixes in the header — use the
@@ -1985,24 +1896,11 @@
const senderColor = getSenderColor(sender);
const senderLetter = sender.replace(/[^\w]/g, '').charAt(0).toUpperCase() || '?';
let rawBody = msg.text || '';
// Detect a leading @TARGET reply prefix and split it out so we can
// style it in the sender color (#1367 detail-view spec).
let replyTarget = '';
const replyMatch = rawBody.match(/^@([A-Za-z0-9_\-]{1,32})\s+/);
if (replyMatch) {
replyTarget = replyMatch[1];
rawBody = rawBody.slice(replyMatch[0].length);
}
let displayText = highlightMentions(rawBody);
if (replyTarget) {
displayText = '<span class="ch-reply-target" style="color:' + senderColor + '">@' +
escapeHtml(replyTarget) + '</span> ' + displayText;
}
let displayText;
displayText = highlightMentions(msg.text || '');
const tsDate = msg.timestamp ? new Date(msg.timestamp) : null;
const time = tsDate ? tsDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
const date = tsDate ? tsDate.toLocaleDateString() : '';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
const date = msg.timestamp ? new Date(msg.timestamp).toLocaleDateString() : '';
const meta = [];
meta.push(date + ' ' + time);
@@ -2012,15 +1910,12 @@
if (msg.snr !== null && msg.snr !== undefined) meta.push(`SNR ${msg.snr}`);
const safeId = btoa(encodeURIComponent(sender));
// #1367: emit BOTH the new chat-app class names (.ch-message /
// .ch-message-bubble / .ch-message-meta) and the legacy .ch-msg*
// names so existing tests/themes don't regress.
return `<div class="ch-msg ch-message">
return `<div class="ch-msg">
<div class="ch-avatar ch-tappable" style="background:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${senderLetter}</div>
<div class="ch-msg-content ch-message-content">
<div class="ch-msg-sender ch-message-sender ch-sender-link ch-tappable" style="color:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${escapeHtml(sender)}</div>
<div class="ch-msg-bubble ch-message-bubble">${displayText}</div>
<div class="ch-msg-meta ch-message-meta">${meta.join(' · ')}${msg.packetHash ? ` · <a href="#/packets/${msg.packetHash}" class="ch-analyze-link">View packet →</a>` : ''}</div>
<div class="ch-msg-content">
<div class="ch-msg-sender ch-sender-link ch-tappable" style="color:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${escapeHtml(sender)}</div>
<div class="ch-msg-bubble">${displayText}</div>
<div class="ch-msg-meta">${meta.join(' · ')}${msg.packetHash ? ` · <a href="#/packets/${msg.packetHash}" class="ch-analyze-link">View packet →</a>` : ''}</div>
</div>
</div>`;
}).join('');
-49
View File
@@ -1123,42 +1123,6 @@
'</div>';
}
// ── #1361 Colorblind preset selector ──
// MVP scope: radio selector + 1-line description + WCAG warning badge.
// Stretch (live Brettel/Vienot simulation overlay, "Reset to default Wong"
// button) intentionally deferred to a follow-up issue.
function _renderColorblindPresetSelector() {
var MCP = (typeof window !== 'undefined') && window.MeshCorePresets;
if (!MCP || !Array.isArray(MCP.list)) return '';
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">' +
'<input type="radio" name="cv2-cb-preset" data-cv2-cb-preset value="' + escAttr(p.id) + '"' + checked + ' style="margin-top:3px">' +
'<div style="flex:1">' +
'<div style="font-weight:600">' + esc(p.label) + '</div>' +
'<div class="cust-hint" style="font-size:12px;color:var(--text-muted)">' + esc(p.description) + '</div>' +
_renderCbPresetWarning(p.id) +
'</div>' +
'</label>';
}).join('');
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 _renderCbPresetWarning(id) {
var MCP = window.MeshCorePresets;
if (!MCP || typeof MCP.validatePreset !== 'function') return '';
var rep = MCP.validatePreset(id);
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
var failing = rep.filter(function (r) { return dark ? !r.passDark : !r.passLight; });
if (!failing.length) return '';
var names = failing.map(function (r) { return r.role; }).join(', ');
return '<div class="cust-cb-warn" style="margin-top:4px;font-size:11px;color:var(--status-yellow);background:rgba(255,200,0,0.08);padding:4px 6px;border-radius:4px">⚠ WCAG 1.4.11: ' + esc(names) + ' below 3:1 vs ' + (dark ? 'dark' : 'light') + ' tiles</div>';
}
function _renderNodes() {
var eff = _getEffective();
var server = _getServer();
@@ -1196,7 +1160,6 @@
var liveHeatPct = Math.round(liveHeatOpacity * 100);
return '<div class="cust-panel' + (_activeTab === 'nodes' ? ' active' : '') + '" data-panel="nodes">' +
_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 +
@@ -1793,18 +1756,6 @@
// GeoFilter tab init
if (_activeTab === 'geofilter') _initGeoFilterTab(container);
// #1361 Colorblind preset radio — switches preset via MeshCorePresets.applyPreset
container.querySelectorAll('[data-cv2-cb-preset]').forEach(function (radio) {
radio.addEventListener('change', function () {
if (!radio.checked) return;
var id = radio.value;
if (window.MeshCorePresets && typeof window.MeshCorePresets.applyPreset === 'function') {
window.MeshCorePresets.applyPreset(id);
_refreshPanel();
}
});
});
// Preset buttons
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
+6 -39
View File
@@ -8,21 +8,9 @@
* - 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.
* - #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.
* - 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.
*/
(function () {
'use strict';
@@ -50,11 +38,7 @@
relevant: function () {
if (onLiveRoute()) return false; // #1244
var h = location.hash || '';
// #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);
return /^#\/(packets|nodes)/.test(h);
},
position: 'bottom',
},
@@ -72,10 +56,7 @@
text: 'Tip: swipe in from the left edge to open navigation.',
relevant: function () {
if (onLiveRoute()) return false; // #1244
// #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]');
return window.innerWidth > 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]');
},
position: 'top-left',
},
@@ -84,11 +65,7 @@
text: 'Tip: pull down to refresh the connection.',
relevant: function () {
if (onLiveRoute()) return false; // #1244
// #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);
return !!document.querySelector('.pull-to-reconnect');
},
position: 'top',
},
@@ -215,16 +192,6 @@
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();
}
+1 -23
View File
@@ -22,19 +22,6 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<!-- PR #893 follow-up: apply persisted theme before stylesheet/paint to prevent
FOUC (light flash for users who chose dark). Matches keys used by app.js. -->
<script>
(function () {
try {
var saved = localStorage.getItem('meshcore-theme');
var t = saved === 'dark' || saved === 'light'
? saved
: (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', t);
} catch (_) { /* localStorage may be blocked; CSS handles default */ }
})();
</script>
<link rel="stylesheet" href="style.css?v=__BUST__">
<link rel="stylesheet" href="home.css?v=__BUST__">
<link rel="stylesheet" href="live.css?v=__BUST__">
@@ -98,14 +85,7 @@
</div>
<button class="nav-btn" id="searchToggle" title="Search (Ctrl+K)">🔍</button>
<button class="nav-btn" id="customizeToggle" title="Customize theme & branding">🎨</button>
<label class="theme-toggle" id="darkModeToggle" title="Toggle dark mode">
<input type="checkbox" id="darkModeCheckbox" role="switch" aria-label="Toggle dark mode">
<span class="theme-toggle-track" aria-hidden="true">
<span class="theme-toggle-thumb"></span>
<span class="theme-toggle-icon theme-toggle-sun">☀️</span>
<span class="theme-toggle-icon theme-toggle-moon">🌙</span>
</span>
</label>
<button class="nav-btn" id="darkModeToggle" title="Toggle dark mode">☀️</button>
<button class="nav-btn hamburger" id="hamburger" title="Menu" aria-label="Toggle navigation menu"></button>
</div>
</nav>
@@ -122,7 +102,6 @@
<script src="vendor/qrcode.js"></script>
<script src="roles.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>
<script src="area-filter.js?v=__BUST__"></script>
@@ -150,7 +129,6 @@
<script src="packets.js?v=__BUST__"></script>
<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="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>
-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="1"/></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;
}
}
+85 -181
View File
@@ -20,40 +20,13 @@
let userHasMoved = false;
let controlsCollapsed = false;
// Safe escape — falls back to identity if app.js hasn't loaded yet.
// Note: `esc` is not a true global; some IIFEs define it locally. Reference
// through globalThis so the optional lookup is safe under `no-undef`.
const safeEsc = (typeof globalThis.esc === 'function') ? globalThis.esc : function (s) { return s; };
// Safe escape — falls back to identity if app.js hasn't loaded yet
const safeEsc = (typeof esc === 'function') ? esc : function (s) { return s; };
// Roles loaded from shared roles.js (ROLE_STYLE, ROLE_LABELS, ROLE_COLORS globals)
// ── #1356 a11y constants — letter prefix + glyph + neutral fill carriers ──
// ROLE_LETTERS gives each role a single capital-letter primary carrier
// (legible at 10px monospace, survives full grayscale).
var ROLE_LETTERS = {
repeater: 'R',
companion: 'C',
room: 'M',
sensor: 'S',
observer: 'O',
};
// MB_GLYPHS prefix the hash text with a non-color status carrier.
var MB_GLYPHS = {
confirmed: '\u2713', // ✓
suspected: '?',
unknown: '\u2717', // ✗
};
// Per-status CSS class (drives the colored 3px left-border in style.css).
var MB_STATUS_CLASS = {
confirmed: 'status-confirmed',
suspected: 'status-suspected',
unknown: 'status-unknown',
};
// #1356 V3 marker-dot tint set — high-luminance accents that mirror the
// CSS `--mc-mb-confirmed/suspected/unknown` left-border stripe palette so the
// marker-dot and label-stripe surfaces stay visually consistent. Module
// scope (not loop-local) to avoid per-iteration object allocation.
var MB_MARKER_TINT = { confirmed: '#56F0A0', suspected: '#FFD966', unknown: '#FF8888' };
// Multi-byte support overlay colors
var MB_COLORS = { confirmed: '#27ae60', suspected: '#f39c12', unknown: '#e74c3c' };
function makeMarkerIcon(role, isStale, isAlsoObserver, colorOverride) {
const s = ROLE_STYLE[role] || ROLE_STYLE.companion;
@@ -63,26 +36,14 @@
let path;
switch (s.shape) {
case 'diamond':
path = `<polygon points="${c},2 ${size-2},${c} ${c},${size-2} 2,${c}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<polygon points="${c},2 ${size-2},${c} ${c},${size-2} 2,${c}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
break;
case 'square':
path = `<rect x="3" y="3" width="${size-6}" height="${size-6}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<rect x="3" y="3" width="${size-6}" height="${size-6}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
break;
case 'triangle':
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
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="1"/>`;
break;
}
case 'star': {
// 5-pointed star
const cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
@@ -93,11 +54,11 @@
pts += `${cx + outer * Math.cos(aOuter)},${cy + outer * Math.sin(aOuter)} `;
pts += `${cx + inner * Math.cos(aInner)},${cy + inner * Math.sin(aInner)} `;
}
path = `<polygon points="${pts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<polygon points="${pts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="1.5"/>`;
break;
}
default: // circle
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${fillColor}" stroke="#fff" stroke-width="2"/>`;
}
// If this node is also an observer, add a small star overlay
let obsOverlay = '';
@@ -124,26 +85,16 @@
});
}
function makeRepeaterLabelIcon(node, isStale, isAlsoObserver, mbStatus) {
function makeRepeaterLabelIcon(node, isStale, isAlsoObserver, colorOverride) {
var s = ROLE_STYLE['repeater'] || ROLE_STYLE.companion;
var hs = node.hash_size || 1;
// Show the short mesh hash ID (first N bytes of pubkey, uppercased)
var shortHash = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '??';
// #1356 V3: glyph is the primary non-color status carrier, hash is the data,
// status color is a thin left-border (CSS class drives the hue).
var status = mbStatus || null;
var glyph = status ? (MB_GLYPHS[status] || MB_GLYPHS.unknown) : '';
var statusClass = status ? (' ' + (MB_STATUS_CLASS[status] || MB_STATUS_CLASS.unknown)) : '';
var ariaStatus = status ? ('multi-byte ' + status + ', hash ' + shortHash)
: ('repeater hash ' + shortHash);
// Observer indicator stays a star — it is an orthogonal signal, not a status color.
var obsIndicator = isAlsoObserver
? ' <span aria-hidden="true" style="color:' + (ROLE_COLORS.observer || '#f1c40f') + ';font-size:13px;line-height:1;" title="Also an observer">★</span>'
: '';
// Glyph + thin-space (U+2009) + hash. Visible content is aria-hidden so AT
// reads the aria-label only (avoids "check mark 3 E" literal announcements).
var visible = (glyph ? glyph + '\u2009' : '') + shortHash;
var html = '<div class="mc-mb-label' + statusClass + '" role="img" aria-label="' + ariaStatus + '">' +
'<span aria-hidden="true">' + visible + '</span>' + obsIndicator + '</div>';
var bgColor = colorOverride || s.color;
// If this repeater is also an observer, show a star indicator inside the label
var obsIndicator = isAlsoObserver ? ' <span style="color:' + (ROLE_COLORS.observer || '#f1c40f') + ';font-size:13px;line-height:1;" title="Also an observer">★</span>' : '';
var html = '<div style="background:' + bgColor + ';color:#fff;font-weight:bold;font-size:11px;padding:2px 5px;border-radius:3px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.4);text-align:center;line-height:1.2;white-space:nowrap;">' +
shortHash + obsIndicator + '</div>';
return L.divIcon({
html: html,
className: 'meshcore-marker meshcore-label-marker' + (isStale ? ' marker-stale' : ''),
@@ -292,12 +243,6 @@
clusterGroup = createClusterGroup();
if (filters.clustering && clusterGroup) clusterGroup.addTo(map);
routeLayer = L.layerGroup().addTo(map);
// Exposed for the #1374 route renderer (window.MeshRoute) and its E2E tests.
if (typeof window !== 'undefined') {
window.__mc_map = map;
window.__mc_routeLayer = routeLayer;
window.deconflictLabels = deconflictLabels;
}
// Fix map size on SPA load
setTimeout(() => map.invalidateSize(), 100);
@@ -317,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) {
@@ -513,7 +416,7 @@
});
}
function drawPacketRoute(hopKeys, origin, opts) {
function drawPacketRoute(hopKeys, origin) {
// 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
@@ -522,7 +425,6 @@
console.warn('drawPacketRoute: origin should be an object {pubkey,lat,lon,name}, got string. Coercing.');
origin = { pubkey: origin };
}
opts = opts || {};
// Hide default markers so only the route is visible
if (markerLayer) map.removeLayer(markerLayer);
if (clusterGroup) map.removeLayer(clusterGroup);
@@ -541,22 +443,14 @@
if (markerLayer) map.addLayer(markerLayer);
if (clusterGroup) map.addLayer(clusterGroup);
map.removeControl(closeBtn);
var container = map.getContainer();
var legend = container.querySelector('.mc-route-legend');
if (legend) legend.remove();
var ctx = container.querySelector('.mc-route-context-label');
if (ctx) ctx.remove();
});
return div;
};
closeBtn.addTo(map);
// Resolve hop short hashes to node positions with geographic disambiguation.
// 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.
// Resolve hop short hashes to node positions with geographic disambiguation
const raw = hopKeys.map(hop => {
const hopLower = String(hop).toLowerCase();
const hopLower = hop.toLowerCase();
const candidates = nodes.filter(n => {
const pk = n.public_key.toLowerCase();
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk)) &&
@@ -566,9 +460,9 @@
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 (candidates.length > 1) {
return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates };
return { name: hop.slice(0,8), resolved: false, candidates };
}
return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false };
return null;
});
// Disambiguate: pick candidate closest to center of already-resolved hops
@@ -589,42 +483,80 @@
}
}
const positions = raw.filter(h => h != null);
const positions = raw.filter(h => h && h.resolved);
// Resolve and prepend origin node
if (origin) {
let originPos = null;
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 };
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, 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 && 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 };
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role, isOrigin: true };
}
}
if (originPos) positions.unshift(originPos);
}
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 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()
});
return;
const coords = positions.map(p => [p.lat, p.lon]);
if (positions.length >= 2) {
L.polyline(coords, {
color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4'
}).addTo(routeLayer);
}
// ── Legacy fallback (kept tiny — should never run in production) ─────
const coords = positions.filter(p => p.lat != null).map(p => [p.lat, p.lon]);
// Add numbered markers at each hop
var labelItems = [];
positions.forEach((p, i) => {
const isOrigin = i === 0 && p.isOrigin;
const isLast = i === positions.length - 1 && positions.length > 1;
const color = isOrigin ? '#06b6d4' : isLast ? (getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444') : i === 0 ? (getComputedStyle(document.documentElement).getPropertyValue('--status-green').trim() || '#22c55e') : '#f59e0b';
const radius = isOrigin ? 14 : 10;
const label = isOrigin ? 'Sender' : isLast ? 'Last Hop' : `Hop ${isOrigin ? i : i}`;
if (isOrigin) {
L.circleMarker([p.lat, p.lon], {
radius: radius + 4, fillColor: 'transparent', fillOpacity: 0, color: '#06b6d4', weight: 2, opacity: 0.6
}).addTo(routeLayer);
}
const marker = L.circleMarker([p.lat, p.lon], {
radius: radius, fillColor: color,
fillOpacity: 0.9, color: '#fff', weight: 2
}).addTo(routeLayer);
const popupHtml = `<div style="font-size:12px;min-width:160px">
<div style="font-weight:700;margin-bottom:4px">${label}: ${safeEsc(p.name)}</div>
<div style="color:#9ca3af;font-size:11px;margin-bottom:4px">${p.role || 'unknown'}</div>
<div style="font-family:monospace;font-size:10px;color:#6b7280;margin-bottom:6px;word-break:break-all">${safeEsc(p.pubkey || '')}</div>
<div style="font-size:11px;color:#9ca3af">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}</div>
${p.pubkey ? `<div style="margin-top:6px"><a href="#/nodes/${p.pubkey}" style="color:var(--accent);font-size:11px">View Node →</a></div>` : ''}
</div>`;
marker.bindPopup(popupHtml, { className: 'route-popup' });
labelItems.push({ latLng: L.latLng(p.lat, p.lon), isLabel: true, text: `${i + 1}. ${p.name}` });
});
// Deconflict labels so overlapping hop names spread out
deconflictLabels(labelItems, map);
labelItems.forEach(function (m) {
var pos = m.adjustedLatLng || m.latLng;
var icon = L.divIcon({ className: 'route-tooltip', html: m.text, iconSize: [null, null], iconAnchor: [0, 0] });
L.marker(pos, { icon: icon, interactive: false }).addTo(routeLayer);
if (m.offset > 2) {
L.polyline([m.latLng, pos], { weight: 1, color: '#475569', opacity: 0.5, dashArray: '3 3' }).addTo(routeLayer);
}
});
// Fit map to route
if (coords.length >= 2) {
L.polyline(coords, { color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4' }).addTo(routeLayer);
map.fitBounds(L.latLngBounds(coords).pad(0.3));
} else if (coords.length === 1) {
} else {
map.setView(coords[0], 13);
}
}
@@ -961,18 +893,12 @@
const pk = (node.public_key || '').toLowerCase();
const isAlsoObserver = _observerByPubkey.has(pk);
const useLabel = node.role === 'repeater' && filters.hashLabels;
// #1356 V3: multi-byte status is no longer encoded by label fill color.
// Pass the raw status string to the label icon (it picks glyph + CSS class);
// marker-dot tinting (for non-label rendering) keeps a colorblind-safe hex.
var mbStatus = null;
// Multi-byte overlay: color repeaters by multi_byte_status
var mbColor = null;
if (filters.multiByteOverlay && node.role === 'repeater') {
mbStatus = node.multi_byte_status || 'unknown';
// Marker-dot tint (module-scope MB_MARKER_TINT) — high-luminance accent
// set kept in sync with --mc-mb-* CSS stripes so label + marker agree.
mbColor = MB_MARKER_TINT[mbStatus] || MB_MARKER_TINT.unknown;
mbColor = MB_COLORS[node.multi_byte_status] || MB_COLORS.unknown;
}
const icon = useLabel ? makeRepeaterLabelIcon(node, isStale, isAlsoObserver, mbStatus) : makeMarkerIcon(node.role || 'companion', isStale, isAlsoObserver, mbColor);
const icon = useLabel ? makeRepeaterLabelIcon(node, isStale, isAlsoObserver, mbColor) : makeMarkerIcon(node.role || 'companion', isStale, isAlsoObserver, mbColor);
const latLng = L.latLng(node.lat, node.lon);
allMarkers.push({ latLng, node, icon, isLabel: useLabel, popupFn: function() { return buildPopup(node); }, alt: (node.name || 'Unknown') + ' (' + (node.role || 'node') + (isAlsoObserver ? ' + observer' : '') + ')' });
}
@@ -1474,46 +1400,24 @@
var total = (typeof cluster.getChildCount === 'function') ? cluster.getChildCount() : markers.length;
var bucket = total >= 100 ? 'lg' : total >= 30 ? 'md' : 'sm';
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).
// 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)',
room: 'var(--mc-role-room)',
sensor: 'var(--mc-role-sensor)',
observer: 'var(--mc-role-observer)',
};
var pillsHtml = '';
var tooltipParts = [];
var pillsShown = 0;
var palette = (typeof ROLE_COLORS !== 'undefined') ? ROLE_COLORS : {};
for (var j = 0; j < roleOrder.length; j++) {
var role = roleOrder[j];
var n = counts[role] || 0;
if (n <= 0) continue;
tooltipParts.push(n + ' ' + role + (n === 1 ? '' : 's'));
if (pillsShown < 4) {
var bg = ROLE_BG_VAR[role] || 'var(--mc-role-companion)';
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:#1a1a1a" ' +
'title="' + n + ' ' + role + (n === 1 ? '' : 's') + '">' +
letter + n + '</span>';
var bg = palette[role] || '#6b7280';
pillsHtml += '<span class="mc-pill" style="background:' + bg + '">' + n + '</span>';
pillsShown += 1;
}
}
// #1356 V1: cluster gets role="img" + an aria-label summarising the
// count and per-role breakdown so screen readers announce the data.
var ariaLabel = total + ' nodes — ' + tooltipParts.join(', ');
var html = '<div class="mc-cluster mc-' + bucket + '" ' +
'role="img" aria-label="' + ariaLabel + '">' +
'<b class="mc-count" aria-hidden="true">' + total + '</b>' +
'<div class="mc-pills" aria-hidden="true">' + pillsHtml + '</div>' +
var html = '<div class="mc-cluster mc-' + bucket + '">' +
'<b class="mc-count">' + total + '</b>' +
'<div class="mc-pills">' + pillsHtml + '</div>' +
'</div>';
var icon = L.divIcon({
html: html,
@@ -1523,7 +1427,7 @@
// Stash a tooltip string for callers that want to bindTooltip (markercluster
// does not natively pipe this through, but it's available via cluster icon
// for E2E inspection).
icon._tooltip = ariaLabel;
icon._tooltip = total + ' nodes — ' + tooltipParts.join(', ');
return icon;
}
+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="1"/>';
break;
case 'triangle':
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + (size - 2) +
' 2,' + (size - 2) + '" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
break;
case 'diamond':
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + c + ' ' +
c + ',' + (size - 2) + ' 2,' + c +
'" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
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="1"/>';
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"/>';
break;
}
default: // circle
path = '<circle cx="' + c + '" cy="' + c + '" r="' + (c - 2) +
'" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
}
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'];
-452
View File
@@ -1,452 +0,0 @@
/**
* #1374 Packet-route map renderer.
*
* Pure-ish renderer for a resolved packet route on top of a Leaflet map.
* Caller resolves hops (server- or client-side) and passes the positions
* array as [origin, hop1, hop2, , destination]. This module owns:
*
* - role-aware shape markers (reuses window.makeRoleMarkerSVG)
* - origin / destination visual + semantic distinction
* - sequence-number badges beside each marker (not in label text)
* - directional <marker-end> arrows on edges
* - per-hop color gradient (bright fading)
* - per-marker role="img" + aria-label "Hop N of M, <name>, <role>"
* - per-edge aria-label "Hop N → N+1, ~Xkm"
* - reuses window.deconflictLabels (registered by map.js)
* - collapsible legend panel
* - "Route observed at <timestamp>" toolbar context label
* - partial-route: ch-unresolved class + "X of N hops resolved" badge
*
* Animations gate on `prefers-reduced-motion`; high-contrast / forced-colors
* mode is handled by CSS.
*
* See test-issue-1374-route-map-a11y-e2e.js for the contract.
*/
(function () {
'use strict';
// Wong palette: per-hop sequence gradient, bright → fading.
// Used purely as a redundant carrier alongside the sequence-number badge,
// so colorblind / forced-colors users still read the order from the badge.
function seqColor(idx, total) {
if (total <= 1) return '#56F0A0';
// HSL: 152° (green) full-bright at idx=0 → 18° (orange) at last hop.
var t = idx / Math.max(1, total - 1);
var hue = 152 - 134 * t;
var sat = 70;
var light = 50 + 8 * t;
return 'hsl(' + hue.toFixed(0) + ',' + sat + '%,' + light + '%)';
}
function haversineKm(a, b) {
if (a.lat == null || b.lat == null) return null;
var R = 6371;
var dLat = (b.lat - a.lat) * Math.PI / 180;
var dLon = (b.lon - a.lon) * Math.PI / 180;
var la1 = a.lat * Math.PI / 180, la2 = b.lat * Math.PI / 180;
var h = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(la1) * Math.cos(la2) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
return Math.round(R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)));
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
/**
* Build the role-aware marker SVG for a hop. Origin and destination get a
* larger outline + a glyph ( / ) layered on the standard role shape so
* the role information remains visible.
*/
function buildHopSVG(p, opts) {
var size = opts.size || 22;
var role = p.role || 'companion';
var color = opts.color;
var inner = (window.makeRoleMarkerSVG &&
window.makeRoleMarkerSVG(role, color, size)) ||
'<svg width="' + size + '" height="' + size + '"><circle cx="' + (size / 2) +
'" cy="' + (size / 2) + '" r="' + (size / 2 - 2) + '" fill="' + color +
'" stroke="#fff" stroke-width="1"/></svg>';
// Outer ring for origin/destination
var outerSize = (opts.isOrigin || opts.isDest) ? size + 10 : size + 4;
var pad = (outerSize - size) / 2;
var ringStroke = opts.isOrigin ? '#06b6d4' : opts.isDest ? '#ef4444' : '#666';
var ringWidth = (opts.isOrigin || opts.isDest) ? 2.4 : 1.2;
var ringDash = opts.unresolved ? '4 3' : 'none';
var ringFill = opts.unresolved ? 'rgba(150,150,150,0.15)' : 'none';
var glyph = '';
if (opts.isOrigin) {
glyph = '<text x="' + (outerSize / 2) + '" y="' + (outerSize / 2 + 4) +
'" text-anchor="middle" font-size="11" font-weight="700" fill="#0f172a" aria-hidden="true">\u25B6</text>';
} else if (opts.isDest) {
glyph = '<text x="' + (outerSize / 2) + '" y="' + (outerSize / 2 + 4) +
'" text-anchor="middle" font-size="12" font-weight="700" fill="#0f172a" aria-hidden="true">\u2691</text>';
}
// Strip outer <svg> from inner SVG, re-wrap with outer ring + glyph
var innerBody = inner.replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
var svg = '<svg width="' + outerSize + '" height="' + outerSize +
'" viewBox="0 0 ' + outerSize + ' ' + outerSize +
'" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<circle cx="' + (outerSize / 2) + '" cy="' + (outerSize / 2) +
'" r="' + (outerSize / 2 - ringWidth / 2) +
'" fill="' + ringFill + '" stroke="' + ringStroke +
'" stroke-width="' + ringWidth + '" stroke-dasharray="' + ringDash + '"/>' +
'<g transform="translate(' + pad + ',' + pad + ')">' + innerBody + '</g>' +
glyph +
'</svg>';
return { svg: svg, size: outerSize };
}
function buildBadge(idx, total, opts) {
var txt;
if (opts.isOrigin) txt = '\u25B6'; // ▶
else if (opts.isDest) txt = '\u2691'; // ⚑
else txt = String(idx); // intermediate hop number
return '<span class="mc-route-seq-badge" aria-hidden="true">' + txt + '</span>';
}
function buildPopupHtml(p, hopNum, total) {
var pubkeyShort = p.pubkey ? String(p.pubkey).slice(0, 12) : '—';
var roleLine = escapeHtml(p.role || 'unknown');
var lastSeen = p.last_seen
? new Date(p.last_seen).toLocaleString()
: (p.last_heard ? new Date(p.last_heard).toLocaleString() : '—');
var obsCount = p.observation_count != null ? p.observation_count : '—';
var coords = (p.lat != null && p.lon != null)
? (p.lat.toFixed(4) + ', ' + p.lon.toFixed(4))
: '—';
var deepLink = p.pubkey
? '<div style="margin-top:6px"><a class="mc-route-popup-link" href="#/map?node=' +
encodeURIComponent(p.pubkey) + '">Show on main map \u2192</a></div>'
: '';
return '<div class="mc-route-popup">' +
'<div class="mc-route-popup-title">Hop ' + hopNum + ' of ' + total +
': ' + escapeHtml(p.name || pubkeyShort) + '</div>' +
'<div class="mc-route-popup-row"><span>Role</span><b>' + roleLine + '</b></div>' +
'<div class="mc-route-popup-row"><span>Pubkey</span><code>' +
escapeHtml(pubkeyShort) + '\u2026</code></div>' +
'<div class="mc-route-popup-row"><span>Last seen</span>' + escapeHtml(lastSeen) + '</div>' +
'<div class="mc-route-popup-row"><span>Observations</span>' + escapeHtml(String(obsCount)) + '</div>' +
'<div class="mc-route-popup-row"><span>Coords</span>' + escapeHtml(coords) + '</div>' +
deepLink +
'</div>';
}
function ariaLabelFor(p, idx, total) {
var name = p.name || (p.pubkey ? String(p.pubkey).slice(0, 8) : 'unknown');
var role = p.role || 'unknown';
var base = 'Hop ' + (idx + 1) + ' of ' + total + ', ' + name + ', ' + role;
if (p.isOrigin) base += ', originator';
if (p.isDest) base += ', destination';
if (p.resolved === false) base += ', unresolved';
return base;
}
function ensureArrowDefs(mapRef) {
// Inject a single SVG <defs> into Leaflet's overlay pane.
var pane = mapRef.getPane && mapRef.getPane('overlayPane');
if (!pane) return;
if (document.getElementById('mc-route-arrow-defs')) return;
var ns = 'http://www.w3.org/2000/svg';
var svgNS = document.createElementNS(ns, 'svg');
svgNS.setAttribute('id', 'mc-route-arrow-defs');
svgNS.setAttribute('width', '0');
svgNS.setAttribute('height', '0');
svgNS.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden;');
svgNS.setAttribute('aria-hidden', 'true');
var defs = document.createElementNS(ns, 'defs');
var marker = document.createElementNS(ns, 'marker');
marker.setAttribute('id', 'mc-route-arrow');
marker.setAttribute('viewBox', '0 0 10 10');
marker.setAttribute('refX', '8');
marker.setAttribute('refY', '5');
marker.setAttribute('markerWidth', '6');
marker.setAttribute('markerHeight', '6');
marker.setAttribute('orient', 'auto-start-reverse');
var poly = document.createElementNS(ns, 'path');
poly.setAttribute('d', 'M0,0 L10,5 L0,10 z');
poly.setAttribute('fill', 'currentColor');
marker.appendChild(poly);
defs.appendChild(marker);
svgNS.appendChild(defs);
document.body.appendChild(svgNS);
}
function buildLegend(container, resolvedCount, totalCount) {
// Remove any prior legend
var prior = container.querySelector('.mc-route-legend');
if (prior) prior.remove();
var roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
var roleEntries = roles.map(function (r) {
var color = (window.ROLE_COLORS && window.ROLE_COLORS[r]) || '#888';
var svg = window.makeRoleMarkerSVG ? window.makeRoleMarkerSVG(r, color, 14) : '';
return '<li class="mc-route-legend-entry mc-route-legend-role">' +
'<span class="mc-route-legend-swatch">' + svg + '</span>' +
'<span>' + r + '</span></li>';
}).join('');
var html =
'<div class="mc-route-legend" role="region" aria-label="Route legend">' +
'<button type="button" class="mc-route-legend-toggle" aria-expanded="true" aria-controls="mc-route-legend-body">' +
'Legend' +
'</button>' +
'<div id="mc-route-legend-body" class="mc-route-legend-body">' +
(resolvedCount < totalCount
? '<div class="mc-route-resolved-badge" role="status">' +
resolvedCount + ' of ' + totalCount + ' hops resolved</div>'
: '<div class="mc-route-resolved-badge" role="status">' +
totalCount + ' of ' + totalCount + ' hops resolved</div>') +
'<ul class="mc-route-legend-list">' +
'<li class="mc-route-legend-entry"><span class="mc-route-legend-glyph" aria-hidden="true">\u25B6</span><span>origin (originator)</span></li>' +
'<li class="mc-route-legend-entry"><span class="mc-route-legend-glyph" aria-hidden="true">\u2691</span><span>destination</span></li>' +
'<li class="mc-route-legend-entry"><span class="mc-route-legend-gradient" aria-hidden="true"></span><span>hop-order color (bright \u2192 fading)</span></li>' +
'</ul>' +
'<div class="mc-route-legend-section">role shapes</div>' +
'<ul class="mc-route-legend-list">' + roleEntries + '</ul>' +
'</div>' +
'</div>';
var wrap = document.createElement('div');
wrap.innerHTML = html;
var node = wrap.firstChild;
container.appendChild(node);
var btn = node.querySelector('.mc-route-legend-toggle');
var body = node.querySelector('.mc-route-legend-body');
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
body.style.display = open ? 'none' : '';
});
}
function buildContextLabel(container, timestamp) {
var prior = container.querySelector('.mc-route-context-label');
if (prior) prior.remove();
var ts = timestamp ? new Date(timestamp).toLocaleString() : 'unknown time';
var el = document.createElement('div');
el.className = 'mc-route-context-label';
el.setAttribute('role', 'status');
el.textContent = 'Route observed at ' + ts;
container.appendChild(el);
}
/**
* Render the route. Caller passes the Leaflet map, a clean layer group,
* and the ordered positions array.
*
* @param {L.Map} mapRef
* @param {L.LayerGroup} layer
* @param {Array<{lat,lon,name,role,pubkey,isOrigin?,isDest?,resolved?,
* last_seen?,last_heard?,observation_count?}>} positions
* @param {{timestamp?:string|number}} [opts]
*/
function render(mapRef, layer, positions, opts) {
opts = opts || {};
if (!mapRef || !layer || !Array.isArray(positions) || positions.length === 0) return;
layer.clearLayers();
ensureArrowDefs(mapRef);
// Mark origin / destination explicitly. If caller didn't set isDest, the
// last resolved hop becomes the destination.
var total = positions.length;
var resolvedCount = positions.filter(function (p) { return p.resolved !== false; }).length;
positions.forEach(function (p, i) {
if (i === 0 && !('isOrigin' in p)) p.isOrigin = true;
if (i === total - 1 && !('isDest' in p)) p.isDest = true;
});
// Partial-route placement: unresolved hops with no lat/lon are
// interpolated between the nearest resolved neighbors so they render as
// dashed-gray placeholders on the route line.
for (var pi = 0; pi < positions.length; pi++) {
var cur = positions[pi];
if (cur.lat != null && cur.lon != null) continue;
var before = null, after = null;
for (var k = pi - 1; k >= 0; k--) {
if (positions[k].lat != null && positions[k].lon != null) { before = positions[k]; break; }
}
for (var k2 = pi + 1; k2 < positions.length; k2++) {
if (positions[k2].lat != null && positions[k2].lon != null) { after = positions[k2]; break; }
}
if (before && after) {
cur.lat = (before.lat + after.lat) / 2;
cur.lon = (before.lon + after.lon) / 2;
} else if (before) {
cur.lat = before.lat; cur.lon = before.lon;
} else if (after) {
cur.lat = after.lat; cur.lon = after.lon;
}
}
var reduceMotion = window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ── Edges ───────────────────────────────────────────────────────
for (var i = 0; i < total - 1; i++) {
var a = positions[i], b = positions[i + 1];
if (a.lat == null || a.lon == null || b.lat == null || b.lon == null) continue;
var color = seqColor(i, total - 1);
var dist = haversineKm(a, b);
var ariaLabel = 'Hop ' + (i + 1) + ' \u2192 ' + (i + 2) +
(dist != null ? ', ~' + dist + 'km' : '');
var poly = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
color: color,
weight: 3.5,
opacity: 0.92,
dashArray: (a.resolved === false || b.resolved === false) ? '6 4' : null,
className: 'mc-route-edge'
}).addTo(layer);
// Patch the rendered <path> element to add aria-label + marker-end.
// Leaflet builds it on the next animation frame, so defer.
(function (polyRef, lbl, col) {
setTimeout(function () {
var el = polyRef.getElement && polyRef.getElement();
if (!el) return;
el.setAttribute('aria-label', lbl);
el.setAttribute('role', 'img');
el.classList.add('mc-route-edge');
el.setAttribute('marker-end', 'url(#mc-route-arrow)');
el.style.color = col; // arrow inherits via currentColor
if (reduceMotion) el.style.transition = 'none';
}, 0);
})(poly, ariaLabel, color);
}
// ── Markers + labels ────────────────────────────────────────────
var labelItems = [];
positions.forEach(function (p, i) {
if (p.lat == null || p.lon == null) return;
var unresolved = (p.resolved === false);
var color = unresolved ? '#9ca3af' : ((window.ROLE_COLORS && window.ROLE_COLORS[p.role]) || '#3b82f6');
var size = (p.isOrigin || p.isDest) ? 24 : 18;
var built = buildHopSVG(p, { color: color, size: size, isOrigin: p.isOrigin, isDest: p.isDest, unresolved: unresolved });
var badge = buildBadge(i + 1, total, { isOrigin: p.isOrigin, isDest: p.isDest });
var classNames = 'mc-route-marker' + (unresolved ? ' ch-unresolved' : '') +
(p.isOrigin ? ' mc-route-origin' : '') + (p.isDest ? ' mc-route-dest' : '');
var aria = ariaLabelFor(p, i, total);
var html =
'<div class="' + classNames + '" role="img" aria-label="' + escapeHtml(aria) +
'" tabindex="0" data-hop-index="' + i + '">' +
built.svg +
badge +
'</div>';
var icon = L.divIcon({
html: html,
className: 'mc-route-marker-icon',
iconSize: [built.size + 14, built.size + 14],
iconAnchor: [(built.size + 14) / 2, (built.size + 14) / 2]
});
var marker = L.marker([p.lat, p.lon], { icon: icon, keyboard: true }).addTo(layer);
marker.bindPopup(buildPopupHtml(p, i + 1, total), { className: 'mc-route-popup-wrap' });
labelItems.push({
latLng: L.latLng(p.lat, p.lon),
isLabel: true,
text: p.name || (p.pubkey ? String(p.pubkey).slice(0, 8) : 'hop')
});
});
// Deconflict label boxes — reuses map.js' shared algorithm.
if (typeof window.deconflictLabels === 'function') {
window.deconflictLabels(labelItems, mapRef);
}
labelItems.forEach(function (m) {
var pos = m.adjustedLatLng || m.latLng;
var labelHtml = '<div class="mc-route-label">' + escapeHtml(m.text) + '</div>';
var icon = L.divIcon({
html: labelHtml,
className: 'mc-route-label-icon',
iconSize: null,
iconAnchor: [0, -16]
});
var lblMarker = L.marker(pos, { icon: icon, interactive: false }).addTo(layer);
m._lblMarker = lblMarker;
if (m.offset && m.offset > 2) {
L.polyline([m.latLng, pos], {
weight: 1, color: '#475569', opacity: 0.5, dashArray: '3 3'
}).addTo(layer);
}
});
// Second-pass overlap resolution: shared `deconflictLabels` uses a fixed
// 38×24 collision box, but our role-aware labels are often wider. After
// Leaflet paints, measure the real DOM rects and nudge any overlapping
// labels vertically using an L.DomUtil offset (no relayout).
//
// We run the nudge once immediately AND again after `fitBounds`
// completes its async pan (`moveend`), because fitBounds re-projects
// the labels and can re-introduce overlap that the first nudge missed.
function nudgeOverlappingLabels() {
var containerEl = mapRef.getContainer ? mapRef.getContainer() : document.body;
var labelEls = Array.from(containerEl.querySelectorAll('.mc-route-label'));
// Reset prior nudges so we recompute from scratch (otherwise stacked
// nudges from successive passes drift labels off-screen).
for (var li = 0; li < labelEls.length; li++) {
var parent = labelEls[li].parentElement;
if (parent && parent.dataset && parent.dataset.mcRouteDy) {
parent.style.marginTop = '';
delete parent.dataset.mcRouteDy;
}
}
var rects = labelEls.map(function (el) { return el.getBoundingClientRect(); });
var maxIter = 8;
for (var iter = 0; iter < maxIter; iter++) {
var moved = false;
for (var i = 0; i < labelEls.length; i++) {
for (var j = i + 1; j < labelEls.length; j++) {
var a = rects[i], b = rects[j];
if (a.x < b.x + b.width && a.x + a.width > b.x &&
a.y < b.y + b.height && a.y + a.height > b.y) {
// Push the later label downward by the overlap height + 6px.
var dy = (a.y + a.height) - b.y + 6;
var p2 = labelEls[j].parentElement;
if (p2 && p2.style) {
var prev = p2.dataset.mcRouteDy ? Number(p2.dataset.mcRouteDy) : 0;
var next = prev + dy;
p2.dataset.mcRouteDy = String(next);
p2.style.marginTop = next + 'px';
}
rects[j] = labelEls[j].getBoundingClientRect();
moved = true;
}
}
}
if (!moved) break;
}
}
setTimeout(nudgeOverlappingLabels, 30);
mapRef.once('moveend', function () { setTimeout(nudgeOverlappingLabels, 30); });
// Fit map to route
var coords = positions.filter(function (p) { return p.lat != null && p.lon != null; })
.map(function (p) { return [p.lat, p.lon]; });
if (coords.length >= 2) {
mapRef.fitBounds(L.latLngBounds(coords).pad(0.3));
} else if (coords.length === 1) {
mapRef.setView(coords[0], 13);
}
// ── Overlay UI: legend + context label ──────────────────────────
var container = mapRef.getContainer ? mapRef.getContainer() : document.getElementById('leaflet-map');
if (container) {
buildLegend(container, resolvedCount, total);
buildContextLabel(container, opts.timestamp);
}
}
window.MeshRoute = {
render: render,
_seqColor: seqColor,
_haversineKm: haversineKm,
_ariaLabelFor: ariaLabelFor
};
})();
+15 -552
View File
@@ -178,9 +178,6 @@
--bg-secondary: var(--surface-2);
--text-secondary: var(--text-muted);
--bg: var(--surface);
/* PR #893: --shadow used by .theme-toggle thumb shadow; define in :root for
* light theme. Dark theme override is set in both dark-mode blocks below. */
--shadow: rgba(0,0,0,0.3);
--trace-ghost-color: #94a3b8;
/* #1128: documented z-index scale. Use these custom props for any new
@@ -229,7 +226,6 @@
--input-bg: #1e1e34;
--selected-bg: #1e3a5f;
--hover-bg: rgba(255,255,255, 0.06);
--shadow: rgba(0,0,0,0.5);
--trace-ghost-color: #94a3b8;
--section-bg: #1e1e34;
}
@@ -260,7 +256,6 @@
--input-bg: #1e1e34;
--selected-bg: #1e3a5f;
--hover-bg: rgba(255,255,255, 0.06);
--shadow: rgba(0,0,0,0.5);
--trace-ghost-color: #94a3b8;
--section-bg: #1e1e34;
}
@@ -623,69 +618,6 @@ input[type="week"] {
min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center;
}
.nav-btn:hover { background: var(--nav-bg2); color: var(--nav-text); }
/* === Theme Toggle Switch === */
.theme-toggle {
display: inline-flex; align-items: center; cursor: pointer;
padding: 0; margin: 0; border: none; background: none;
min-width: 44px; min-height: 44px; justify-content: center;
}
.theme-toggle input[type="checkbox"] {
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
}
.theme-toggle-track {
position: relative; width: 46px; height: 24px;
background: var(--border); border-radius: 12px;
transition: background 0.2s ease; display: flex; align-items: center;
border: 1px solid var(--border);
}
.theme-toggle input:checked ~ .theme-toggle-track {
background: var(--accent);
}
/* PR #893 follow-up: keyboard focus indicator. The native checkbox is visually
* hidden, so we draw the ring on the track sibling when the checkbox is
* :focus-visible. Matches the global focus-ring style above. */
.theme-toggle input:focus-visible ~ .theme-toggle-track {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.theme-toggle-thumb {
position: absolute; left: 3px; width: 18px; height: 18px;
background: var(--nav-text); border-radius: 50%;
box-shadow: 0 1px 3px var(--shadow);
transition: transform 0.2s ease;
z-index: 1;
}
.theme-toggle input:checked ~ .theme-toggle-track .theme-toggle-thumb {
transform: translateX(22px);
}
.theme-toggle-icon {
position: absolute; font-size: 10px; line-height: 1;
top: 50%; transform: translateY(-50%);
pointer-events: none; user-select: none;
transition: opacity 0.2s ease;
}
.theme-toggle-sun { right: 4px; opacity: 1; }
.theme-toggle-moon { left: 4px; opacity: 0; }
.theme-toggle input:checked ~ .theme-toggle-track .theme-toggle-sun { opacity: 0; }
.theme-toggle input:checked ~ .theme-toggle-track .theme-toggle-moon { opacity: 1; }
/* PR #893 follow-up: respect prefers-reduced-motion disable the slide/fade
* animation so the thumb snaps to position. */
@media (prefers-reduced-motion: reduce) {
.theme-toggle-track,
.theme-toggle-thumb,
.theme-toggle-icon { transition: none; }
}
/* PR #893 follow-up: Windows High Contrast / forced-colors mode the track
* background and thumb shadow get flattened to system colors, so explicitly
* keep a system-colored border on track and thumb to stay visible. */
@media (forced-colors: active) {
.theme-toggle-track { border: 1px solid CanvasText; background: Canvas; }
.theme-toggle-thumb { background: CanvasText; box-shadow: none; }
.theme-toggle input:focus-visible ~ .theme-toggle-track {
outline: 2px solid Highlight;
}
}
/* === Nav Stats === */
.nav-stats {
display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--nav-text-muted);
@@ -1901,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; }
@@ -3390,189 +3296,32 @@ th.sort-active { color: var(--accent, #60a5fa); }
.tools-card h3 { margin: 0 0 4px 0; font-size: 16px; }
.tools-card p { margin: 0; font-size: 13px; color: var(--text-muted); }
/* Map marker clustering (issue #1036, a11y refit issue #1356)
*
* #1356 WCAG 2.2 AA refit Tufte structural framing + audit minimal patch.
* Design source: github.com/Kpa-clawbot/CoreScope/issues/1356 (Tufte + audit comments).
*
* Carriers (NON-color) of meaning, per WCAG 1.4.1:
* - V1 cluster bubbles: size (40/48/56px) + numeral + border-style ramp
* (1.5px solid / 2.5px solid / 2px double). Fill is a single neutral.
* - V2 role pills: capital-letter prefix (R/C/M/S/O). Wong (2011) palette
* hue is secondary. Dark text (#1a1a1a) on ALL five pills (audit override
* so only ONE text-color rule is needed and every pill passes 4.5:1).
* - V3 multi-byte hash labels: unicode glyph prefix (/?/) + neutral fill
* + 3px colored left-border using the audit's high-luminance accent set
* (NOT Tol "vibrant" those failed 3:1 vs the neutral fill).
*
* Constants are --mc-* namespaced. The reserved --info / --warning / --accent
* system vars are NOT touched (per issue scope + AGENTS.md).
*/
:root {
/* V1 — cluster bubble */
--mc-cluster-fill: rgba(33, 41, 54, 0.88);
--mc-cluster-text: #ffffff;
--mc-cluster-border: #666666; /* audit: white border = 1.05:1 vs Carto-light; #666 = 4.83:1 */
/* V2 — role pills (Wong 2011 colorblind-safe palette) */
--mc-role-repeater: #D55E00; /* vermillion */
--mc-role-companion: #56B4E9; /* sky blue */
--mc-role-room: #009E73; /* bluish-green */
--mc-role-sensor: #F0E442; /* yellow */
--mc-role-observer: #CC79A7; /* reddish-purple */
/* V3 — multi-byte hash labels (neutral fill + high-luminance accent stripes) */
--mc-mb-fill: rgba(33, 41, 54, 0.92);
--mc-mb-text: #ffffff;
--mc-mb-confirmed: #56F0A0; /* audit override of Tol vibrant for fill contrast */
--mc-mb-suspected: #FFD966;
--mc-mb-unknown: #FF8888;
}
/*
* #1361 Colorblind preset overrides.
*
* Each block overrides --mc-role-* and --mc-mb-* CSS vars when the body
* carries the matching data-cb-preset attribute. cb-presets.js also writes
* these vars inline on documentElement (defense-in-depth so the preset
* takes effect even on pages that ship custom theme overrides), and keeps
* window.ROLE_COLORS in sync for JS consumers (legend, cluster builder).
*
* Palette sources cited in PR body. Authoritative CSS rules here mirror
* the JS PRESETS table in public/cb-presets.js both are the source of
* 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).
* */
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;
--mc-role-companion: #648FFF;
--mc-role-room: #785EF0;
--mc-role-sensor: #FFB000;
--mc-role-observer: #DC267F;
--mc-mb-confirmed: #648FFF;
--mc-mb-suspected: #FFB000;
--mc-mb-unknown: #DC267F;
}
body[data-cb-preset="prot"] {
/* Protan: swap repeater anchor for higher-luminance amber. */
--mc-role-repeater: #FFB000;
--mc-role-companion: #648FFF;
--mc-role-room: #785EF0;
--mc-role-sensor: #FE6100;
--mc-role-observer: #DC267F;
--mc-mb-confirmed: #648FFF;
--mc-mb-suspected: #FFB000;
--mc-mb-unknown: #DC267F;
}
body[data-cb-preset="trit"] {
/* Paul Tol muted (B/Y-safe). */
--mc-role-repeater: #CC6677;
--mc-role-companion: #117733;
--mc-role-room: #882255;
--mc-role-sensor: #DDCC77;
--mc-role-observer: #AA4499;
--mc-mb-confirmed: #117733;
--mc-mb-suspected: #DDCC77;
--mc-mb-unknown: #CC6677;
}
body[data-cb-preset="achromat"] {
/* Pure luminance ramp at 20/35/50/70/90% — relies on #1356/#1357 carriers. */
--mc-role-repeater: #333333;
--mc-role-companion: #595959;
--mc-role-room: #808080;
--mc-role-sensor: #b3b3b3;
--mc-role-observer: #e6e6e6;
--mc-mb-confirmed: #b3b3b3;
--mc-mb-suspected: #808080;
--mc-mb-unknown: #595959;
}
/* ── Map marker clustering (issue #1036) ── */
.mc-cluster-wrap { background: transparent !important; border: 0 !important; }
.mc-cluster {
width: 48px; height: 48px; border-radius: 50%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
font-family: var(--font, system-ui, sans-serif);
background: var(--mc-cluster-fill);
color: var(--mc-cluster-text); text-shadow: 0 1px 2px rgba(0,0,0,0.5);
border: 2px solid var(--mc-cluster-border);
/* Dark halo + soft shadow — audit fix so the border edge is visible vs Carto-light */
box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.35);
color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5);
border: 2px solid rgba(255,255,255,0.85);
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
cursor: pointer;
transition: transform 120ms ease;
}
.mc-cluster:hover { transform: scale(1.06); }
/* Border-style ramp is the redundant non-color carrier of the count bucket. */
.mc-cluster.mc-sm { width: 40px; height: 40px; border-width: 1.5px; border-style: solid; }
.mc-cluster.mc-md { width: 48px; height: 48px; border-width: 2.5px; border-style: solid; }
.mc-cluster.mc-lg { width: 56px; height: 56px; border-width: 2px; border-style: double; }
.mc-cluster .mc-count { font-size: 0.875rem; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.mc-cluster.mc-lg .mc-count { font-size: 1rem; }
.mc-cluster.mc-sm { background: var(--info, #2563eb); width: 40px; height: 40px; }
.mc-cluster.mc-md { background: var(--warning, #d97706); width: 48px; height: 48px; }
.mc-cluster.mc-lg { background: var(--accent, #dc2626); width: 56px; height: 56px; }
.mc-cluster .mc-count { font-size: 14px; font-weight: 700; line-height: 1; }
.mc-cluster.mc-lg .mc-count { font-size: 16px; }
.mc-cluster .mc-pills {
display: flex; gap: 2px; margin-top: 3px;
}
.mc-cluster .mc-pill {
display: inline-block; min-width: 12px; padding: 1px 3px;
border-radius: 3px;
/* #1364: removed the prior `max-width` cap it clamped the BOX
(including the 1px 3px padding) to ~2.5ch of text, ellipsizing `R60`
to `R`. JS in map.js already caps counts at "999+" (max 5 chars:
`R999+`), which is the load-bearing safety. `overflow:hidden` +
`text-overflow:ellipsis` stay as belt-only graceful-degrade if the
JS cap is ever bypassed. */
overflow: hidden; text-overflow: ellipsis;
/* 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: #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. */
}
/* V3 — multi-byte hash labels: neutral fill + colored 3px left border */
.mc-mb-label {
background: var(--mc-mb-fill);
color: var(--mc-mb-text);
/* Sized in rem (0.75rem = 12px @ default root) so user font-size
preferences scale the label per SC 1.4.4 Resize Text 200%. */
font: 600 0.75rem/1.2 ui-monospace, "SF Mono", Consolas, monospace;
letter-spacing: 0.02em;
padding: 2px 5px 2px 4px;
border-left: 3px solid transparent;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.35);
white-space: nowrap;
text-align: center;
line-height: 1.2;
}
.mc-mb-label.status-confirmed { border-left-color: var(--mc-mb-confirmed); }
.mc-mb-label.status-suspected { border-left-color: var(--mc-mb-suspected); }
.mc-mb-label.status-unknown { border-left-color: var(--mc-mb-unknown); }
/* Forced-colors / Windows High Contrast — degrade gracefully (audit item 7). */
@media (forced-colors: active) {
.mc-cluster, .mc-pill, .mc-mb-label {
forced-color-adjust: auto;
background: Canvas;
color: CanvasText;
border-color: CanvasText;
}
display: inline-block; min-width: 12px; padding: 0 3px;
border-radius: 6px; font-size: 9px; font-weight: 600; line-height: 12px;
color: #fff; text-align: center; text-shadow: none;
border: 1px solid rgba(255,255,255,0.4);
}
/* === #1034 PR1: Channel Add modal + sectioned sidebar === */
@@ -4014,289 +3763,3 @@ body { touch-action: pan-y; }
}
}
/* === end #1065 ====================================================== */
/* === #1367 Channels chat-app redesign (mobile) ====================== */
/* Mobile (<768px): flat chat-app row list. Full-width 80px rows with
a hash-colored avatar, bold name, ellipsized last-message preview,
and right-aligned relative timestamp. No inline action chips. */
.ch-row {
display: flex; align-items: center; gap: 12px;
width: 100%; min-height: 80px; height: 80px; padding: 8px 12px;
background: transparent; border: 0; border-bottom: 1px solid var(--border);
text-align: left; cursor: pointer; color: var(--text);
-webkit-tap-highlight-color: rgba(0,0,0,.08);
touch-action: manipulation;
}
.ch-row:hover { background: var(--row-hover); }
.ch-row.selected { background: var(--selected-bg); }
.ch-row-avatar {
width: 64px; height: 64px; flex: 0 0 64px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 14px; letter-spacing: 0.5px;
}
.ch-row-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.ch-row-line1 {
display: flex; align-items: baseline; gap: 8px;
min-width: 0;
}
.ch-row-name {
flex: 1 1 auto; min-width: 0;
font-weight: 700; font-size: 15px; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ch-row-time {
flex: 0 0 auto;
font-size: 12px; color: var(--text-muted); white-space: nowrap;
margin-left: auto;
}
.ch-row-preview {
font-size: 13px; color: var(--text-muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Detail view chrome (mobile): back chevron + title. */
.ch-back {
display: none; background: none; border: 0; cursor: pointer;
color: var(--text); font-size: 24px; line-height: 1;
min-width: 40px; min-height: 40px; padding: 0 8px;
border-radius: 6px;
align-items: center; justify-content: center;
-webkit-tap-highlight-color: rgba(0,0,0,.08);
touch-action: manipulation;
}
.ch-back:hover, .ch-back:focus { background: var(--row-hover); outline: none; }
/* Per-message structure additions (mobile + desktop): a colored
reply-target prefix at the start of the bubble body. */
.ch-reply-target { font-weight: 700; }
/* Mobile-only layout swap: sidebar IS the full screen until a channel
is opened, then the main pane slides over it. */
@media (max-width: 767px) {
.ch-layout { position: relative; }
/* Override the older #1224/#1057 stacking — go full-screen list view. */
.ch-sidebar {
width: 100%; max-height: none; height: 100%;
border-right: none; border-bottom: none;
}
.ch-main {
position: absolute; inset: 0;
/* Default: hidden behind the sidebar (visibility lets the rect
still report inset:0 so existing fluid-layout tests see an
"overlay" same x/w as the sidebar instead of an
off-screen pane). */
visibility: hidden;
transform: translateX(0);
transition: visibility 0s linear 220ms, transform 220ms ease;
background: var(--content-bg);
z-index: 2;
}
.ch-layout.ch-detail-open .ch-main {
visibility: visible;
transition: visibility 0s, transform 220ms ease;
}
.ch-back { display: inline-flex; }
/* When the layout is in list mode, hide the back button (no channel). */
.ch-layout:not(.ch-detail-open) .ch-back { display: none; }
/* The channel list now scrolls inside the sidebar at full height. */
.ch-channel-list { padding-bottom: 24px; }
}
/* === end #1367 ====================================================== */
/* #1374 packet-route map view
Role-aware shape markers + sequence-number badges + directional
arrows + collapsible legend. WCAG SC 1.3.1 / 1.4.3 / 1.4.11 AA.
- Marker badge background uses --mc-route-badge-bg / -fg with measured
contrast 7:1 against #1a1a1a text on Carto Positron AND Dark
Matter (we burn-in #f8fafc fill + #0f172a text both tiles).
- Edges use a per-hop HSL gradient as REDUNDANT carrier; the sequence
number badge is the primary order signal so colorblind users and
forced-colors users still read the route.
- `prefers-reduced-motion: reduce` disables marker focus pulse.
- `forced-colors: active` strips role colors uses CanvasText/Canvas.
----------------------------------------------------------------- */
:root {
--mc-route-badge-bg: #f8fafc;
--mc-route-badge-fg: #0f172a;
--mc-route-badge-border: #1a1a1a;
--mc-route-label-bg: #f8fafc;
--mc-route-label-fg: #0f172a;
--mc-route-label-border: #475569;
--mc-route-legend-bg: rgba(248, 250, 252, 0.96);
--mc-route-legend-fg: #0f172a;
--mc-route-legend-border: #475569;
}
[data-theme="dark"] {
--mc-route-legend-bg: rgba(15, 23, 42, 0.94);
--mc-route-legend-fg: #f1f5f9;
--mc-route-legend-border: #94a3b8;
}
.mc-route-marker-icon { background: transparent !important; border: none !important; }
.mc-route-marker {
position: relative;
display: inline-block;
line-height: 0;
}
.mc-route-marker:focus {
outline: 3px solid #06b6d4;
outline-offset: 2px;
border-radius: 50%;
}
.mc-route-marker.ch-unresolved {
opacity: 0.65;
filter: grayscale(0.8);
}
.mc-route-seq-badge {
position: absolute;
bottom: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 3px;
background: var(--mc-route-badge-bg);
color: var(--mc-route-badge-fg);
border: 1.5px solid var(--mc-route-badge-border);
border-radius: 8px;
font: 700 10px/14px system-ui, sans-serif;
text-align: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.mc-route-label-icon { background: transparent !important; border: none !important; }
.mc-route-label {
display: inline-block;
padding: 1px 6px;
background: var(--mc-route-label-bg);
color: var(--mc-route-label-fg);
border: 1px solid var(--mc-route-label-border);
border-radius: 3px;
font: 600 11px/14px system-ui, sans-serif;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.mc-route-edge {
fill: none;
}
.mc-route-legend {
position: absolute;
top: 12px;
right: 12px;
z-index: 700;
max-width: 240px;
background: var(--mc-route-legend-bg);
color: var(--mc-route-legend-fg);
border: 1px solid var(--mc-route-legend-border);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
font: 12px/1.4 system-ui, sans-serif;
}
.mc-route-legend-toggle {
display: block;
width: 100%;
padding: 6px 10px;
background: transparent;
color: inherit;
border: 0;
border-bottom: 1px solid var(--mc-route-legend-border);
font: 700 12px/1.2 system-ui, sans-serif;
text-align: left;
cursor: pointer;
}
.mc-route-legend-toggle:focus { outline: 2px solid #06b6d4; outline-offset: 1px; }
.mc-route-legend-toggle[aria-expanded="false"] + .mc-route-legend-body { display: none; }
.mc-route-legend-body { padding: 8px 10px; }
.mc-route-legend-list { list-style: none; padding: 0; margin: 4px 0; }
.mc-route-legend-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
}
.mc-route-legend-swatch svg { display: block; }
.mc-route-legend-glyph {
display: inline-block;
width: 14px;
text-align: center;
font-weight: 700;
color: var(--mc-route-legend-fg);
}
.mc-route-legend-gradient {
display: inline-block;
width: 32px;
height: 8px;
border-radius: 2px;
background: linear-gradient(90deg, hsl(152,70%,50%), hsl(18,70%,58%));
border: 1px solid var(--mc-route-legend-border);
}
.mc-route-legend-section {
margin-top: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--mc-route-legend-fg);
opacity: 0.75;
}
.mc-route-resolved-badge {
display: inline-block;
padding: 2px 6px;
margin-bottom: 4px;
background: #fef3c7;
color: #78350f;
border: 1px solid #92400e;
border-radius: 3px;
font: 700 11px/14px system-ui, sans-serif;
}
.mc-route-context-label {
position: absolute;
top: 12px;
left: 60px;
z-index: 700;
padding: 4px 8px;
background: var(--mc-route-legend-bg);
color: var(--mc-route-legend-fg);
border: 1px solid var(--mc-route-legend-border);
border-radius: 4px;
font: 600 11px/1.3 system-ui, sans-serif;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
.mc-route-popup .mc-route-popup-title {
font: 700 13px/1.3 system-ui, sans-serif;
margin-bottom: 4px;
color: var(--text, #0f172a);
}
.mc-route-popup .mc-route-popup-row {
display: flex;
justify-content: space-between;
gap: 8px;
font: 11px/1.4 system-ui, sans-serif;
color: var(--text-muted, #475569);
}
.mc-route-popup .mc-route-popup-row b,
.mc-route-popup .mc-route-popup-row code {
color: var(--text, #0f172a);
}
.mc-route-popup-link {
color: var(--accent, #0ea5e9);
font-size: 11px;
text-decoration: underline;
}
@media (prefers-reduced-motion: reduce) {
.mc-route-marker,
.mc-route-edge { transition: none !important; animation: none !important; }
}
@media (forced-colors: active) {
.mc-route-marker svg circle,
.mc-route-marker svg rect,
.mc-route-marker svg polygon { stroke: CanvasText !important; }
.mc-route-seq-badge,
.mc-route-label,
.mc-route-legend,
.mc-route-context-label {
background: Canvas !important;
color: CanvasText !important;
border-color: CanvasText !important;
}
.mc-route-edge { stroke: CanvasText !important; }
}
+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;
-1
View File
@@ -25,7 +25,6 @@ node test-channel-qr-wiring.js
node test-channel-issue-1087.js
node test-analytics-channels-integration.js
node test-observers-headings.js
node test-marker-outline-weight.js
node test-traces.js
echo ""
+8 -38
View File
@@ -188,30 +188,17 @@ async function run() {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const themeBefore = await page.$eval('html', el => el.getAttribute('data-theme'));
// The toggle may be a <label#darkModeToggle> wrapping a checkbox (new toggle-switch
// design) or a <button#darkModeToggle> (legacy button design). Try the checkbox path
// first, then fall back to the old button scan.
// Find toggle button
const allButtons = await page.$$('button');
let toggled = false;
// New toggle-switch: click the label or directly set the checkbox
const toggleLabel = await page.$('#darkModeToggle');
if (toggleLabel) {
await toggleLabel.click();
toggled = true;
} else {
// Legacy fallback: scan buttons for sun/moon emoji
const allButtons = await page.$$('button');
for (const b of allButtons) {
const text = await b.textContent();
if (text.includes('\u2600') || text.includes('\ud83c\udf19') || text.includes('\ud83c\udf11') || text.includes('\ud83c\udf15')) {
await b.click();
toggled = true;
break;
}
for (const b of allButtons) {
const text = await b.textContent();
if (text.includes('\u2600') || text.includes('\ud83c\udf19') || text.includes('\ud83c\udf11') || text.includes('\ud83c\udf15')) {
await b.click();
toggled = true;
break;
}
}
assert(toggled, 'Could not find dark mode toggle button');
await page.waitForFunction(
(before) => document.documentElement.getAttribute('data-theme') !== before,
@@ -219,23 +206,6 @@ async function run() {
);
const themeAfter = await page.$eval('html', el => el.getAttribute('data-theme'));
assert(themeBefore !== themeAfter, `Theme didn't change: before=${themeBefore}, after=${themeAfter}`);
// PR #893 follow-up: tighten — if the new toggle-switch is present, verify
// (a) the checkbox is present and behaves as role="switch", and
// (b) the chosen theme persists across a full reload (localStorage path).
const checkbox = await page.$('#darkModeCheckbox');
if (checkbox) {
const role = await checkbox.evaluate(el => el.getAttribute('role'));
assert(role === 'switch', `Expected role="switch" on #darkModeCheckbox, got "${role}"`);
const checkedNow = await checkbox.evaluate(el => el.checked);
assert(checkedNow === (themeAfter === 'dark'),
`Checkbox state out of sync: checked=${checkedNow}, theme=${themeAfter}`);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#darkModeToggle');
const themePersisted = await page.$eval('html', el => el.getAttribute('data-theme'));
assert(themePersisted === themeAfter,
`Theme did not persist across reload: was=${themeAfter}, after-reload=${themePersisted}`);
}
});
// Test: Stats bar shows version/commit badge
+4 -7
View File
@@ -208,11 +208,8 @@ async function main() {
await ctx.close();
// ── (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 });
// ── (e) at 1024x800, edge-swipe hint visible on first visit ──
const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } });
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 +217,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 393x800 (mobile — corrected per #1402)');
pass('(e) edge-drawer hint visible at 1024x800');
} else {
fail(`(e) edge-drawer hint NOT visible at 393x800 — state=${JSON.stringify(edgeHint)}`);
fail(`(e) edge-drawer hint NOT visible at 1024x800 — state=${JSON.stringify(edgeHint)}`);
}
await ctx2.close();
+2 -10
View File
@@ -36,11 +36,7 @@ async function run() {
await page.waitForSelector('#chList', { timeout: 10000 });
await page.waitForFunction(() => {
const l = document.getElementById('chList');
// #1367: mobile now renders flat .ch-row entries; older .ch-item
// markup still ships on desktop. Accept either so this regression
// test keeps gating the header/empty-state/name-width invariants
// (which apply to both layouts) without pinning the row markup.
return l && l.querySelectorAll('.ch-item, .ch-row').length > 0;
return l && l.querySelectorAll('.ch-item').length > 0;
}, { timeout: 15000 });
await page.waitForTimeout(300);
@@ -70,11 +66,7 @@ async function run() {
await step('first channel row name has computed-width >150px', async () => {
const nameW = await page.evaluate(() => {
// #1367: chat-app mobile row uses .ch-row + .ch-row-name. Fall back to
// the legacy .ch-item .ch-item-name so this test still works on the
// desktop layout / any regression that re-renders the old markup.
const name = document.querySelector('#chList .ch-row .ch-row-name')
|| document.querySelector('#chList .ch-item .ch-item-name');
const name = document.querySelector('#chList .ch-item .ch-item-name');
if (!name) return null;
return Math.round(name.getBoundingClientRect().width);
});
-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); });
-200
View File
@@ -1,200 +0,0 @@
/**
* #1356 WCAG 2.2 AA accessibility for map cluster bubbles, role pills,
* and multi-byte hash labels.
*
* Locked design = Tufte's structural framing (drop color as primary signal,
* use shape / glyph / border-style as carriers) WITH the audit's "Minimal
* patch to Tufte's proposal to reach AA" applied.
*
* Design sources:
* - https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535244400
* - https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535849354
*
* Pure-string assertions (mirrors test-issue-1293-marker-shapes.js pattern)
* so this runs in the JS-unit-tests CI step without a browser.
*/
'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');
const cssSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
console.log('\n=== #1356 V1: cluster bubble — neutral fill, border-style ramp, ARIA ===');
// V1.a — CSS must define a neutral cluster fill constant (not the bucket color).
assert(/--mc-cluster-fill\s*:/.test(cssSrc),
'style.css declares --mc-cluster-fill CSS variable');
// V1.b — Per-bucket background MUST NOT be the old --info/--warning/--accent system colors.
// (Those system vars are reserved per AGENTS.md / issue scope.)
const clusterBlock = cssSrc.match(/\.mc-cluster\.mc-sm[\s\S]{0,400}\.mc-cluster\.mc-lg[^}]*\}/);
assert(clusterBlock && !/var\(--info|var\(--warning|var\(--accent/.test(clusterBlock[0]),
'cluster sm/md/lg no longer use --info / --warning / --accent for fill');
// V1.c — Border-style ramp (solid → heavier → double) is the redundant carrier.
assert(/\.mc-cluster\.mc-lg[^}]*double/.test(cssSrc),
'cluster lg uses "double" border-style as a non-color carrier');
// V1.d — Audit override: border color must be #666 (NOT white) plus a dark halo via box-shadow.
assert(/--mc-cluster-border\s*:\s*#666/i.test(cssSrc),
'--mc-cluster-border is #666 (audit fix for SC 1.4.11 vs Carto-light)');
assert(/\.mc-cluster[^{]*\{[\s\S]*?box-shadow[^;]*rgba\(0\s*,\s*0\s*,\s*0/i.test(cssSrc),
'.mc-cluster has a dark halo box-shadow (audit fix for border visibility)');
// V1.e — ARIA on the cluster div (rendered in makeClusterIcon).
assert(/role=["']img["']/.test(mapSrc) && /aria-label[^=]*=[^>]*nodes/.test(mapSrc),
'makeClusterIcon emits role="img" + aria-label summarising count + role breakdown');
assert(/' nodes — '/.test(mapSrc) || /\d+ nodes — /.test(mapSrc) ||
/total\s*\+\s*' nodes — '/.test(mapSrc),
'cluster aria-label matches /\\d+ nodes — / pattern (summary + breakdown)');
console.log('\n=== #1356 V2: role pills — letter primary, Wong palette, dark text ===');
// V2.a — A ROLE_LETTERS map is defined for the 5 roles.
assert(/ROLE_LETTERS\s*=\s*\{[\s\S]*?repeater[\s\S]*?['"]R['"][\s\S]*?companion[\s\S]*?['"]C['"][\s\S]*?room[\s\S]*?['"]M['"][\s\S]*?sensor[\s\S]*?['"]S['"][\s\S]*?observer[\s\S]*?['"]O['"]/.test(mapSrc),
'map.js defines ROLE_LETTERS with R/C/M/S/O for the five roles');
// V2.b — makeClusterIcon emits the letter (not just a count) inside the pill.
const pillEmitRe = /<span class="mc-pill[^>]*>[^<]*' \+\s*ROLE_LETTERS\[/;
assert(pillEmitRe.test(mapSrc) || /ROLE_LETTERS\[role\][\s\S]{0,200}mc-pill/.test(mapSrc) ||
/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 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[^;]*;/);
assert(pillFontMatch && /1[0-9]px|0\.625rem|0\.6875rem|0\.75rem/.test(pillFontMatch[0]),
'.mc-pill font-size is ≥ 10px (audit fix for SC 1.4.3 / 1.4.4)');
// V2.e — Wong palette declared as --mc-role-* constants.
['repeater','companion','room','sensor','observer'].forEach(function(r){
assert(new RegExp('--mc-role-' + r + '\\s*:').test(cssSrc),
'--mc-role-' + r + ' CSS variable declared');
});
// V2.f — per-pill aria-label "<N> <role>s".
assert(/aria-label="'\s*\+\s*n\s*\+\s*' '\s*\+\s*role/.test(mapSrc) ||
/aria-label=("|')[\s\S]{0,80}\+\s*n\s*\+[\s\S]{0,80}\+\s*role/.test(mapSrc),
'pill HTML emits aria-label with count + role');
// V2.g — DO NOT touch --info / --warning / --accent (out of scope hard rule).
const mcRoleBlock = cssSrc.match(/--mc-role-[\s\S]{0,1500}/);
assert(mcRoleBlock && !/--info\s*:|--warning\s*:|--accent\s*:/.test(mcRoleBlock[0]),
'role pill constants are --mc-* namespaced (do not redefine --info/--warning/--accent)');
console.log('\n=== #1356 V3: multi-byte hash labels — glyph + neutral fill + colored border-left ===');
// V3.a — MB_GLYPHS map for ✓ / ? / ✗.
assert(/MB_GLYPHS\s*=\s*\{[\s\S]*?confirmed[\s\S]*?['"\\]u2713|MB_GLYPHS\s*=\s*\{[\s\S]*?confirmed[\s\S]*?['"]\u2713['"]/.test(mapSrc) ||
/MB_GLYPHS\s*=\s*\{[\s\S]*?confirmed[\s\S]*?['"]✓['"]/.test(mapSrc),
'map.js defines MB_GLYPHS with ✓ for confirmed');
assert(/MB_GLYPHS[\s\S]*?suspected[\s\S]*?['"]\?['"]/.test(mapSrc),
'MB_GLYPHS.suspected === "?"');
assert(/MB_GLYPHS[\s\S]*?unknown[\s\S]*?['"\\]u2717|MB_GLYPHS[\s\S]*?unknown[\s\S]*?['"]✗['"]/.test(mapSrc),
'MB_GLYPHS.unknown === ✗ (u2717)');
// V3.b — Neutral fill constant for multi-byte label.
assert(/--mc-mb-fill\s*:/.test(cssSrc),
'--mc-mb-fill CSS variable declared (neutral fill, not status color)');
// V3.c — High-luminance accent set (audit override of Tol "vibrant").
// Confirmed #56F0A0 / suspected #FFD966 / unknown #FF8888.
assert(/--mc-mb-confirmed\s*:\s*#56F0A0/i.test(cssSrc),
'--mc-mb-confirmed is #56F0A0 (audit high-luminance set, not #117733)');
assert(/--mc-mb-suspected\s*:\s*#FFD966/i.test(cssSrc),
'--mc-mb-suspected is #FFD966');
assert(/--mc-mb-unknown\s*:\s*#FF8888/i.test(cssSrc),
'--mc-mb-unknown is #FF8888');
// V3.d — 3px colored left border in style.
assert(/border-left\s*:\s*3px solid/.test(cssSrc),
'.mc-mb-label has 3px solid border-left (colored accent stripe)');
// V3.e — makeRepeaterLabelIcon prepends MB_GLYPHS[status].
assert(/MB_GLYPHS\[[^\]]+\][\s\S]{0,200}shortHash|shortHash[\s\S]{0,200}MB_GLYPHS\[/.test(mapSrc),
'makeRepeaterLabelIcon prepends MB_GLYPHS glyph to the hash text');
// V3.f — aria-label "multi-byte <status>, hash <ID>".
assert(/aria-label="'\s*\+\s*ariaStatus\s*\+\s*'"/.test(mapSrc) ||
/'multi-byte '\s*\+\s*status\s*\+\s*', hash '\s*\+\s*shortHash/.test(mapSrc) ||
/aria-label="multi-byte \$\{[^}]+\}, hash \$\{shortHash\}"/.test(mapSrc),
'makeRepeaterLabelIcon emits aria-label "multi-byte <status>, hash <ID>"');
// V3.g — Glyph span must be aria-hidden so AT does not read "check mark 3 E".
assert(/<span aria-hidden="true">[\s\S]{0,100}shortHash|<span aria-hidden="true">'\s*\+\s*(?:glyph|visible)/.test(mapSrc) ||
/aria-hidden="true">'\s*\+\s*visible/.test(mapSrc),
'visible glyph+hash span is aria-hidden="true" (AT reads aria-label only)');
// V3.h — repeater label MUST use the neutral fill via var(--mc-mb-fill); MUST
// NOT paint background per-status (that would re-enable the pre-#1356
// color-only signal). Affirmative check on the neutral-fill rule AND
// negative check on the per-status bgColor pattern (round-1 adversarial #5:
// the prior `!removal || affirmative` form short-circuited to a tautology).
assert(/\.mc-mb-label\b[^{]*\{[^}]*background\s*:\s*var\(--mc-mb-fill\)/.test(cssSrc),
'.mc-mb-label background uses var(--mc-mb-fill) — neutral fill, not status color');
assert(!/bgColor\s*=\s*colorOverride\s*\|\|\s*s\.color/.test(mapSrc),
'old per-status bgColor pattern is gone (no per-status background painting)');
console.log('\n=== #1356 Round-1 coverage adds: dual-marker star, null mbStatus, forced-colors ===');
// COV-1 — Observer-also-repeater dual marker: the ★ star glyph inside
// makeRepeaterLabelIcon's obsIndicator branch MUST carry aria-hidden="true",
// otherwise the AT announcement is polluted with "black star" / "star" on
// top of the meaningful aria-label. Round-1 (Kent + adversarial) flagged.
// Match the exact obsIndicator construction shape: `isAlsoObserver ? ' <span aria-hidden="true" ... ★`.
assert(/isAlsoObserver[\s\S]{0,40}\?\s*['"][^'"]*<span\s+aria-hidden="true"[^>]*>[^<]*★/.test(mapSrc),
'observer-also-repeater star span carries aria-hidden="true" (no AT pollution)');
// COV-2 — makeRepeaterLabelIcon with no multi_byte_status field must NOT emit
// an aria-label containing "multi-byte undefined" (the obvious bug if the
// null-fallback branch is dropped). Verify the source has the explicit
// `mbStatus || null` + truthy-check structure that prevents this.
assert(/var\s+status\s*=\s*mbStatus\s*\|\|\s*null\s*;/.test(mapSrc),
'makeRepeaterLabelIcon normalises missing mbStatus to null (not "undefined")');
assert(/ariaStatus\s*=\s*status\s*\?\s*\(\s*['"]multi-byte\s/.test(mapSrc),
'ariaStatus uses ternary on truthy `status` — null falls through to "repeater hash <ID>" branch');
// Negative regression: no template/concat that would ever produce "multi-byte undefined".
assert(!/['"]multi-byte\s*['"]\s*\+\s*mbStatus(?![^,]*\?)/.test(mapSrc),
'no unconditional concat of "multi-byte " + mbStatus (would emit "multi-byte undefined" on null)');
// COV-3 — @media (forced-colors: active) block MUST exist in style.css AND
// MUST NOT contain `forced-color-adjust: none` anywhere within its body
// (audit explicitly warned against `none`; degrades High Contrast Mode).
const fcMatch = cssSrc.match(/@media\s*\(\s*forced-colors\s*:\s*active\s*\)\s*\{[\s\S]*?\n\}/);
assert(fcMatch, '@media (forced-colors: active) block present in style.css');
assert(fcMatch && !/forced-color-adjust\s*:\s*none/i.test(fcMatch[0]),
'@media (forced-colors: active) block does NOT use forced-color-adjust: none (audit regression guard)');
console.log('\n=== #1356 Hard rules: --info / --warning / --accent untouched ===');
// Sanity: ensure new --mc-* constants don't redefine the reserved system vars.
// (--info and --warning are only used via var(..., fallback) — they may not be declared
// at all; --accent IS declared.)
const newConstantsBlock = (cssSrc.match(/\/\*[^*]*#1356[\s\S]*?\*\/[\s\S]*?(?=\/\*|$)/) || ['', ''])[0];
assert(!/--info\s*:|--warning\s*:|--accent\s*:/.test(newConstantsBlock),
'#1356 CSS block does not redefine --info / --warning / --accent');
assert(/--accent\s*:/.test(cssSrc), '--accent CSS variable still defined');
console.log('\n=== Summary ===');
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
if (failed > 0) { console.error('\n#1356 FAIL'); process.exit(1); }
console.log('\n#1356 PASS');
-93
View File
@@ -1,93 +0,0 @@
/**
* #1360 regression(map): #1357 cluster role pills lost the count number.
*
* Pill body must contain BOTH the role letter (WCAG carrier from #1356)
* AND the per-role count (the data sighted operators need at a glance).
*
* Pure-string assertions over public/map.js (mirrors #1356 test pattern).
*/
'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=== #1360: pill body emits letter + count (not letter alone) ===');
// A. Source must concatenate letter and n (the count) into the pill body.
// Acceptable shapes: `letter + n`, `letter + String(n)`, `(letter + n)`.
const concatRe = /letter\s*\+\s*(?:String\()?\s*n\b/;
assert(concatRe.test(mapSrc),
'map.js concatenates letter + n (or letter + String(n)) for pill body');
// B. The pill body must NOT be bare `letter` followed immediately by '</span>'.
// i.e. reject `... + letter + '</span>'` with nothing in between.
const bareLetterRe = /\+\s*letter\s*\+\s*['"]<\/span>/;
assert(!bareLetterRe.test(mapSrc),
'pill body is no longer just letter (no `+ letter + "</span>"` pattern)');
// C. Simulate makeClusterIcon by exercising __meshcoreMapInternals if loadable
// in Node — fallback: pattern-check the rendered HTML template.
// map.js is browser-oriented (Leaflet IIFE) so we string-test the template.
// Build a synthetic expected pill body: a letter from R/C/M/S/O + digits.
// The assertion below validates the rendered shape via regex over the
// template's emitted output pattern.
const pillTemplateRe = /<span class="mc-pill[\s\S]{0,400}letter\s*\+\s*(?:String\()?\s*n/;
assert(pillTemplateRe.test(mapSrc),
'pill HTML template body interpolates letter + n inside the span');
// D. Letter is still the first character of the pill body (preserves #1356
// WCAG carrier ordering — assistive scanning sees the role letter first).
// The concatenation must be `letter + n`, not `n + letter`.
const reverseRe = /\bn\s*\+\s*letter\b/;
assert(!reverseRe.test(mapSrc),
'letter precedes count in concatenation (letter + n, not n + letter)');
// E. Acceptance criterion from the issue: pill body matches /^[RCMSO]\d+$/
// for non-zero counts. Verify ROLE_LETTERS maps to the expected set.
const roleLettersRe = /ROLE_LETTERS\s*=\s*\{([\s\S]*?)\}/;
const rlMatch = mapSrc.match(roleLettersRe);
assert(rlMatch, 'ROLE_LETTERS map is defined in map.js');
if (rlMatch) {
const letters = (rlMatch[1].match(/'[A-Z]'/g) || []).map(function (s) { return s[1]; });
const expected = ['R', 'C', 'M', 'S', 'O'];
const haveAll = expected.every(function (l) { return letters.indexOf(l) !== -1; });
assert(haveAll,
'ROLE_LETTERS includes R, C, M, S, O so pill body matches /^[RCMSO]\\d+$/');
}
// === #1360 follow-up: 4+ digit count overflow guard ===
console.log('\n=== #1360 follow-up: pill width bounded for 4+ digit counts ===');
// F. JS cap: makeClusterIcon must clamp counts > 999 to "999+" so pill body
// becomes e.g. "R999+" instead of "R1234" / "R10000".
const jsCapRe = /n\s*>\s*999[\s\S]{0,80}['"]999\+['"]/;
assert(jsCapRe.test(mapSrc),
'makeClusterIcon caps counts > 999 to "999+" (n > 999 → "999+")');
// G. CSS guard: .mc-pill rule must include max-width AND text-overflow:ellipsis
// as defense-in-depth in case a render slips past the JS cap.
const cssSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
const pillRuleRe = /\.mc-cluster\s+\.mc-pill\s*\{([\s\S]*?)\}/;
const pillMatch = cssSrc.match(pillRuleRe);
assert(pillMatch, '.mc-cluster .mc-pill rule found in style.css');
if (pillMatch) {
const body = pillMatch[1];
// #1364: dropped `max-width` — it over-clamped multi-digit counts.
// Graceful-degrade ellipsis assertion stays.
assert(/text-overflow\s*:\s*ellipsis/.test(body),
'.mc-pill declares text-overflow: ellipsis (graceful clip)');
}
console.log('\n=== Summary ===');
console.log(' Passed: ' + passed);
console.log(' Failed: ' + failed);
console.log('\n#1360 ' + (failed === 0 ? 'PASS' : 'FAIL'));
process.exit(failed === 0 ? 0 : 1);
-215
View File
@@ -1,215 +0,0 @@
/**
* #1361 Theme customizer: first-class colorblind-mode presets.
*
* MVP scope (locked):
* - 5 presets: default, deut, prot, trit, achromat
* - Each preset overrides --mc-role-* CSS vars + --mc-mb-* status vars
* - Achromatopsia uses pure luminance ramp (no hue)
* - Persisted to localStorage("meshcore-cb-preset"), survives reload,
* syncs across tabs via the `storage` event.
* - Customizer UI exposes a radio/dropdown to switch preset.
* - WCAG 1.4.3 / 1.4.11 validation helper exists and is correct on
* known reference pairs.
*
* Pure-string + vm.createContext assertions (mirrors test-issue-1356 / 1360
* pattern) so this runs in the JS-unit-tests CI step without a browser.
*
* Stretch goals (live simulation overlay, "Reset to default Wong" button)
* are explicitly DEFERRED and intentionally NOT asserted here.
*/
'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 presetsPath = path.join(__dirname, 'public', 'cb-presets.js');
const styleSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
const customSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
const appSrc = fs.readFileSync(path.join(__dirname, 'public', 'app.js'), 'utf8');
const indexSrc = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
console.log('\n=== #1361 A: cb-presets.js module exists and is loadable ===');
assert(fs.existsSync(presetsPath), 'public/cb-presets.js exists');
const presetsSrc = fs.existsSync(presetsPath) ? fs.readFileSync(presetsPath, 'utf8') : '';
// Build a minimal browser-ish sandbox so we can run the IIFE module.
function makeSandbox() {
const root = { style: { _vars: {}, setProperty(k, v) { this._vars[k] = v; }, getPropertyValue(k) { return this._vars[k]; }, removeProperty(k) { delete this._vars[k]; } } };
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,
getElementById(id) { return null; },
createElement() { return { setAttribute() {}, appendChild() {}, style: {} }; },
},
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; },
};
sandbox.window = sandbox;
sandbox.document.body = body;
return { sandbox, root, body, storage, listeners };
}
let envOK = false, env;
try {
env = makeSandbox();
vm.createContext(env.sandbox);
vm.runInContext(presetsSrc, env.sandbox);
envOK = true;
} catch (e) {
console.error(' ! cb-presets.js failed to load in vm sandbox: ' + e.message);
}
console.log('\n=== #1361 B: MeshCorePresets.list — 5 documented presets ===');
const MCP = envOK && env.sandbox.window && env.sandbox.window.MeshCorePresets;
assert(!!MCP, 'window.MeshCorePresets exists after script load');
assert(MCP && Array.isArray(MCP.list), 'MeshCorePresets.list is an array');
const expectedIds = ['default', 'deut', 'prot', 'trit', 'achromat'];
if (MCP && Array.isArray(MCP.list)) {
assert(MCP.list.length === 5, 'list contains exactly 5 presets (got ' + MCP.list.length + ')');
const ids = MCP.list.map(function (p) { return p.id; });
expectedIds.forEach(function (id) {
assert(ids.indexOf(id) >= 0, 'list contains preset id="' + id + '"');
});
MCP.list.forEach(function (p) {
assert(typeof p.label === 'string' && p.label.length > 0, 'preset "' + p.id + '" has non-empty label');
assert(typeof p.description === 'string' && p.description.length > 0, 'preset "' + p.id + '" has 1-line description');
assert(p.roleColors && typeof p.roleColors === 'object', 'preset "' + p.id + '" has roleColors map');
['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) {
assert(typeof p.roleColors[role] === 'string' && /^#[0-9a-f]{6}$/i.test(p.roleColors[role]),
'preset "' + p.id + '" has hex roleColors.' + role);
});
});
}
console.log('\n=== #1361 C: applyPreset sets body[data-cb-preset] + CSS vars ===');
assert(MCP && typeof MCP.applyPreset === 'function', 'applyPreset is a function');
if (MCP && typeof MCP.applyPreset === 'function') {
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
MCP.applyPreset(id);
assert(env.body.getAttribute('data-cb-preset') === id,
'applyPreset("' + id + '") sets body[data-cb-preset="' + id + '"]');
// Verify the css var for repeater matches the preset's declared color
const declared = MCP.list.find(function (p) { return p.id === id; }).roleColors.repeater;
const got = env.root.style.getPropertyValue('--mc-role-repeater');
assert(got && got.toLowerCase() === declared.toLowerCase(),
'applyPreset("' + id + '") sets --mc-role-repeater=' + declared + ' (got ' + got + ')');
});
}
console.log('\n=== #1361 D: persistence — localStorage("meshcore-cb-preset") ===');
if (MCP) {
MCP.applyPreset('trit');
assert(env.storage.getItem('meshcore-cb-preset') === 'trit',
'applyPreset persists choice to localStorage key "meshcore-cb-preset"');
}
console.log('\n=== #1361 E: re-init from localStorage re-applies preset ===');
// Fresh sandbox with localStorage pre-populated
{
const env2 = makeSandbox();
env2.storage.setItem('meshcore-cb-preset', 'achromat');
vm.createContext(env2.sandbox);
try {
vm.runInContext(presetsSrc, env2.sandbox);
const MCP2 = env2.sandbox.window.MeshCorePresets;
// Module init OR explicit initFromStorage should re-apply
if (MCP2 && typeof MCP2.initFromStorage === 'function') MCP2.initFromStorage();
assert(env2.body.getAttribute('data-cb-preset') === 'achromat',
're-init from localStorage re-applies "achromat" preset to body data-attr');
} catch (e) {
assert(false, 're-init sandbox load failed: ' + e.message);
}
}
console.log('\n=== #1361 F: cross-tab sync via storage event ===');
if (MCP) {
// Dispatch a synthetic storage event for our key
const ev = new env.sandbox.Event('storage');
ev.key = 'meshcore-cb-preset';
ev.newValue = 'prot';
env.sandbox.dispatchEvent(ev);
assert(env.body.getAttribute('data-cb-preset') === 'prot',
'storage event with newValue="prot" updates body[data-cb-preset="prot"]');
}
console.log('\n=== #1361 G: style.css has preset blocks for non-default presets ===');
['deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
const re = new RegExp('body\\[data-cb-preset=["\']' + id + '["\']\\][^{]*\\{[^}]*--mc-role-repeater', 'i');
assert(re.test(styleSrc),
'style.css has body[data-cb-preset="' + id + '"] block overriding --mc-role-repeater');
});
console.log('\n=== #1361 H: customize-v2.js has Colorblind preset selector UI ===');
assert(/data-cv2-cb-preset|cust-cb-preset|colorblind|Colorblind/i.test(customSrc),
'customize-v2.js contains a Colorblind preset selector hook');
assert(/MeshCorePresets|applyPreset|cb-preset/i.test(customSrc),
'customize-v2.js wires the UI to MeshCorePresets.applyPreset');
console.log('\n=== #1361 I: index.html loads cb-presets.js BEFORE app.js ===');
const cbIdx = indexSrc.indexOf('cb-presets.js');
const appIdx = indexSrc.indexOf('app.js?');
assert(cbIdx > 0, 'index.html includes <script src="cb-presets.js?...">');
assert(cbIdx >= 0 && appIdx >= 0 && cbIdx < appIdx,
'cb-presets.js script tag precedes app.js (so app.js can init the preset)');
console.log('\n=== #1361 J: app.js initializes preset on DOMContentLoaded ===');
assert(/MeshCorePresets\s*[\.\&]/.test(appSrc) || /window\.MeshCorePresets/.test(appSrc),
'app.js references window.MeshCorePresets (init wiring)');
assert(/['"]storage['"]/.test(appSrc) && /meshcore-cb-preset/.test(appSrc),
'app.js handles cross-tab storage event for meshcore-cb-preset');
console.log('\n=== #1361 K: WCAG luminance helper — correctness on reference pairs ===');
assert(MCP && MCP.wcag && typeof MCP.wcag.contrast === 'function',
'MeshCorePresets.wcag.contrast(fg, bg) is exposed');
if (MCP && MCP.wcag && typeof MCP.wcag.contrast === 'function') {
const c1 = MCP.wcag.contrast('#000000', '#ffffff');
assert(Math.abs(c1 - 21) < 0.05, 'contrast(black, white) ≈ 21:1 (got ' + c1.toFixed(2) + ')');
const c2 = MCP.wcag.contrast('#ffffff', '#ffffff');
assert(Math.abs(c2 - 1) < 0.001, 'contrast(white, white) === 1:1 (got ' + c2.toFixed(3) + ')');
// Mid-grey #777 vs white ~ 4.48
const c3 = MCP.wcag.contrast('#777777', '#ffffff');
assert(c3 > 4.4 && c3 < 4.7, 'contrast(#777, white) ≈ 4.48 (got ' + c3.toFixed(2) + ')');
}
console.log('\n=== #1361 L: achromat preset is pure luminance (no chroma) ===');
if (MCP) {
const ach = MCP.list.find(function (p) { return p.id === 'achromat'; });
if (ach) {
Object.keys(ach.roleColors).forEach(function (role) {
const hex = ach.roleColors[role];
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
assert(r === g && g === b,
'achromat preset roleColors.' + role + ' is grey (r==g==b, got ' + hex + ')');
});
}
}
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);
-53
View File
@@ -1,53 +0,0 @@
/**
* #1364 regression(map): #1362 pill max-width:4ch over-clamps multi-digit
* counts `R…` instead of `R60`.
*
* The defense-in-depth `max-width: 4ch` added in #1362 ellipsizes pill
* content because the 4ch box includes left/right padding (1px 3px),
* leaving ~2.5ch for text enough for `R6` but not `R60`.
*
* Fix (Option A from issue): drop `max-width` entirely. JS already caps
* at "999+" so CSS guard was overcaution. Keep `overflow:hidden` +
* `text-overflow:ellipsis` as graceful-degrade if JS ever fails.
*/
'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 cssSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
const pillRuleRe = /\.mc-cluster\s+\.mc-pill\s*\{([\s\S]*?)\}/;
const pillMatch = cssSrc.match(pillRuleRe);
console.log('\n=== #1364: .mc-pill no longer clamps multi-digit counts ===');
assert(pillMatch, '.mc-cluster .mc-pill rule found in style.css');
if (pillMatch) {
const body = pillMatch[1];
// Primary regression guard: NO max-width: 4ch (or any max-width that would
// clamp `R999+`). Issue acceptance criterion: "assert .mc-pill CSS does
// NOT contain max-width: 4ch".
assert(!/max-width\s*:\s*4ch/.test(body),
'.mc-pill does NOT declare `max-width: 4ch` (regression guard for #1364)');
// Graceful degradation: keep belt-only overflow guards in case JS cap
// is bypassed by a hypothetical regression.
assert(/overflow\s*:\s*hidden/.test(body),
'.mc-pill keeps `overflow: hidden` as graceful-degrade');
assert(/text-overflow\s*:\s*ellipsis/.test(body),
'.mc-pill keeps `text-overflow: ellipsis` as graceful-degrade');
}
console.log('\n=== Summary ===');
console.log(' Passed: ' + passed);
console.log(' Failed: ' + failed);
console.log('\n#1364 ' + (failed === 0 ? 'PASS' : 'FAIL'));
process.exit(failed === 0 ? 0 : 1);
-249
View File
@@ -1,249 +0,0 @@
/**
* E2E (#1367): Channels page chat-app redesign restore prod's row layout,
* drop the analytics chip, and add a per-channel detail view.
*
* Design source: issue #1367 body + 4 design-lock comments
* (Operator + Tufte): full-width chat-app rows with avatar / name /
* preview / relative-time; no inline action chips on rows; tap a row
* to slide into a full-screen messages view; back chevron + title.
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1367-channels-chat-app-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 (375x800) -----
const ctx = await browser.newContext({ viewport: { width: 375, height: 800 } });
const page = await ctx.newPage();
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chList', { timeout: 10000 });
// New rows use .ch-row; wait for at least one to render.
await page.waitForFunction(() => {
const l = document.getElementById('chList');
return l && l.querySelectorAll('.ch-row').length > 0;
}, { timeout: 15000 });
await page.waitForTimeout(200);
await step('channel rows use .ch-row, are ~80px tall, full-width', async () => {
const data = await page.evaluate(() => {
const rows = document.querySelectorAll('#chList .ch-row');
if (!rows.length) return null;
const r = rows[0];
const rect = r.getBoundingClientRect();
const parentW = r.parentElement.getBoundingClientRect().width;
return { h: Math.round(rect.height), w: Math.round(rect.width), parentW: Math.round(parentW), count: rows.length };
});
assert(data, 'no .ch-row elements found');
assert(data.h >= 72 && data.h <= 88, '.ch-row height must be 72-88px, got ' + data.h);
// Full-width within its list container (allow 4px slop for borders/padding).
assert(data.w >= data.parentW - 8, '.ch-row width ' + data.w + ' must fill parent ' + data.parentW);
});
await step('each row has .ch-avatar with hash-derived bg + 2-3 char text', async () => {
const info = await page.evaluate(() => {
const row = document.querySelector('#chList .ch-row');
const av = row && row.querySelector('.ch-avatar');
if (!av) return null;
const bg = getComputedStyle(av).backgroundColor;
return { text: (av.textContent || '').trim(), bg: bg };
});
assert(info, 'first row has no .ch-avatar');
assert(info.text.length >= 1 && info.text.length <= 3, 'avatar text length must be 1-3, got "' + info.text + '"');
// Background should be a real color, not transparent / none.
assert(info.bg && info.bg !== 'rgba(0, 0, 0, 0)' && info.bg !== 'transparent',
'avatar bg must be a real color, got ' + info.bg);
});
await step('row body has bold name, preview text, right-aligned timestamp', async () => {
const data = await page.evaluate(() => {
const row = document.querySelector('#chList .ch-row');
const name = row && row.querySelector('.ch-row-name');
const prev = row && row.querySelector('.ch-row-preview');
const time = row && row.querySelector('.ch-row-time');
if (!name || !prev || !time) return { missing: { name: !name, prev: !prev, time: !time } };
const rowRect = row.getBoundingClientRect();
const timeRect = time.getBoundingClientRect();
const nameRect = name.getBoundingClientRect();
return {
nameWeight: getComputedStyle(name).fontWeight,
timeRight: rowRect.right - timeRect.right,
// Timestamp must sit to the right of the name's right edge.
timeAfterName: timeRect.left >= nameRect.right - 4,
};
});
assert(!data.missing, 'missing sub-elements: ' + JSON.stringify(data.missing || {}));
const w = parseInt(data.nameWeight, 10) || 0;
assert(w >= 600 || data.nameWeight === 'bold', 'channel name must be bold, got ' + data.nameWeight);
assert(data.timeRight <= 20, 'timestamp must be right-aligned, got ' + data.timeRight + 'px from row right');
assert(data.timeAfterName, 'timestamp must be to the right of the name');
});
await step('rows have NO inline share/remove action chips', async () => {
const offenders = await page.evaluate(() => {
const rows = document.querySelectorAll('#chList .ch-row');
let bad = [];
for (const r of rows) {
if (r.querySelector('.ch-row-actions, .ch-share, .ch-remove, .ch-share-btn, .ch-remove-btn, [data-share-channel], [data-remove-channel]')) {
bad.push(r.getAttribute('data-hash') || '?');
}
}
return bad;
});
assert(offenders.length === 0,
'inline action chips found on ' + offenders.length + ' rows: ' + offenders.slice(0, 3).join(','));
});
await step('header has NO analytics / chart-emoji chip', async () => {
const hits = await page.evaluate(() => {
const sidebar = document.querySelector('.ch-sidebar');
const header = sidebar && sidebar.querySelector('.ch-sidebar-header');
if (!header) return { noHeader: true };
const hasLink = !!header.querySelector('.ch-analytics-link, a[href*="analytics"]');
const hasEmoji = (header.textContent || '').indexOf('\uD83D\uDCCA') !== -1;
return { hasLink, hasEmoji };
});
assert(!hits.noHeader, 'channels sidebar header not found');
assert(!hits.hasLink, 'analytics link must be removed from header');
assert(!hits.hasEmoji, '📊 emoji must be removed from header');
});
await step('tap a row → URL hash changes to channel detail route', async () => {
// Prefer a row whose preview is non-empty (i.e., the channel has at
// least one observed message), so the downstream detail-view test
// can rely on .ch-message rendering. Fall back to the first row.
const targetHash = await page.evaluate(() => {
const rows = Array.from(document.querySelectorAll('#chList .ch-row[data-hash]'));
const withPreview = rows.find(r => {
const p = r.querySelector('.ch-row-preview');
return p && (p.textContent || '').trim().length > 0
&& !/^0x/.test((p.textContent || '').trim());
});
const r = withPreview || rows[0];
return r ? r.getAttribute('data-hash') : null;
});
assert(targetHash, 'no .ch-row[data-hash] to click');
await page.click('#chList .ch-row[data-hash="' + targetHash.replace(/"/g, '\\"') + '"]');
await page.waitForFunction((h) => location.hash.indexOf(encodeURIComponent(h)) !== -1
|| location.hash.indexOf(h) !== -1, targetHash, { timeout: 5000 });
const hash = await page.evaluate(() => location.hash);
assert(hash.indexOf('/channels/') !== -1, 'URL hash should include /channels/<hash>, got ' + hash);
});
// ----- Detail view (mobile, after tap) -----
await step('detail view header: back affordance + "<name> — <count> messages"', async () => {
// The header already updates on selection; assert the back chevron and the title format.
await page.waitForFunction(() => {
const t = document.querySelector('#chHeader .ch-header-text');
return t && /—\s*\d+\s*messages/i.test(t.textContent || '');
}, { timeout: 8000 });
const data = await page.evaluate(() => {
const header = document.getElementById('chHeader');
const back = header && header.querySelector('.ch-back, [data-action="ch-back"], [aria-label*="Back"]');
const title = header && header.querySelector('.ch-header-text');
return {
hasBack: !!back,
title: title ? (title.textContent || '').trim() : '',
};
});
assert(data.hasBack, 'detail header must include a back button (.ch-back / data-action=ch-back)');
assert(/—\s*\d+\s*messages/i.test(data.title), 'header title must be "<name> — <count> messages", got: ' + data.title);
});
await step('detail view renders at least one .ch-message (avatar + bubble + footer)', async () => {
// Wait up to 10s for messages to load. If the chosen channel renders
// an empty-state, fall back to scanning the entire channel list for
// the busiest one and re-opening it.
let ok = await page.evaluate(async () => {
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
for (let i = 0; i < 50; i++) {
const m = document.querySelector('.ch-message');
if (m) {
const av = m.querySelector('.ch-avatar');
const body = m.querySelector('.ch-message-bubble, .ch-msg-bubble');
const foot = m.querySelector('.ch-message-meta, .ch-msg-meta');
if (av && body && foot) return true;
}
await sleep(200);
}
return false;
});
if (!ok) {
// Go back to the list and try the row with the highest visible
// message count in its preview (e.g. "N messages").
await page.evaluate(() => {
const back = document.querySelector('.ch-back, [data-action="ch-back"]');
if (back) back.click();
else history.replaceState(null, '', '#/channels');
});
await page.waitForSelector('#chList .ch-row[data-hash]', { timeout: 5000 });
const altHash = await page.evaluate(() => {
const rows = Array.from(document.querySelectorAll('#chList .ch-row[data-hash]'));
let best = null, bestN = -1;
for (const r of rows) {
const p = r.querySelector('.ch-row-preview');
const t = (p ? p.textContent || '' : '').trim();
const m = t.match(/(\d+)\s+messages/i);
const n = m ? parseInt(m[1], 10) : (t && !/^0x/.test(t) ? 1 : 0);
if (n > bestN) { bestN = n; best = r.getAttribute('data-hash'); }
}
return best;
});
if (altHash) {
await page.click('#chList .ch-row[data-hash="' + altHash.replace(/"/g, '\\"') + '"]');
ok = await page.evaluate(async () => {
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
for (let i = 0; i < 50; i++) {
const m = document.querySelector('.ch-message');
if (m) {
const av = m.querySelector('.ch-avatar');
const body = m.querySelector('.ch-message-bubble, .ch-msg-bubble');
const foot = m.querySelector('.ch-message-meta, .ch-msg-meta');
if (av && body && foot) return true;
}
await sleep(200);
}
return false;
});
}
}
assert(ok, '.ch-message with avatar+bubble+footer not rendered in detail view');
});
await ctx.close();
// ----- Desktop (1024x800) -----
const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } });
const p2 = await ctx2.newPage();
await p2.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await p2.waitForSelector('.ch-layout', { timeout: 10000 });
await p2.waitForTimeout(200);
await step('desktop (1024px): two-pane layout preserved', async () => {
const dir = await p2.evaluate(() => {
const l = document.querySelector('.ch-layout');
return l ? getComputedStyle(l).flexDirection : null;
});
assert(dir === 'row', 'desktop ch-layout flex-direction must remain "row", got ' + dir);
});
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); });
-239
View File
@@ -1,239 +0,0 @@
/**
* #1374 Packet-route map view a11y + visual modernization.
*
* Asserts the rewritten `/#/map?route=N` renderer:
* - role-aware shape markers (reuses makeRoleMarkerSVG)
* - origin / destination semantically distinct from intermediate hops
* - sequence-number badges (separate from label text)
* - directional arrows on edges + per-edge aria-label
* - per-marker role="img" + aria-label "Hop N of M, <name>, <role>"
* - deconflictLabels reused no overlapping label boxes
* - collapsible legend panel renders
* - partial-route handling: unresolved markers + "X of N hops resolved"
*
* Strategy: the production renderer is split into a pure
* `window.MeshRoute.render(map, layer, positions, options)` that the test
* drives directly with synthetic positions, so no DB is required. The
* production `drawPacketRoute` resolves hops then calls the same function.
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-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'); }
// Synthetic 4-hop route in the Bay Area.
const ROUTE_FIXTURE = {
origin: { pubkey: 'aa00aa00aa00aa00', name: 'Originator Node', role: 'companion', lat: 37.78, lon: -122.42, isOrigin: true },
hops: [
{ pubkey: 'bb11bb11bb11bb11', name: 'Big Redwood Oakland', role: 'repeater', lat: 37.80, lon: -122.27, resolved: true },
{ pubkey: 'cc22cc22cc22cc22', name: 'San Carlos Rptr', role: 'repeater', lat: 37.51, lon: -122.26, resolved: true },
{ pubkey: 'dd33dd33dd33dd33', name: 'Room Server SJ', role: 'room', lat: 37.34, lon: -121.89, resolved: true },
{ pubkey: 'ee44ee44ee44ee44', name: 'Destination Node', role: 'sensor', lat: 37.27, lon: -121.97, resolved: true, isDest: true },
]
};
const PARTIAL_FIXTURE = {
origin: { pubkey: 'aa00aa00aa00aa00', name: 'Originator Node', role: 'companion', lat: 37.78, lon: -122.42, isOrigin: true },
hops: [
{ pubkey: 'bb11bb11bb11bb11', name: 'Big Redwood Oakland', role: 'repeater', lat: 37.80, lon: -122.27, resolved: true },
{ pubkey: 'unresolved-xx', name: 'unresol', role: null, resolved: false },
{ pubkey: 'dd33dd33dd33dd33', name: 'Destination Node', role: 'sensor', lat: 37.34, lon: -121.89, resolved: true, isDest: true },
]
};
async function renderRouteOnPage(page, fixture) {
return await page.evaluate((fx) => {
if (!window.MeshRoute || typeof window.MeshRoute.render !== 'function') {
return { error: 'window.MeshRoute.render not present' };
}
// Build positions array: [origin, ...hops]
const positions = [];
if (fx.origin) positions.push(Object.assign({}, fx.origin));
for (const h of fx.hops) positions.push(Object.assign({}, h));
// Reset any existing route
if (window.__mc_routeLayer && window.__mc_routeLayer.clearLayers) {
window.__mc_routeLayer.clearLayers();
}
window.MeshRoute.render(window.__mc_map, window.__mc_routeLayer, positions, {
timestamp: new Date('2025-01-01T12:00:00Z').toISOString()
});
return { ok: true, count: positions.length };
}, fixture);
}
async function runViewport(browser, width, height, label) {
console.log('\n=== Viewport ' + label + ' (' + width + 'x' + height + ') ===');
const ctx = await browser.newContext({ viewport: { width, height } });
const page = await ctx.newPage();
page.on('pageerror', e => console.error(' pageerror:', e.message));
await page.goto(BASE + '/#/map', { waitUntil: 'commit', timeout: 30000 });
await page.waitForSelector('#leaflet-map', { timeout: 10000 });
// Wait for MeshRoute to register
await page.waitForFunction(() => window.MeshRoute && window.__mc_map && window.__mc_routeLayer, { timeout: 10000 });
await page.waitForTimeout(400);
const r1 = await renderRouteOnPage(page, ROUTE_FIXTURE);
assertNoError(r1);
await page.waitForTimeout(1800);
await step(label + ': every hop marker has role="img" and informative aria-label', async () => {
const data = await page.evaluate(() => {
const markers = Array.from(document.querySelectorAll('.mc-route-marker[role="img"]'));
return markers.map(m => m.getAttribute('aria-label') || '');
});
assert(data.length === 5, 'expected 5 markers, got ' + data.length);
const re = /Hop \d+ of \d+, [^,]+, (repeater|companion|room|sensor|observer)/;
for (const lbl of data) {
assert(re.test(lbl), 'aria-label "' + lbl + '" does not match Hop N of M pattern');
}
});
await step(label + ': origin aria-label contains "originator", destination contains "destination"', async () => {
const data = await page.evaluate(() => {
const markers = Array.from(document.querySelectorAll('.mc-route-marker[role="img"]'));
return markers.map(m => m.getAttribute('aria-label') || '');
});
assert(/originator/i.test(data[0]), 'origin label missing "originator": ' + data[0]);
assert(/destination/i.test(data[data.length - 1]), 'destination label missing "destination": ' + data[data.length - 1]);
});
await step(label + ': sequence-number badge present beside each marker (not in label text)', async () => {
const data = await page.evaluate(() => {
const badges = Array.from(document.querySelectorAll('.mc-route-seq-badge'));
return badges.map(b => b.textContent.trim());
});
assert(data.length >= 5, 'expected >=5 sequence badges, got ' + data.length);
// Badges should be numeric or numbered glyphs.
for (const b of data) {
assert(/^[\d①②③④⑤⑥⑦⑧⑨⑩▶⚑]+$/.test(b), 'badge "' + b + '" not numeric/glyph');
}
});
await step(label + ': no two label boxes overlap (deconflict reused)', async () => {
const rects = await page.evaluate(() => {
const labels = Array.from(document.querySelectorAll('.mc-route-label'));
return labels.map(l => {
const r = l.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
});
assert(rects.length >= 2, 'expected at least 2 labels rendered, got ' + rects.length);
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const a = rects[i], b = rects[j];
const overlap = a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
assert(!overlap, 'labels ' + i + ' and ' + j + ' overlap');
}
}
});
await step(label + ': edges have aria-label "Hop N \u2192 N+1"', async () => {
const data = await page.evaluate(() => {
const edges = Array.from(document.querySelectorAll('path.mc-route-edge[aria-label]'));
return edges.map(e => e.getAttribute('aria-label'));
});
assert(data.length >= 4, 'expected >=4 edge aria-labels, got ' + data.length);
const re = /Hop \d+ \u2192 \d+/;
for (const lbl of data) assert(re.test(lbl), 'edge label "' + lbl + '" missing arrow pattern');
});
await step(label + ': edges carry directionality marker (marker-end arrow)', async () => {
const data = await page.evaluate(() => {
const edges = Array.from(document.querySelectorAll('path.mc-route-edge'));
const arrowDefs = document.querySelectorAll('marker[id^="mc-route-arrow"]');
return {
edgeCount: edges.length,
withArrow: edges.filter(e => /url\(#mc-route-arrow/.test(e.getAttribute('marker-end') || '')).length,
defCount: arrowDefs.length
};
});
assert(data.defCount >= 1, 'expected at least one <marker id="mc-route-arrow…"> def, got ' + data.defCount);
assert(data.withArrow >= data.edgeCount, 'not all edges have marker-end arrow: ' +
data.withArrow + '/' + data.edgeCount);
});
await step(label + ': collapsible legend panel renders with role entries', async () => {
const data = await page.evaluate(() => {
const legend = document.querySelector('.mc-route-legend');
if (!legend) return { found: false };
const toggle = legend.querySelector('[aria-expanded]');
const entries = legend.querySelectorAll('.mc-route-legend-entry, .mc-route-legend-role');
const txt = legend.textContent.toLowerCase();
return {
found: true,
hasToggle: !!toggle,
entryCount: entries.length,
hasRoleTerm: /repeater|companion|room|sensor/.test(txt),
hasOriginTerm: /origin/.test(txt),
hasDestTerm: /destin/.test(txt)
};
});
assert(data.found, '.mc-route-legend not rendered');
assert(data.hasToggle, 'legend toggle missing aria-expanded');
assert(data.entryCount >= 3, 'expected >=3 legend entries, got ' + data.entryCount);
assert(data.hasRoleTerm, 'legend missing role labels');
assert(data.hasOriginTerm, 'legend missing origin/destination glyph entries');
assert(data.hasDestTerm, 'legend missing destination glyph entry');
});
await step(label + ': toolbar shows "Route observed at <timestamp>" context label', async () => {
const data = await page.evaluate(() => {
const el = document.querySelector('.mc-route-context-label');
return el ? el.textContent : null;
});
assert(data && /Route observed at/i.test(data), 'missing "Route observed at" label, got: ' + data);
});
// Partial route case
const r2 = await page.evaluate(() => {
if (window.__mc_routeLayer && window.__mc_routeLayer.clearLayers) window.__mc_routeLayer.clearLayers();
});
await renderRouteOnPage(page, PARTIAL_FIXTURE);
await page.waitForTimeout(1500);
await step(label + ': partial-route — unresolved marker carries ch-unresolved class', async () => {
const data = await page.evaluate(() => {
return document.querySelectorAll('.mc-route-marker[class*="ch-unresolved"]').length;
});
assert(data >= 1, 'expected >=1 ch-unresolved marker, got ' + data);
});
await step(label + ': partial-route — "X of N hops resolved" badge present', async () => {
const data = await page.evaluate(() => {
const el = document.querySelector('.mc-route-resolved-badge');
return el ? el.textContent : null;
});
assert(data && /\d+ of \d+ hops resolved/i.test(data), 'missing resolved badge, got: ' + data);
});
await ctx.close();
}
function assertNoError(r) {
if (r && r.error) throw new Error(r.error);
}
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);
try {
await runViewport(browser, 375, 800, 'mobile');
await runViewport(browser, 1920, 1080, 'desktop');
} finally {
await browser.close();
}
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
}
run().catch(e => { console.error(e); process.exit(1); });
-50
View File
@@ -1,50 +0,0 @@
/**
* #1375 regression(analytics): Scopes tab fetches `/api/api/scope-stats`
* (duplicate prefix) 404 SPA HTML JSON.parse error.
*
* The `api()` helper already prepends `/api`. Other callers in
* public/analytics.js correctly pass `/scope-stats` style relative paths;
* the Scopes loader was the lone offender passing `/api/scope-stats`,
* producing the doubled prefix at runtime.
*
* Fix: drop the leading `/api` from the Scopes-tab call so the helper
* builds `/api/scope-stats?window=…`.
*
* Originally landed on the PR #915 branch (commit 2fd22cee) but that
* branch never merged, so the bug resurfaced in subsequent rebases.
*/
'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', 'analytics.js'), 'utf8');
console.log('\n=== #1375: Scopes tab scope-stats fetch path ===');
// Regression guard: the buggy doubled-prefix form must never reappear.
const badRe = /api\(\s*['"]\/api\/scope-stats/g;
const badMatches = src.match(badRe) || [];
assert(badMatches.length === 0,
"ZERO `api('/api/scope-stats'` occurrences in analytics.js " +
'(regression guard for doubled /api prefix)');
// Positive: the corrected, helper-relative form is present exactly once.
const goodRe = /api\(\s*['"]\/scope-stats/g;
const goodMatches = src.match(goodRe) || [];
assert(goodMatches.length === 1,
"Exactly one `api('/scope-stats'` call exists (the fixed loader) — " +
'found ' + goodMatches.length);
console.log('\n=== Summary ===');
console.log(' Passed: ' + passed);
console.log(' Failed: ' + failed);
console.log('\n#1375 ' + (failed === 0 ? 'PASS' : 'FAIL'));
process.exit(failed === 0 ? 0 : 1);
-244
View File
@@ -1,244 +0,0 @@
#!/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 -2
View File
@@ -149,7 +149,6 @@ function makeLiveSandbox({ withAppJs = false } = {}) {
addLiveGlobals(ctx);
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/packet-helpers.js');
if (withAppJs) loadInCtx(ctx, 'public/app.js');
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
console.error('live.js load error:', e.message);
@@ -191,7 +190,7 @@ console.log('\n=== live.js: dbPacketToLive ===');
const pkt = { id: 1, hash: 'x', decoded_json: null, path_json: null, timestamp: '2024-01-01T00:00:00Z' };
const result = dbPacketToLive(pkt);
assert.strictEqual(result.decoded.header.payloadTypeName, 'UNKNOWN');
assert.strictEqual(result.decoded.path.hops.length, 0);
assert.deepStrictEqual(result.decoded.path.hops, []);
});
test('uses payload_type_name as fallback', () => {
-74
View File
@@ -1,74 +0,0 @@
/**
* Follow-up to #1293 (PR #1334) operator feedback: always-on white
* outline at stroke-width=2 was too heavy and dominated the map at
* zoomed-out levels. This test pins the lighter weight.
*
* Acceptance:
* - makeRoleMarkerSVG renders shape strokes with stroke-width <= 1
* (thin, just enough to make shapes distinct on dark/light tiles).
* - The selected/pulse highlight ring still uses a thicker weight
* (>= 2) so the highlight remains visible.
*/
'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');
console.log('\n=== marker outline weight: always-on stroke is thin ===');
const helperMatch = rolesSrc.match(/window\.makeRoleMarkerSVG[\s\S]*?\n\s*\};/);
const helperBlock = helperMatch ? helperMatch[0] : '';
assert(helperBlock.length > 0, 'makeRoleMarkerSVG block located');
// Every stroke-width literal inside the helper must be <= 1.
const widthRe = /stroke-width="([0-9.]+)"/g;
let m, widths = [];
while ((m = widthRe.exec(helperBlock)) !== null) {
widths.push(parseFloat(m[1]));
}
assert(widths.length > 0, 'helper contains stroke-width literals');
const maxW = widths.reduce((a, b) => Math.max(a, b), 0);
assert(maxW <= 1,
'makeRoleMarkerSVG max stroke-width <= 1 (got ' + maxW + ' across ' +
widths.length + ' shapes)');
// live.js inline fallback SVG must also be thin (it can render before
// roles.js loads in degraded scenarios).
const addNodeIdx = liveSrc.indexOf('function addNodeMarker');
const addNodeBody = liveSrc.slice(addNodeIdx, addNodeIdx + 2500);
const fallbackMatch = addNodeBody.match(/stroke="#fff"\s+stroke-width="([0-9.]+)"/);
if (fallbackMatch) {
assert(parseFloat(fallbackMatch[1]) <= 1,
'live.js inline fallback SVG stroke-width <= 1 (got ' + fallbackMatch[1] + ')');
}
console.log('\n=== highlight ring stays visible (weight >= 2) ===');
// The pulseNodeMarker / highlight ring uses ring.setStyle({ weight: N }).
// At least one such setStyle on _highlightRing must use weight >= 2 so
// the selected/highlighted node remains obviously highlighted.
const ringWeightRe = /ringHl\.setStyle\(\s*\{[^}]*weight:\s*([0-9.]+)/g;
let rm, ringWeights = [];
while ((rm = ringWeightRe.exec(liveSrc)) !== null) {
ringWeights.push(parseFloat(rm[1]));
}
assert(ringWeights.length >= 1,
'highlight ring (_highlightRing) sets weight at least once');
const maxRing = ringWeights.reduce((a, b) => Math.max(a, b), 0);
assert(maxRing >= 2,
'highlight ring max weight >= 2 (got ' + maxRing + ') so highlight stays visible');
console.log('\n=== Summary ===');
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
if (failed > 0) { console.error('\nmarker-outline-weight FAIL'); process.exit(1); }
console.log('\nmarker-outline-weight PASS');
+16 -30
View File
@@ -143,14 +143,12 @@ async function main() {
}
}
// #1105 MINOR 9 (updated by #1391): the active-route pill is now
// PINNED inline at any viewport ≥768px — even if it is not a
// data-priority="high" link. So when we navigate to /#/observers
// (non-high) at 1080px, the observers link MUST stay inline and the
// More menu MUST NOT contain it. The navMoreBtn .active mirror only
// fires when the active route is actually in the dropdown — under
// #1391 that can no longer happen at any width ≥768px, so this test
// verifies the inverse contract.
// #1105 MINOR 9: when at a collapsed width, navigating to a route
// whose link overflows into the More menu must light up #navMoreBtn
// with .active. Verifies rebuildMoreMenu() correctly mirrors the
// active state from the inline (cloned) link to the More button on
// each hashchange (applyNavPriority is wired to hashchange and runs
// after the route handler's class toggles).
await page.setViewportSize({ width: 1080, height: HEIGHT });
await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.top-nav .nav-links');
@@ -173,41 +171,29 @@ async function main() {
const activeMirror = await page.evaluate(() => {
const observersInline = document.querySelector('.nav-links .nav-link[href="#/observers"]');
const inlineHidden = observersInline && observersInline.classList.contains('is-overflow');
const inlineActive = observersInline && observersInline.classList.contains('active');
const inlineWidth = observersInline ? observersInline.getBoundingClientRect().width : 0;
const moreBtn = document.getElementById('navMoreBtn');
const moreBtnActive = moreBtn ? moreBtn.classList.contains('active') : false;
const moreMenuHrefs = Array.from(document.querySelectorAll('#navMoreMenu .nav-link'))
const moreMenuActiveHrefs = Array.from(document.querySelectorAll('#navMoreMenu .nav-link.active'))
.map(a => a.getAttribute('href'));
return { inlineHidden, inlineActive, inlineWidth, moreBtnActive, moreMenuHrefs };
return { inlineHidden, moreBtnActive, moreMenuActiveHrefs };
});
const mirrorReasons = [];
// #1391: active link MUST stay inline (not overflowed).
if (activeMirror.inlineHidden) {
mirrorReasons.push('#1391 contract: #/observers is active route — MUST stay inline at 1080px, not in More');
if (!activeMirror.inlineHidden) {
mirrorReasons.push('precondition: #/observers should be in the More menu at 1080px (not visible inline)');
}
if (!activeMirror.inlineActive) {
mirrorReasons.push('inline #/observers link missing .active class');
if (!activeMirror.moreBtnActive) {
mirrorReasons.push('navMoreBtn missing .active class while #/observers is the active route');
}
if (activeMirror.inlineWidth === 0) {
mirrorReasons.push('inline #/observers has zero width (clipped)');
}
// #1391: navMoreBtn should NOT have .active because the active link
// is inline, not in the dropdown.
if (activeMirror.moreBtnActive) {
mirrorReasons.push('navMoreBtn has .active but active route #/observers is inline (mirror should be off)');
}
// #1391: More menu must NOT contain the active link.
if (activeMirror.moreMenuHrefs.includes('#/observers')) {
mirrorReasons.push(`More menu contains active route #/observers (must be inline only): menu=[${activeMirror.moreMenuHrefs.join(', ')}]`);
if (!activeMirror.moreMenuActiveHrefs.includes('#/observers')) {
mirrorReasons.push(`More-menu clone of #/observers missing .active (active hrefs in menu: [${activeMirror.moreMenuActiveHrefs.join(', ')}])`);
}
if (mirrorReasons.length === 0) {
passes++;
console.log(` ✅ active-pinned @1080 #/observers: inline + .active set, More mirror off, menu excludes active`);
console.log(` ✅ active-mirror @1080 #/observers: navMoreBtn.active=true, menu .active=#/observers`);
} else {
failures++;
console.log(` ❌ active-pinned @1080 #/observers: ${mirrorReasons.join(' | ')}`);
console.log(` ❌ active-mirror @1080 #/observers: ${mirrorReasons.join(' | ')}`);
}
await browser.close();
-183
View File
@@ -1,183 +0,0 @@
#!/usr/bin/env node
/* Issue #1391 20th Priority+ nav regression.
*
* Symptom: at viewport ~1080-1200px on a non-high-priority active route
* (e.g. /#/perf, /#/audio-lab), the active-route pill is shoved into the
* More dropdown instead of staying visible inline. Operator screenshot at
* ~1080px on /#/perf showed the navbar with only the "Perf" pill visible
* (or, in the inverse failure mode, NO inline pill at all, with More
* containing only the orphaned active route).
*
* Acceptance (from issue #1391):
* - Active-route pill MUST always be visible inline (never overflowed
* to More) at any viewport 768px.
* - If active route is NOT a high-priority link (e.g. /#/perf), the
* high-priority links MUST still be inline 768px.
* - Every link in overflow MUST be reachable via the More dropdown
* (the existing #1311/#1139 contract don't regress).
*
* Mutation guard: removing the "pin active inline" rule in applyNavPriority
* must make this test fail (active link gets overflowed at 1080px on /#/perf).
*/
'use strict';
const assert = require('node:assert');
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const HIGH_PRIORITY_HREFS = ['#/home', '#/packets', '#/map', '#/live', '#/nodes'];
// Routes whose link is NOT data-priority="high" (verified via
// `grep data-priority public/index.html`). These exercise the
// "active pill is non-high" branch where the bug surfaces.
const NON_HIGH_ROUTES = ['#/perf', '#/audio-lab', '#/analytics', '#/observers'];
// Operator screenshot was ~1080px. Cover the narrow-desktop CSS branch
// (≤1100) AND the measurement-loop branch (>1100) — bug reproduces in
// both, and the #1311 fix only addressed >1100.
const WIDTHS = [1024, 1080, 1100, 1101, 1200, 1300];
const HEIGHT = 800;
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-nav-priority-1391-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-nav-priority-1391-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) {
for (const route of NON_HIGH_ROUTES) {
await page.setViewportSize({ width: w, height: HEIGHT });
await page.goto(`${BASE}/${route}`, { 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((route) => {
const links = Array.from(document.querySelectorAll('.nav-links .nav-link'));
let activeHref = null;
let activeOverflowed = false;
let activeWidth = 0;
const visibleHighPri = [];
const overflowedHighPri = [];
for (const a of links) {
const href = a.getAttribute('href');
const isActive = a.classList.contains('active');
const isOverflow = a.classList.contains('is-overflow');
const w = a.getBoundingClientRect().width;
if (isActive) {
activeHref = href;
activeOverflowed = isOverflow;
activeWidth = w;
}
if (a.dataset.priority === 'high') {
if (isOverflow || w === 0) overflowedHighPri.push({ href, isOverflow, w });
else visibleHighPri.push(href);
}
}
// Open More dropdown and capture its items (clones live in
// .nav-more-menu, the originals stay in .nav-links).
const moreBtn = document.getElementById('navMoreBtn');
const moreWrap = document.querySelector('.nav-more-wrap');
const moreMenu = document.getElementById('navMoreMenu');
const moreVisible = moreWrap && !moreWrap.classList.contains('is-hidden');
const moreItems = moreMenu
? Array.from(moreMenu.querySelectorAll('.nav-link')).map(a => a.getAttribute('href'))
: [];
// Every inline-overflowed link must appear in the More dropdown
// (otherwise it's unreachable).
const overflowedHrefs = links
.filter(a => a.classList.contains('is-overflow'))
.map(a => a.getAttribute('href'));
const missingFromMore = overflowedHrefs.filter(h => !moreItems.includes(h));
return {
activeHref, activeOverflowed, activeWidth,
visibleHighPri, overflowedHighPri,
moreVisible, moreItems, overflowedHrefs, missingFromMore,
};
}, route);
const tag = `${w}px @ ${route}`;
const expectedActive = route;
try {
// (1) Active pill is correctly identified and present inline.
assert.strictEqual(
data.activeHref, expectedActive,
`${tag}: expected active=${expectedActive}, got ${data.activeHref}`
);
assert.strictEqual(
data.activeOverflowed, false,
`${tag}: active-route pill ${expectedActive} MUST NOT be in overflow ` +
`(was overflowed=${data.activeOverflowed}, width=${data.activeWidth})`
);
assert.ok(
data.activeWidth > 0,
`${tag}: active-route pill ${expectedActive} must have non-zero width inline ` +
`(got width=${data.activeWidth})`
);
// (2) All high-priority links must be inline (regression guard for #1311).
assert.deepStrictEqual(
[...data.visibleHighPri].sort(),
[...HIGH_PRIORITY_HREFS].sort(),
`${tag}: expected all 5 high-pri inline, got [${data.visibleHighPri.join(', ')}] ` +
`overflowed=[${data.overflowedHighPri.map(o => o.href).join(', ')}]`
);
// (3) Every overflowed link is reachable via the More dropdown
// (no orphaned overflow links).
assert.deepStrictEqual(
data.missingFromMore, [],
`${tag}: overflowed links missing from More dropdown: [${data.missingFromMore.join(', ')}] ` +
`(more=[${data.moreItems.join(', ')}])`
);
passes++;
console.log(`${tag}: active inline + ${data.visibleHighPri.length}/5 high-pri inline + ` +
`More has ${data.moreItems.length} item(s)`);
} catch (e) {
failures++;
console.log(`${tag}: ${e.message}`);
}
}
}
await browser.close();
const total = WIDTHS.length * NON_HIGH_ROUTES.length;
console.log(`\ntest-nav-priority-1391-e2e.js: ${failures === 0 ? 'OK' : 'FAIL'}${passes}/${total} passed`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((err) => {
console.error('test-nav-priority-1391-e2e.js: fatal', err);
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,