Compare commits

..

5 Commits

Author SHA1 Message Date
you 31745f9edc fix: add section colors to THEME_CSS_MAP, trailing bytes label, and missing tests
- Add section color CSS variables to THEME_CSS_MAP in customize.js
- Add AppData catch-all label for trailing bytes when hasName flag is not set
- Add test: ADVERT with payload < 100 bytes (generic Payload branch)
- Add test: ADVERT with combined location + name flags
- Add test: trailing bytes without name flag get AppData label
2026-04-02 23:50:43 +00:00
you a89b577ce5 fix: use CSS variables for section colors, fix overlap test, remove redundant repeatHex
- style.css: section-row rules now use var(--section-*-bg) instead of
  hardcoded rgba() values, completing the review feedback fix
- decoder_test.go: overlap check condition was too strict (required both
  <= AND <, effectively only catching <). Fixed to just <=
- decoder_test.go: replaced custom repeatHex() with strings.Repeat()
  from stdlib (DRY)
2026-04-02 23:44:50 +00:00
you 6cd616bcef fix: address PR #500 review feedback
- Replace hardcoded rgba() section colors with CSS variables defined in :root
  and both dark theme blocks
- Label previously unlabeled ADVERT flag bytes 0x20/0x40 as Feature1/Feature2
- Extract shared cleanHex() and parsePacketFrame() to eliminate DRY violation
  between DecodePacket and BuildBreakdown
- Add tests: combined ADVERT flags, feat1-only ADVERT, TRANSPORT_DIRECT
  breakdown and decode, simple payload breakdown
2026-04-02 23:42:41 +00:00
you 1d1cd46d3b test: add Playwright E2E tests for hex breakdown colors (#329)
Convert the manual test plan from the PR description into actual
Playwright tests that verify:
- Hex dump shows color-coded spans (not monochrome)
- Hex legend appears with color swatch items
- Field breakdown table section rows have tinted color classes
2026-04-02 23:42:41 +00:00
efiten bc92b8b5c9 fix: restore color-coded hex breakdown in packet detail (#329)
The buildBreakdown function was never ported when the Go backend replaced
Node.js. The server has returned breakdown:{} since the Go migration,
causing createColoredHexDump() and buildHexLegend() to render everything
as monochrome.

- Add BuildBreakdown() to decoder.go: computes labeled byte ranges for
  Header, Transport Codes, Path Length, Path, and Payload sections.
  ADVERT payloads are further broken down into PubKey, Timestamp,
  Signature, Flags, Latitude, Longitude, and Name sub-ranges.
- Wire into handlePacketDetail in routes.go (was struct{}{}).
- Update PacketDetailResponse.Breakdown from interface{} to *Breakdown.
- Add per-section CSS classes (section-header/transport/path/payload) to
  sectionRow() in packets.js so the field breakdown table also carries
  distinct background tints per section.
- Add matching CSS rules in style.css.
- 8 new tests covering all section types, transport routes, zero-hop
  packets, and ADVERT sub-fields (location, name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:42:41 +00:00
29 changed files with 722 additions and 8739 deletions
File diff suppressed because it is too large Load Diff
-96
View File
@@ -3715,99 +3715,3 @@ func TestGetChannelMessagesAfterIngest(t *testing.T) {
t.Errorf("newest message should be 'brand new message', got %q", lastMsg["text"])
}
}
func TestIndexByNodePreCheck(t *testing.T) {
store := &PacketStore{
byNode: make(map[string][]*StoreTx),
nodeHashes: make(map[string]map[string]bool),
}
t.Run("indexes ADVERT with pubKey", func(t *testing.T) {
tx := &StoreTx{Hash: "h1", DecodedJSON: `{"pubKey":"AABBCC","type":"ADVERT"}`}
store.indexByNode(tx)
if len(store.byNode["AABBCC"]) != 1 {
t.Errorf("expected 1 entry for pubKey AABBCC, got %d", len(store.byNode["AABBCC"]))
}
})
t.Run("indexes destPubKey", func(t *testing.T) {
tx := &StoreTx{Hash: "h2", DecodedJSON: `{"destPubKey":"DDEEFF","type":"MSG"}`}
store.indexByNode(tx)
if len(store.byNode["DDEEFF"]) != 1 {
t.Errorf("expected 1 entry for destPubKey DDEEFF, got %d", len(store.byNode["DDEEFF"]))
}
})
t.Run("indexes srcPubKey", func(t *testing.T) {
tx := &StoreTx{Hash: "h2b", DecodedJSON: `{"srcPubKey":"112233","type":"TXT_MSG"}`}
store.indexByNode(tx)
if len(store.byNode["112233"]) != 1 {
t.Errorf("expected 1 entry for srcPubKey 112233, got %d", len(store.byNode["112233"]))
}
})
t.Run("skips channel message without pubKey", func(t *testing.T) {
beforeLen := len(store.byNode)
tx := &StoreTx{Hash: "h3", DecodedJSON: `{"type":"CHAN","channel":"#test","text":"hello"}`}
store.indexByNode(tx)
if len(store.byNode) != beforeLen {
t.Errorf("expected byNode unchanged for channel packet, got %d new entries", len(store.byNode)-beforeLen)
}
})
t.Run("skips empty DecodedJSON", func(t *testing.T) {
beforeLen := len(store.byNode)
tx := &StoreTx{Hash: "h4", DecodedJSON: ""}
store.indexByNode(tx)
if len(store.byNode) != beforeLen {
t.Error("expected byNode unchanged for empty DecodedJSON")
}
})
t.Run("deduplicates same hash", func(t *testing.T) {
tx := &StoreTx{Hash: "h1", DecodedJSON: `{"pubKey":"AABBCC","type":"ADVERT"}`}
store.indexByNode(tx) // second call for same hash
if len(store.byNode["AABBCC"]) != 1 {
t.Errorf("expected dedup to keep 1 entry, got %d", len(store.byNode["AABBCC"]))
}
})
}
// BenchmarkIndexByNode measures indexByNode performance with and without pubkey
// fields to demonstrate the strings.Contains pre-check optimization.
func BenchmarkIndexByNode(b *testing.B) {
// Payload WITHOUT any pubkey fields — should be skipped via pre-check
noPubkey := `{"type":1,"msgId":42,"sender":"node1","data":"hello world"}`
// Payload WITH a pubkey field — requires JSON parse
withPubkey := `{"type":1,"msgId":42,"pubKey":"AABB","sender":"node1","data":"hello world"}`
b.Run("no_pubkey_skip", func(b *testing.B) {
store := &PacketStore{
byNode: make(map[string][]*StoreTx),
nodeHashes: make(map[string]map[string]bool),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
tx := &StoreTx{
Hash: fmt.Sprintf("hash-%d", i),
DecodedJSON: noPubkey,
}
store.indexByNode(tx)
}
})
b.Run("with_pubkey_parse", func(b *testing.B) {
store := &PacketStore{
byNode: make(map[string][]*StoreTx),
nodeHashes: make(map[string]map[string]bool),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
tx := &StoreTx{
Hash: fmt.Sprintf("hash-%d", i),
DecodedJSON: withPubkey,
}
store.indexByNode(tx)
}
})
}
-26
View File
@@ -698,32 +698,6 @@ func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortB
}
}
if region != "" {
codes := normalizeRegionCodes(region)
if len(codes) > 0 {
placeholders := make([]string, len(codes))
regionArgs := make([]interface{}, len(codes))
for i, c := range codes {
placeholders[i] = "?"
regionArgs[i] = c
}
joinCond := "obs.rowid = o.observer_idx"
if !db.isV3 {
joinCond = "obs.id = o.observer_id"
}
subq := fmt.Sprintf(`public_key IN (
SELECT DISTINCT JSON_EXTRACT(t.decoded_json, '$.pubKey')
FROM transmissions t
JOIN observations o ON o.transmission_id = t.id
JOIN observers obs ON %s
WHERE t.payload_type = 4
AND UPPER(TRIM(obs.iata)) IN (%s)
)`, joinCond, strings.Join(placeholders, ","))
where = append(where, subq)
args = append(args, regionArgs...)
}
}
w := ""
if len(where) > 0 {
w = "WHERE " + strings.Join(where, " AND ")
-162
View File
@@ -1012,168 +1012,6 @@ func TestGetNodesFiltering(t *testing.T) {
t.Errorf("expected 1 node with offset, got %d", len(nodes))
}
})
t.Run("region filter SJC", func(t *testing.T) {
nodes, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SJC")
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 node for SJC region, got %d", total)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0]["public_key"] != "aabbccdd11223344" {
t.Errorf("expected TestRepeater, got %v", nodes[0]["public_key"])
}
})
t.Run("region filter SFO", func(t *testing.T) {
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SFO")
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 node for SFO region, got %d", total)
}
})
t.Run("region filter multi", func(t *testing.T) {
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "SJC,SFO")
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 node for SJC,SFO region, got %d", total)
}
})
t.Run("region filter unknown", func(t *testing.T) {
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "AMS")
if err != nil {
t.Fatal(err)
}
if total != 0 {
t.Errorf("expected 0 nodes for unknown region, got %d", total)
}
})
}
// setupTestDBV2 creates an in-memory SQLite database with the v2 schema
// where observations use observer_id TEXT instead of observer_idx INTEGER.
func setupTestDBV2(t *testing.T) *DB {
t.Helper()
conn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
conn.SetMaxOpenConns(1)
schema := `
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
);
CREATE TABLE observers (
id TEXT PRIMARY KEY,
name TEXT,
iata TEXT,
last_seen TEXT,
first_seen TEXT,
packet_count INTEGER DEFAULT 0
);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_id TEXT,
observer_name TEXT,
direction TEXT,
snr REAL,
rssi REAL,
score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL
);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
}
return &DB{conn: conn, isV3: false}
}
func TestGetNodesRegionFilterV2(t *testing.T) {
db := setupTestDBV2(t)
defer db.Close()
now := time.Now().UTC()
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
recentEpoch := now.Add(-1 * time.Hour).Unix()
// Seed observer with IATA code
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs-v2-1', 'V2 Observer', 'LAX', ?, '2026-01-01T00:00:00Z', 10)`, recent)
// Seed a node
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES ('v2pubkey11223344', 'V2Node', 'repeater', 34.0, -118.0, ?, '2026-01-01T00:00:00Z', 5)`, recent)
// Seed an ADVERT transmission for the node
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('AABB', 'v2hash0001', ?, 1, 4, '{"pubKey":"v2pubkey11223344","name":"V2Node","type":"ADVERT"}')`, recent)
// Seed v2-style observation: observer_id references observers.id directly
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, path_json, timestamp)
VALUES (1, 'obs-v2-1', 'V2 Observer', 10.0, -90, '[]', ?)`, recentEpoch)
t.Run("v2 region filter match", func(t *testing.T) {
nodes, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "LAX")
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Errorf("expected 1 node for LAX region (v2 schema), got %d", total)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0]["public_key"] != "v2pubkey11223344" {
t.Errorf("expected V2Node, got %v", nodes[0]["public_key"])
}
})
t.Run("v2 region filter no match", func(t *testing.T) {
_, total, _, err := db.GetNodes(50, 0, "", "", "", "", "", "JFK")
if err != nil {
t.Fatal(err)
}
if total != 0 {
t.Errorf("expected 0 nodes for JFK region (v2 schema), got %d", total)
}
})
}
func TestGetChannelMessagesDedup(t *testing.T) {
+97 -68
View File
@@ -163,6 +163,62 @@ func isTransportRoute(routeType int) bool {
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
}
// cleanHex removes whitespace from a hex string.
func cleanHex(s string) string {
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, "\r", "")
return s
}
// packetFrame holds parsed packet frame offsets used by both DecodePacket and BuildBreakdown.
type packetFrame struct {
buf []byte
header Header
hasTransport bool
transportOffset int // start of transport codes (if present)
pathOffset int // offset of path length byte
pathByte byte
hashSize int
hashCount int
pathDataOffset int // start of path hop data
payloadOffset int // start of payload
}
// parsePacketFrame parses the common packet frame structure (header, transport codes, path).
// Returns nil if the packet is too short.
func parsePacketFrame(buf []byte) *packetFrame {
if len(buf) < 2 {
return nil
}
f := &packetFrame{buf: buf}
f.header = decodeHeader(buf[0])
offset := 1
f.hasTransport = isTransportRoute(f.header.RouteType)
if f.hasTransport {
if len(buf) < offset+4 {
return nil
}
f.transportOffset = offset
offset += 4
}
if offset >= len(buf) {
return nil
}
f.pathOffset = offset
f.pathByte = buf[offset]
offset++
f.hashSize = int(f.pathByte>>6) + 1
f.hashCount = int(f.pathByte & 0x3F)
f.pathDataOffset = offset
offset += f.hashSize * f.hashCount
f.payloadOffset = offset
return f
}
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
if len(buf) < 4 {
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
@@ -334,49 +390,34 @@ func decodePayload(payloadType int, buf []byte) Payload {
// DecodePacket decodes a hex-encoded MeshCore packet.
func DecodePacket(hexString string) (*DecodedPacket, error) {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
hexString = cleanHex(hexString)
buf, err := hex.DecodeString(hexString)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(buf) < 2 {
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
}
header := decodeHeader(buf[0])
offset := 1
f := parsePacketFrame(buf)
if f == nil {
return nil, fmt.Errorf("packet too short")
}
var tc *TransportCodes
if isTransportRoute(header.RouteType) {
if len(buf) < offset+4 {
return nil, fmt.Errorf("packet too short for transport codes")
}
if f.hasTransport {
tc = &TransportCodes{
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
Code1: strings.ToUpper(hex.EncodeToString(buf[f.transportOffset : f.transportOffset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[f.transportOffset+2 : f.transportOffset+4])),
}
offset += 4
}
if offset >= len(buf) {
return nil, fmt.Errorf("packet too short (no path byte)")
}
pathByte := buf[offset]
offset++
path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed
payloadBuf := buf[offset:]
payload := decodePayload(header.PayloadType, payloadBuf)
path, _ := decodePath(f.pathByte, buf, f.pathDataOffset)
payloadBuf := buf[f.payloadOffset:]
payload := decodePayload(f.header.PayloadType, payloadBuf)
// TRACE packets store hop IDs in the payload (buf[9:]) rather than the header
// path field. The header path byte still encodes hashSize in bits 6-7, which
// we use to split the payload path data into individual hop prefixes.
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
if f.header.PayloadType == PayloadTRACE && payload.PathData != "" {
pathBytes, err := hex.DecodeString(payload.PathData)
if err == nil && path.HashSize > 0 {
hops := make([]string, 0, len(pathBytes)/path.HashSize)
@@ -389,7 +430,7 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
}
return &DecodedPacket{
Header: header,
Header: f.header,
TransportCodes: tc,
Path: path,
Payload: payload,
@@ -411,66 +452,50 @@ type Breakdown struct {
// BuildBreakdown computes labeled byte ranges for each section of a MeshCore packet.
// The returned ranges are consumed by createColoredHexDump() and buildHexLegend()
// in the frontend (public/app.js).
// in the frontend (public/packets.js).
func BuildBreakdown(hexString string) *Breakdown {
hexString = strings.ReplaceAll(hexString, " ", "")
hexString = strings.ReplaceAll(hexString, "\n", "")
hexString = strings.ReplaceAll(hexString, "\r", "")
hexString = cleanHex(hexString)
buf, err := hex.DecodeString(hexString)
if err != nil || len(buf) < 2 {
return &Breakdown{Ranges: []HexRange{}}
}
f := parsePacketFrame(buf)
if f == nil {
return &Breakdown{Ranges: []HexRange{{Start: 0, End: 0, Label: "Header"}}}
}
var ranges []HexRange
offset := 0
// Byte 0: Header
// Header byte
ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"})
offset = 1
header := decodeHeader(buf[0])
// Bytes 1-4: Transport Codes (TRANSPORT_FLOOD / TRANSPORT_DIRECT only)
if isTransportRoute(header.RouteType) {
if len(buf) < offset+4 {
return &Breakdown{Ranges: ranges}
}
ranges = append(ranges, HexRange{Start: offset, End: offset + 3, Label: "Transport Codes"})
offset += 4
// Transport codes
if f.hasTransport {
ranges = append(ranges, HexRange{Start: f.transportOffset, End: f.transportOffset + 3, Label: "Transport Codes"})
}
if offset >= len(buf) {
return &Breakdown{Ranges: ranges}
}
// Next byte: Path Length (bits 7-6 = hashSize-1, bits 5-0 = hashCount)
ranges = append(ranges, HexRange{Start: offset, End: offset, Label: "Path Length"})
pathByte := buf[offset]
offset++
hashSize := int(pathByte>>6) + 1
hashCount := int(pathByte & 0x3F)
pathBytes := hashSize * hashCount
// Path length byte
ranges = append(ranges, HexRange{Start: f.pathOffset, End: f.pathOffset, Label: "Path Length"})
// Path hops
if hashCount > 0 && offset+pathBytes <= len(buf) {
ranges = append(ranges, HexRange{Start: offset, End: offset + pathBytes - 1, Label: "Path"})
pathBytes := f.hashSize * f.hashCount
if f.hashCount > 0 && f.pathDataOffset+pathBytes <= len(buf) {
ranges = append(ranges, HexRange{Start: f.pathDataOffset, End: f.pathDataOffset + pathBytes - 1, Label: "Path"})
}
offset += pathBytes
if offset >= len(buf) {
if f.payloadOffset >= len(buf) {
return &Breakdown{Ranges: ranges}
}
payloadStart := offset
// Payload — break ADVERT into named sub-fields; everything else is one Payload range
if header.PayloadType == PayloadADVERT && len(buf)-payloadStart >= 100 {
ranges = append(ranges, HexRange{Start: payloadStart, End: payloadStart + 31, Label: "PubKey"})
ranges = append(ranges, HexRange{Start: payloadStart + 32, End: payloadStart + 35, Label: "Timestamp"})
ranges = append(ranges, HexRange{Start: payloadStart + 36, End: payloadStart + 99, Label: "Signature"})
if f.header.PayloadType == PayloadADVERT && len(buf)-f.payloadOffset >= 100 {
ps := f.payloadOffset
ranges = append(ranges, HexRange{Start: ps, End: ps + 31, Label: "PubKey"})
ranges = append(ranges, HexRange{Start: ps + 32, End: ps + 35, Label: "Timestamp"})
ranges = append(ranges, HexRange{Start: ps + 36, End: ps + 99, Label: "Signature"})
appStart := payloadStart + 100
appStart := ps + 100
if appStart < len(buf) {
ranges = append(ranges, HexRange{Start: appStart, End: appStart, Label: "Flags"})
appFlags := buf[appStart]
@@ -481,17 +506,21 @@ func BuildBreakdown(hexString string) *Breakdown {
fOff += 8
}
if appFlags&0x20 != 0 && fOff+2 <= len(buf) {
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 1, Label: "Feature1"})
fOff += 2
}
if appFlags&0x40 != 0 && fOff+2 <= len(buf) {
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 1, Label: "Feature2"})
fOff += 2
}
if appFlags&0x80 != 0 && fOff < len(buf) {
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "Name"})
} else if fOff < len(buf) {
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "AppData"})
}
}
} else {
ranges = append(ranges, HexRange{Start: payloadStart, End: len(buf) - 1, Label: "Payload"})
ranges = append(ranges, HexRange{Start: f.payloadOffset, End: len(buf) - 1, Label: "Payload"})
}
return &Breakdown{Ranges: ranges}
+183 -11
View File
@@ -1,6 +1,7 @@
package main
import (
"strings"
"testing"
)
@@ -159,9 +160,9 @@ func TestBuildBreakdown_AdvertBasic(t *testing.T) {
// PathByte 0x01: 1 hop, 1-byte hash
// PathHop: AA
// Payload: 100 bytes (PubKey32 + Timestamp4 + Signature64) + Flags=0x02 (repeater, no extras)
pubkey := repeatHex("AB", 32)
pubkey := strings.Repeat("AB", 32)
ts := "00000000" // 4 bytes
sig := repeatHex("CD", 64)
sig := strings.Repeat("CD", 64)
flags := "02"
hex := "1101AA" + pubkey + ts + sig + flags
b := BuildBreakdown(hex)
@@ -176,9 +177,9 @@ func TestBuildBreakdown_AdvertBasic(t *testing.T) {
func TestBuildBreakdown_AdvertWithLocation(t *testing.T) {
// flags=0x12: hasLocation bit set
pubkey := repeatHex("00", 32)
pubkey := strings.Repeat("00", 32)
ts := "00000000"
sig := repeatHex("00", 64)
sig := strings.Repeat("00", 64)
flags := "12" // 0x10 = hasLocation
latBytes := "00000000"
lonBytes := "00000000"
@@ -190,9 +191,9 @@ func TestBuildBreakdown_AdvertWithLocation(t *testing.T) {
func TestBuildBreakdown_AdvertWithName(t *testing.T) {
// flags=0x82: hasName bit set
pubkey := repeatHex("00", 32)
pubkey := strings.Repeat("00", 32)
ts := "00000000"
sig := repeatHex("00", 64)
sig := strings.Repeat("00", 64)
flags := "82" // 0x80 = hasName
name := "4E6F6465" // "Node" in hex
hex := "1101AA" + pubkey + ts + sig + flags + name
@@ -235,10 +236,181 @@ func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantE
t.Errorf("range %q not found in %v", label, rangeLabels(ranges))
}
func repeatHex(byteHex string, n int) string {
s := ""
for i := 0; i < n; i++ {
s += byteHex
// --- BuildBreakdown tests (PR #500 review feedback) ---
func TestBuildBreakdown_SimplePayload(t *testing.T) {
// Header 0x11 = ADVERT + ZERO_HOP, path byte 0x00 = no hops
// Payload < 100 bytes → single "Payload" range
h := "1100" + strings.Repeat("AB", 10)
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
expect := []string{"Header", "Path Length", "Payload"}
if len(labels) != len(expect) {
t.Fatalf("expected %v, got %v", expect, labels)
}
for i, e := range expect {
if labels[i] != e {
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
}
}
return s
}
func TestBuildBreakdown_TransportDirect(t *testing.T) {
// TXT_MSG (0x01) + TRANSPORT_DIRECT (route 3) = 0x07
h := "07" + "AABBCCDD" + "00" + strings.Repeat("EE", 5)
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
if len(labels) < 4 {
t.Fatalf("expected ≥4 ranges, got %v", labels)
}
if labels[1] != "Transport Codes" {
t.Errorf("expected Transport Codes, got %s", labels[1])
}
if bd.Ranges[1].Start != 1 || bd.Ranges[1].End != 4 {
t.Errorf("transport range wrong: %d-%d", bd.Ranges[1].Start, bd.Ranges[1].End)
}
}
func TestBuildBreakdown_AdvertAllFlags(t *testing.T) {
// ADVERT + ZERO_HOP = 0x11, path 0x00
// flags 0xF2 = location(0x10) + feat1(0x20) + feat2(0x40) + name(0x80) + type 2
pubkey := strings.Repeat("AA", 32)
ts := "01020304"
sig := strings.Repeat("BB", 64)
flags := "F2"
loc := "0100000002000000"
feat1 := "C1C2"
feat2 := "D1D2"
name := strings.Repeat("48", 5)
h := "11" + "00" + pubkey + ts + sig + flags + loc + feat1 + feat2 + name
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
"Flags", "Latitude", "Longitude", "Feature1", "Feature2", "Name"}
if len(labels) != len(expect) {
t.Fatalf("expected %v, got %v", expect, labels)
}
for i, e := range expect {
if labels[i] != e {
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
}
}
// Verify no overlaps
for i := 1; i < len(bd.Ranges); i++ {
if bd.Ranges[i].Start <= bd.Ranges[i-1].End {
t.Errorf("overlap: %s [%d-%d] and %s [%d-%d]",
bd.Ranges[i-1].Label, bd.Ranges[i-1].Start, bd.Ranges[i-1].End,
bd.Ranges[i].Label, bd.Ranges[i].Start, bd.Ranges[i].End)
}
}
// Feature1 & Feature2 are each 2 bytes
if sz := bd.Ranges[8].End - bd.Ranges[8].Start + 1; sz != 2 {
t.Errorf("Feature1 should be 2 bytes, got %d", sz)
}
if sz := bd.Ranges[9].End - bd.Ranges[9].Start + 1; sz != 2 {
t.Errorf("Feature2 should be 2 bytes, got %d", sz)
}
}
func TestBuildBreakdown_AdvertFeat1Only(t *testing.T) {
// flags 0xA1 = feat1(0x20) + name(0x80) + type 1, no location
pubkey := strings.Repeat("AA", 32)
ts := "01020304"
sig := strings.Repeat("BB", 64)
h := "11" + "00" + pubkey + ts + sig + "A1" + "F1F2" + strings.Repeat("4E", 4)
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
"Flags", "Feature1", "Name"}
if len(labels) != len(expect) {
t.Fatalf("expected %v, got %v", expect, labels)
}
for i, e := range expect {
if labels[i] != e {
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
}
}
}
func TestDecodePacket_TransportDirect(t *testing.T) {
h := "07" + "AABBCCDD" + "00" + strings.Repeat("EE", 5)
pkt, err := DecodePacket(h)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pkt.Header.RouteType != RouteTransportDirect {
t.Errorf("expected route %d, got %d", RouteTransportDirect, pkt.Header.RouteType)
}
if pkt.TransportCodes == nil {
t.Fatal("expected transport codes")
}
if pkt.TransportCodes.Code1 != "AABB" {
t.Errorf("Code1: expected AABB, got %s", pkt.TransportCodes.Code1)
}
}
func TestBuildBreakdown_AdvertShortPayload(t *testing.T) {
// ADVERT with payload < 100 bytes should get generic "Payload" label, not sub-ranges
h := "11" + "00" + strings.Repeat("FF", 50) // header + path + 50 bytes payload (< 100)
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
// Should have Header, Path Length, Payload — no PubKey/Timestamp/Signature sub-ranges
if labels[len(labels)-1] != "Payload" {
t.Errorf("expected last range to be 'Payload', got %q", labels[len(labels)-1])
}
for _, l := range labels {
if l == "PubKey" || l == "Timestamp" || l == "Signature" || l == "Flags" {
t.Errorf("unexpected sub-range %q in short ADVERT", l)
}
}
}
func TestBuildBreakdown_AdvertLocationAndName(t *testing.T) {
// flags 0x91 = location(0x10) + name(0x80) + type 1
pubkey := strings.Repeat("AA", 32)
ts := "01020304"
sig := strings.Repeat("BB", 64)
lat := "11223344"
lon := "55667788"
name := strings.Repeat("4E", 6) // "NNNNNN"
h := "11" + "00" + pubkey + ts + sig + "91" + lat + lon + name
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
"Flags", "Latitude", "Longitude", "Name"}
if len(labels) != len(expect) {
t.Fatalf("expected %v, got %v", expect, labels)
}
for i, e := range expect {
if labels[i] != e {
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
}
}
}
func TestBuildBreakdown_AdvertTrailingBytesNoName(t *testing.T) {
// flags 0x11 = location(0x10) + type 1, NO name bit — trailing bytes should be "AppData"
pubkey := strings.Repeat("AA", 32)
ts := "01020304"
sig := strings.Repeat("BB", 64)
lat := "11223344"
lon := "55667788"
trailing := "DEADBEEF"
h := "11" + "00" + pubkey + ts + sig + "11" + lat + lon + trailing
bd := BuildBreakdown(h)
labels := rangeLabels(bd.Ranges)
lastLabel := labels[len(labels)-1]
if lastLabel != "AppData" {
t.Errorf("expected trailing bytes labeled 'AppData', got %q", lastLabel)
}
}
// rangeLabels is defined earlier in this file
-361
View File
@@ -1,361 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
)
// ─── Neighbor API response types ───────────────────────────────────────────────
type NeighborResponse struct {
Node string `json:"node"`
Neighbors []NeighborEntry `json:"neighbors"`
TotalObservations int `json:"total_observations"`
}
type NeighborEntry struct {
Pubkey *string `json:"pubkey"`
Prefix string `json:"prefix"`
Name *string `json:"name"`
Role *string `json:"role"`
Count int `json:"count"`
Score float64 `json:"score"`
FirstSeen string `json:"first_seen"`
LastSeen string `json:"last_seen"`
AvgSNR *float64 `json:"avg_snr"`
Observers []string `json:"observers"`
Ambiguous bool `json:"ambiguous"`
Unresolved bool `json:"unresolved,omitempty"`
Candidates []CandidateEntry `json:"candidates,omitempty"`
}
type CandidateEntry struct {
Pubkey string `json:"pubkey"`
Name string `json:"name"`
Role string `json:"role"`
}
type NeighborGraphResponse struct {
Nodes []GraphNode `json:"nodes"`
Edges []GraphEdge `json:"edges"`
Stats GraphStats `json:"stats"`
}
type GraphNode struct {
Pubkey string `json:"pubkey"`
Name string `json:"name"`
Role string `json:"role"`
NeighborCount int `json:"neighbor_count"`
}
type GraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
Weight int `json:"weight"`
Score float64 `json:"score"`
Bidirectional bool `json:"bidirectional"`
AvgSNR *float64 `json:"avg_snr"`
Ambiguous bool `json:"ambiguous"`
}
type GraphStats struct {
TotalNodes int `json:"total_nodes"`
TotalEdges int `json:"total_edges"`
AmbiguousEdges int `json:"ambiguous_edges"`
AvgClusterSize float64 `json:"avg_cluster_size"`
}
// ─── Graph accessor on Server ──────────────────────────────────────────────────
// getNeighborGraph returns the current neighbor graph, rebuilding if stale.
func (s *Server) getNeighborGraph() *NeighborGraph {
s.neighborMu.Lock()
defer s.neighborMu.Unlock()
if s.neighborGraph == nil || s.neighborGraph.IsStale() {
if s.store != nil {
s.neighborGraph = BuildFromStore(s.store)
} else {
s.neighborGraph = NewNeighborGraph()
}
}
return s.neighborGraph
}
// ─── Handlers ──────────────────────────────────────────────────────────────────
func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) {
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
minCount := 1
if v := r.URL.Query().Get("min_count"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
minCount = n
}
}
minScore := 0.0
if v := r.URL.Query().Get("min_score"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
minScore = f
}
}
includeAmbiguous := true
if v := r.URL.Query().Get("include_ambiguous"); v == "false" {
includeAmbiguous = false
}
graph := s.getNeighborGraph()
edges := graph.Neighbors(pubkey)
now := time.Now()
// Build node info lookup for names/roles.
nodeMap := s.buildNodeInfoMap()
var entries []NeighborEntry
totalObs := 0
for _, e := range edges {
score := e.Score(now)
if e.Count < minCount || score < minScore {
continue
}
if e.Ambiguous && !includeAmbiguous {
continue
}
totalObs += e.Count
// Determine the "other" node (neighbor of the queried pubkey).
neighborPK := e.NodeA
if strings.EqualFold(neighborPK, pubkey) {
neighborPK = e.NodeB
}
entry := NeighborEntry{
Prefix: e.Prefix,
Count: e.Count,
Score: score,
FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339),
LastSeen: e.LastSeen.UTC().Format(time.RFC3339),
Ambiguous: e.Ambiguous,
Observers: observerList(e.Observers),
}
if e.SNRCount > 0 {
avg := e.AvgSNR()
entry.AvgSNR = &avg
}
if e.Ambiguous {
if len(e.Candidates) == 0 {
entry.Unresolved = true
}
for _, cpk := range e.Candidates {
ce := CandidateEntry{Pubkey: cpk}
if info, ok := nodeMap[strings.ToLower(cpk)]; ok {
ce.Name = info.Name
ce.Role = info.Role
}
entry.Candidates = append(entry.Candidates, ce)
}
} else if neighborPK != "" {
entry.Pubkey = &neighborPK
if info, ok := nodeMap[strings.ToLower(neighborPK)]; ok {
entry.Name = &info.Name
entry.Role = &info.Role
}
}
entries = append(entries, entry)
}
// Sort by score descending.
sort.Slice(entries, func(i, j int) bool {
return entries[i].Score > entries[j].Score
})
if entries == nil {
entries = []NeighborEntry{}
}
resp := NeighborResponse{
Node: pubkey,
Neighbors: entries,
TotalObservations: totalObs,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (s *Server) handleNeighborGraph(w http.ResponseWriter, r *http.Request) {
minCount := 5
if v := r.URL.Query().Get("min_count"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
minCount = n
}
}
minScore := 0.1
if v := r.URL.Query().Get("min_score"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
minScore = f
}
}
region := r.URL.Query().Get("region")
roleFilter := strings.ToLower(r.URL.Query().Get("role"))
graph := s.getNeighborGraph()
allEdges := graph.AllEdges()
now := time.Now()
// Resolve region observers if filtering.
var regionObs map[string]bool
if region != "" && s.store != nil {
regionObs = s.store.resolveRegionObservers(region)
}
nodeMap := s.buildNodeInfoMap()
nodeSet := make(map[string]bool)
var filteredEdges []GraphEdge
ambiguousCount := 0
for _, e := range allEdges {
score := e.Score(now)
if e.Count < minCount || score < minScore {
continue
}
// Role filter: at least one endpoint must match the role.
if roleFilter != "" && nodeMap != nil {
aInfo, aOK := nodeMap[strings.ToLower(e.NodeA)]
bInfo, bOK := nodeMap[strings.ToLower(e.NodeB)]
aMatch := aOK && strings.EqualFold(aInfo.Role, roleFilter)
bMatch := bOK && strings.EqualFold(bInfo.Role, roleFilter)
if !aMatch && !bMatch {
continue
}
}
// Region filter: at least one observer must be in the region.
if regionObs != nil {
match := false
for obs := range e.Observers {
if regionObs[obs] {
match = true
break
}
}
if !match {
continue
}
}
ge := GraphEdge{
Source: e.NodeA,
Target: e.NodeB,
Weight: e.Count,
Score: score,
Bidirectional: true,
Ambiguous: e.Ambiguous,
}
if e.SNRCount > 0 {
avg := e.AvgSNR()
ge.AvgSNR = &avg
}
if e.Ambiguous {
ambiguousCount++
// For ambiguous edges, use prefix as target.
if e.NodeB == "" {
ge.Target = "prefix:" + e.Prefix
}
}
filteredEdges = append(filteredEdges, ge)
// Track nodes.
if e.NodeA != "" && !strings.HasPrefix(e.NodeA, "prefix:") {
nodeSet[e.NodeA] = true
}
if e.NodeB != "" && !strings.HasPrefix(e.NodeB, "prefix:") {
nodeSet[e.NodeB] = true
}
}
// Build node list.
// Count neighbors per node from filtered edges.
neighborCounts := make(map[string]int)
for _, ge := range filteredEdges {
neighborCounts[ge.Source]++
neighborCounts[ge.Target]++
}
var nodes []GraphNode
for pk := range nodeSet {
gn := GraphNode{Pubkey: pk, NeighborCount: neighborCounts[pk]}
if info, ok := nodeMap[strings.ToLower(pk)]; ok {
gn.Name = info.Name
gn.Role = info.Role
}
nodes = append(nodes, gn)
}
if filteredEdges == nil {
filteredEdges = []GraphEdge{}
}
if nodes == nil {
nodes = []GraphNode{}
}
avgCluster := 0.0
if len(nodes) > 0 {
avgCluster = float64(len(filteredEdges)*2) / float64(len(nodes))
}
resp := NeighborGraphResponse{
Nodes: nodes,
Edges: filteredEdges,
Stats: GraphStats{
TotalNodes: len(nodes),
TotalEdges: len(filteredEdges),
AmbiguousEdges: ambiguousCount,
AvgClusterSize: avgCluster,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
func observerList(m map[string]bool) []string {
if len(m) == 0 {
return []string{}
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// buildNodeInfoMap returns a map of lowercase pubkey → nodeInfo for name/role lookups.
func (s *Server) buildNodeInfoMap() map[string]nodeInfo {
if s.store == nil {
return nil
}
nodes, _ := s.store.getCachedNodesAndPM()
m := make(map[string]nodeInfo, len(nodes))
for _, n := range nodes {
m[strings.ToLower(n.PublicKey)] = n
}
return m
}
-396
View File
@@ -1,396 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
)
// ─── Helpers ───────────────────────────────────────────────────────────────────
// makeTestServer creates a Server with a pre-built neighbor graph for testing.
func makeTestServer(graph *NeighborGraph) *Server {
srv := &Server{
perfStats: NewPerfStats(),
}
srv.neighborGraph = graph
return srv
}
// makeTestGraph creates a graph with given edges for testing.
func makeTestGraph(edges ...*NeighborEdge) *NeighborGraph {
g := NewNeighborGraph()
g.mu.Lock()
for _, e := range edges {
key := makeEdgeKey(e.NodeA, e.NodeB)
if e.NodeB == "" {
key = makeEdgeKey(e.NodeA, "prefix:"+e.Prefix)
}
e.NodeA = key.A
if e.NodeB != "" {
e.NodeB = key.B
}
g.edges[key] = e
g.byNode[key.A] = append(g.byNode[key.A], e)
if key.B != "" && key.B != key.A {
g.byNode[key.B] = append(g.byNode[key.B], e)
}
}
g.builtAt = time.Now()
g.mu.Unlock()
return g
}
func newEdge(a, b, prefix string, count int, lastSeen time.Time) *NeighborEdge {
return &NeighborEdge{
NodeA: a,
NodeB: b,
Prefix: prefix,
Count: count,
FirstSeen: lastSeen.Add(-24 * time.Hour),
LastSeen: lastSeen,
Observers: map[string]bool{"obs1": true},
SNRSum: -8.0,
SNRCount: 1,
}
}
func newAmbiguousEdge(knownPK, prefix string, candidates []string, count int, lastSeen time.Time) *NeighborEdge {
return &NeighborEdge{
NodeA: knownPK,
NodeB: "",
Prefix: prefix,
Count: count,
FirstSeen: lastSeen.Add(-24 * time.Hour),
LastSeen: lastSeen,
Observers: map[string]bool{"obs1": true},
Ambiguous: true,
Candidates: candidates,
}
}
func serveRequest(srv *Server, method, path string) *httptest.ResponseRecorder {
router := mux.NewRouter()
router.HandleFunc("/api/nodes/{pubkey}/neighbors", srv.handleNodeNeighbors).Methods("GET")
router.HandleFunc("/api/analytics/neighbor-graph", srv.handleNeighborGraph).Methods("GET")
req := httptest.NewRequest(method, path, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// ─── Tests: /api/nodes/{pubkey}/neighbors ──────────────────────────────────────
func TestNeighborAPI_EmptyGraph(t *testing.T) {
srv := makeTestServer(makeTestGraph())
rr := serveRequest(srv, "GET", "/api/nodes/deadbeef/neighbors")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp NeighborResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad JSON: %v", err)
}
if resp.Node != "deadbeef" {
t.Errorf("node = %q, want deadbeef", resp.Node)
}
if len(resp.Neighbors) != 0 {
t.Errorf("expected 0 neighbors, got %d", len(resp.Neighbors))
}
if resp.TotalObservations != 0 {
t.Errorf("expected 0 observations, got %d", resp.TotalObservations)
}
}
func TestNeighborAPI_SingleNeighbor(t *testing.T) {
now := time.Now()
e := newEdge("aaaa", "bbbb", "bb", 50, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
}
n := resp.Neighbors[0]
if n.Pubkey == nil || *n.Pubkey != "bbbb" {
t.Errorf("expected pubkey bbbb, got %v", n.Pubkey)
}
if n.Count != 50 {
t.Errorf("expected count 50, got %d", n.Count)
}
if n.Score <= 0 {
t.Errorf("expected positive score, got %f", n.Score)
}
if n.Ambiguous {
t.Error("expected not ambiguous")
}
}
func TestNeighborAPI_MultipleNeighbors(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
e2 := newEdge("aaaa", "cccc", "cc", 10, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 2 {
t.Fatalf("expected 2 neighbors, got %d", len(resp.Neighbors))
}
// Should be sorted by score descending.
if resp.Neighbors[0].Score < resp.Neighbors[1].Score {
t.Error("expected sorted by score descending")
}
if resp.TotalObservations != 110 {
t.Errorf("expected 110 total observations, got %d", resp.TotalObservations)
}
}
func TestNeighborAPI_AmbiguousCandidates(t *testing.T) {
now := time.Now()
e := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 12, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
}
n := resp.Neighbors[0]
if !n.Ambiguous {
t.Error("expected ambiguous")
}
if n.Pubkey != nil {
t.Errorf("expected nil pubkey for ambiguous, got %v", n.Pubkey)
}
if len(n.Candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(n.Candidates))
}
}
func TestNeighborAPI_UnresolvedPrefix(t *testing.T) {
now := time.Now()
e := newAmbiguousEdge("aaaa", "ff", []string{}, 3, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors))
}
n := resp.Neighbors[0]
if !n.Unresolved {
t.Error("expected unresolved=true")
}
if len(n.Candidates) != 0 {
t.Error("expected empty candidates for unresolved")
}
}
func TestNeighborAPI_MinCountFilter(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
e2 := newEdge("aaaa", "cccc", "cc", 2, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_count=10")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor after min_count filter, got %d", len(resp.Neighbors))
}
if *resp.Neighbors[0].Pubkey != "bbbb" {
t.Error("expected bbbb to survive filter")
}
}
func TestNeighborAPI_MinScoreFilter(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // score ~1.0
e2 := newEdge("aaaa", "cccc", "cc", 1, now.Add(-30*24*time.Hour)) // very low score
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_score=0.5")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 neighbor after min_score filter, got %d", len(resp.Neighbors))
}
}
func TestNeighborAPI_ExcludeAmbiguous(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 50, now)
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01"}, 10, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?include_ambiguous=false")
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 1 {
t.Fatalf("expected 1 non-ambiguous neighbor, got %d", len(resp.Neighbors))
}
}
func TestNeighborAPI_UnknownNode(t *testing.T) {
now := time.Now()
e := newEdge("aaaa", "bbbb", "bb", 50, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/nodes/unknown1234/neighbors")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for unknown node, got %d", rr.Code)
}
var resp NeighborResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Neighbors) != 0 {
t.Errorf("expected 0 neighbors for unknown node, got %d", len(resp.Neighbors))
}
}
// ─── Tests: /api/analytics/neighbor-graph ──────────────────────────────────────
func TestNeighborGraphAPI_EmptyGraph(t *testing.T) {
srv := makeTestServer(makeTestGraph())
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Edges) != 0 {
t.Errorf("expected 0 edges, got %d", len(resp.Edges))
}
if resp.Stats.TotalEdges != 0 {
t.Errorf("expected 0 total edges, got %d", resp.Stats.TotalEdges)
}
if resp.Stats.TotalNodes != 0 {
t.Errorf("expected 0 total nodes, got %d", resp.Stats.TotalNodes)
}
}
func TestNeighborGraphAPI_WithEdges(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
e2 := newEdge("bbbb", "cccc", "cc", 50, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Edges) != 2 {
t.Fatalf("expected 2 edges, got %d", len(resp.Edges))
}
if resp.Stats.TotalNodes != 3 {
t.Errorf("expected 3 nodes, got %d", resp.Stats.TotalNodes)
}
if resp.Stats.TotalEdges != 2 {
t.Errorf("expected 2 total edges, got %d", resp.Stats.TotalEdges)
}
}
func TestNeighborGraphAPI_MinCountDefault(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // passes default min_count=5
e2 := newEdge("aaaa", "cccc", "cc", 2, now) // fails default min_count=5
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph")
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Edges) != 1 {
t.Fatalf("expected 1 edge with default min_count=5, got %d", len(resp.Edges))
}
}
func TestNeighborGraphAPI_AmbiguousEdgesCount(t *testing.T) {
now := time.Now()
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 50, now)
srv := makeTestServer(makeTestGraph(e1, e2))
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.Stats.AmbiguousEdges != 1 {
t.Errorf("expected 1 ambiguous edge, got %d", resp.Stats.AmbiguousEdges)
}
}
func TestNeighborGraphAPI_RegionFilter(t *testing.T) {
now := time.Now()
// Edge with observer "obs-sjc" — would match region SJC if we had region resolution.
// Without a store, region filtering returns nothing (no observers match).
e1 := newEdge("aaaa", "bbbb", "bb", 100, now)
srv := makeTestServer(makeTestGraph(e1))
// No store → region filter has no observers → filters everything out.
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?region=SJC&min_count=1&min_score=0")
var resp NeighborGraphResponse
json.Unmarshal(rr.Body.Bytes(), &resp)
// With no store, regionObs is nil so filter is skipped → all edges returned.
// Actually: region="" when store is nil → regionObs stays nil → no filtering.
// Wait, we set region=SJC and store is nil → resolveRegionObservers won't be called
// because s.store is nil. So regionObs is nil → filter not applied.
// Let's just check it doesn't crash.
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}
func TestNeighborGraphAPI_ResponseShape(t *testing.T) {
now := time.Now()
e := newEdge("aaaa", "bbbb", "bb", 100, now)
srv := makeTestServer(makeTestGraph(e))
rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0")
var raw map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
t.Fatalf("bad JSON: %v", err)
}
// Verify top-level keys.
for _, key := range []string{"nodes", "edges", "stats"} {
if _, ok := raw[key]; !ok {
t.Errorf("missing key %q in response", key)
}
}
// Verify stats keys.
stats := raw["stats"].(map[string]interface{})
for _, key := range []string{"total_nodes", "total_edges", "ambiguous_edges", "avg_cluster_size"} {
if _, ok := stats[key]; !ok {
t.Errorf("missing stats key %q", key)
}
}
}
-500
View File
@@ -1,500 +0,0 @@
package main
import (
"encoding/json"
"math"
"strings"
"sync"
"time"
)
// ─── Constants ─────────────────────────────────────────────────────────────────
const (
// After this many observations, count contributes max weight to the score.
affinitySaturationCount = 100
// Time-decay half-life: 7 days.
affinityHalfLifeHours = 168.0
// Cache TTL for the built graph.
neighborGraphTTL = 60 * time.Second
// Auto-resolve confidence: best must be >= this factor × second-best.
affinityConfidenceRatio = 3.0
// Minimum observation count to auto-resolve.
affinityMinObservations = 3
)
// affinityLambda = ln(2) / half-life-hours, precomputed.
var affinityLambda = math.Ln2 / affinityHalfLifeHours
// ─── Data model ────────────────────────────────────────────────────────────────
// edgeKey is the canonical key for an undirected edge (A < B lexicographically).
// For ambiguous edges where NodeB is unknown, B is the raw prefix prefixed with "prefix:".
type edgeKey struct {
A, B string
}
func makeEdgeKey(a, b string) edgeKey {
if a > b {
a, b = b, a
}
return edgeKey{A: a, B: b}
}
// NeighborEdge represents a weighted, undirected first-hop neighbor relationship.
type NeighborEdge struct {
NodeA string // full pubkey
NodeB string // full pubkey, or "" if unresolved/ambiguous
Prefix string // raw hop prefix that established this edge
Count int // total observations
FirstSeen time.Time //
LastSeen time.Time //
SNRSum float64 // running sum for average
SNRCount int // how many SNR samples
Observers map[string]bool // observer pubkeys that witnessed
Ambiguous bool // multiple candidates or zero candidates
Candidates []string // candidate pubkeys when ambiguous
Resolved bool // true if auto-resolved via Jaccard
}
// Score computes the affinity score at query time with time decay.
func (e *NeighborEdge) Score(now time.Time) float64 {
countFactor := math.Min(1.0, float64(e.Count)/float64(affinitySaturationCount))
hoursSince := now.Sub(e.LastSeen).Hours()
if hoursSince < 0 {
hoursSince = 0
}
decay := math.Exp(-affinityLambda * hoursSince)
return countFactor * decay
}
// AvgSNR returns the average SNR, or 0 if no samples.
func (e *NeighborEdge) AvgSNR() float64 {
if e.SNRCount == 0 {
return 0
}
return e.SNRSum / float64(e.SNRCount)
}
// ─── NeighborGraph ─────────────────────────────────────────────────────────────
// NeighborGraph is a cached, in-memory first-hop neighbor affinity graph.
type NeighborGraph struct {
mu sync.RWMutex
edges map[edgeKey]*NeighborEdge
byNode map[string][]*NeighborEdge // pubkey → edges involving this node
builtAt time.Time
}
// NewNeighborGraph creates an empty graph.
func NewNeighborGraph() *NeighborGraph {
return &NeighborGraph{
edges: make(map[edgeKey]*NeighborEdge),
byNode: make(map[string][]*NeighborEdge),
}
}
// Neighbors returns all edges for a given node pubkey.
func (g *NeighborGraph) Neighbors(pubkey string) []*NeighborEdge {
g.mu.RLock()
defer g.mu.RUnlock()
return g.byNode[strings.ToLower(pubkey)]
}
// AllEdges returns all edges in the graph.
func (g *NeighborGraph) AllEdges() []*NeighborEdge {
g.mu.RLock()
defer g.mu.RUnlock()
out := make([]*NeighborEdge, 0, len(g.edges))
for _, e := range g.edges {
out = append(out, e)
}
return out
}
// IsStale returns true if the graph cache has expired.
func (g *NeighborGraph) IsStale() bool {
g.mu.RLock()
defer g.mu.RUnlock()
return g.builtAt.IsZero() || time.Since(g.builtAt) > neighborGraphTTL
}
// ─── Builder ───────────────────────────────────────────────────────────────────
// BuildFromStore constructs the neighbor graph from all packets in the store.
// The store's read-lock must NOT be held by the caller.
func BuildFromStore(store *PacketStore) *NeighborGraph {
g := NewNeighborGraph()
store.mu.RLock()
// Snapshot what we need under lock.
packets := make([]*StoreTx, len(store.packets))
copy(packets, store.packets)
store.mu.RUnlock()
// Build prefix map for candidate resolution.
// Use cached nodes+PM (avoids DB call if cache is fresh).
_, pm := store.getCachedNodesAndPM()
// Phase 1: Extract edges from every transmission + observation.
for _, tx := range packets {
isAdvert := tx.PayloadType != nil && *tx.PayloadType == 4
fromNode := "" // originator pubkey (from byNode index key)
// Find the originator pubkey — it's the key in store.byNode.
// StoreTx doesn't store from_node directly; we find it via decoded JSON
// or the byNode index. However, iterating byNode is expensive.
// The originator pubkey is in the decoded JSON "from_node" field,
// but parsing JSON per tx is expensive too.
// Actually, let's look at how byNode is keyed.
// Looking at store.go, byNode maps pubkey → transmissions where that
// pubkey is the "from" node. We need the reverse: tx → from_node.
// The from_node is embedded in DecodedJSON.
// For efficiency, let's extract it once.
fromNode = extractFromNode(tx)
for _, obs := range tx.Observations {
path := parsePathJSON(obs.PathJSON)
observerPK := strings.ToLower(obs.ObserverID)
if len(path) == 0 {
// Zero-hop
if isAdvert && fromNode != "" {
fromLower := strings.ToLower(fromNode)
if fromLower != observerPK { // self-edge guard
g.upsertEdge(fromLower, observerPK, "", observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
}
}
continue
}
// Edge 1: originator ↔ path[0] — ADVERTs only
if isAdvert && fromNode != "" {
firstHop := strings.ToLower(path[0])
fromLower := strings.ToLower(fromNode)
if fromLower != firstHop { // self-edge guard (shouldn't happen but spec says check)
candidates := pm.m[firstHop]
g.upsertEdgeWithCandidates(fromLower, firstHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
}
}
// Edge 2: observer ↔ path[last] — ALL packet types
lastHop := strings.ToLower(path[len(path)-1])
if observerPK != lastHop { // self-edge guard
candidates := pm.m[lastHop]
g.upsertEdgeWithCandidates(observerPK, lastHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp))
}
}
}
// Phase 2: Disambiguation via Jaccard similarity.
g.disambiguate()
g.mu.Lock()
g.builtAt = time.Now()
g.mu.Unlock()
return g
}
// extractFromNode pulls the from_node pubkey from a StoreTx.
// It looks in DecodedJSON for "from_node" or "from".
func extractFromNode(tx *StoreTx) string {
if tx.DecodedJSON == "" {
return ""
}
// Fast path: look for "from_node" key.
var decoded map[string]interface{}
if err := jsonUnmarshalFast(tx.DecodedJSON, &decoded); err != nil {
return ""
}
if v, ok := decoded["from_node"]; ok {
if s, ok := v.(string); ok {
return s
}
}
if v, ok := decoded["from"]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// jsonUnmarshalFast is a thin wrapper; could be optimized later.
func jsonUnmarshalFast(data string, v interface{}) error {
return json.Unmarshal([]byte(data), v)
}
// upsertEdge adds/updates an edge between two fully-known pubkeys.
func (g *NeighborGraph) upsertEdge(pubkeyA, pubkeyB, prefix, observer string, snr *float64, ts time.Time) {
key := makeEdgeKey(pubkeyA, pubkeyB)
g.mu.Lock()
defer g.mu.Unlock()
e, exists := g.edges[key]
if !exists {
e = &NeighborEdge{
NodeA: key.A,
NodeB: key.B,
Prefix: prefix,
Observers: make(map[string]bool),
FirstSeen: ts,
LastSeen: ts,
}
g.edges[key] = e
g.byNode[key.A] = append(g.byNode[key.A], e)
g.byNode[key.B] = append(g.byNode[key.B], e)
}
e.Count++
if ts.After(e.LastSeen) {
e.LastSeen = ts
}
if ts.Before(e.FirstSeen) {
e.FirstSeen = ts
}
if snr != nil {
e.SNRSum += *snr
e.SNRCount++
}
if observer != "" {
e.Observers[observer] = true
}
}
// upsertEdgeWithCandidates handles prefix-based edges that may be ambiguous.
func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candidates []nodeInfo, observer string, snr *float64, ts time.Time) {
if len(candidates) == 1 {
resolved := strings.ToLower(candidates[0].PublicKey)
if resolved == knownPK {
return // self-edge guard
}
g.upsertEdge(knownPK, resolved, prefix, observer, snr, ts)
return
}
// Filter out self from candidates
filtered := make([]string, 0, len(candidates))
for _, c := range candidates {
pk := strings.ToLower(c.PublicKey)
if pk != knownPK {
filtered = append(filtered, pk)
}
}
if len(filtered) == 1 {
g.upsertEdge(knownPK, filtered[0], prefix, observer, snr, ts)
return
}
// Ambiguous or orphan: use prefix-based key
pseudoB := "prefix:" + prefix
key := makeEdgeKey(knownPK, pseudoB)
g.mu.Lock()
defer g.mu.Unlock()
e, exists := g.edges[key]
if !exists {
e = &NeighborEdge{
NodeA: key.A,
NodeB: "",
Prefix: prefix,
Observers: make(map[string]bool),
Ambiguous: true,
Candidates: filtered,
FirstSeen: ts,
LastSeen: ts,
}
g.edges[key] = e
g.byNode[knownPK] = append(g.byNode[knownPK], e)
}
e.Count++
if ts.After(e.LastSeen) {
e.LastSeen = ts
}
if ts.Before(e.FirstSeen) {
e.FirstSeen = ts
}
if snr != nil {
e.SNRSum += *snr
e.SNRCount++
}
if observer != "" {
e.Observers[observer] = true
}
}
// ─── Disambiguation ────────────────────────────────────────────────────────────
// disambiguate resolves ambiguous edges using Jaccard similarity of neighbor sets.
// Only fully-resolved edges are used as evidence (transitivity poisoning guard).
func (g *NeighborGraph) disambiguate() {
g.mu.Lock()
defer g.mu.Unlock()
// Build resolved neighbor sets: for each node, collect the set of nodes
// it has fully-resolved (non-ambiguous) edges with.
resolvedNeighbors := make(map[string]map[string]bool)
for _, e := range g.edges {
if e.Ambiguous || e.NodeB == "" {
continue
}
if resolvedNeighbors[e.NodeA] == nil {
resolvedNeighbors[e.NodeA] = make(map[string]bool)
}
if resolvedNeighbors[e.NodeB] == nil {
resolvedNeighbors[e.NodeB] = make(map[string]bool)
}
resolvedNeighbors[e.NodeA][e.NodeB] = true
resolvedNeighbors[e.NodeB][e.NodeA] = true
}
// Try to resolve each ambiguous edge.
for key, e := range g.edges {
if !e.Ambiguous || len(e.Candidates) < 2 {
continue
}
if e.Count < affinityMinObservations {
continue
}
// Determine the known node (the one that's a real pubkey, not the prefix side).
knownNode := e.NodeA
if strings.HasPrefix(e.NodeA, "prefix:") {
knownNode = e.NodeB
}
// If knownNode is empty (shouldn't happen for ambiguous edges with candidates), skip.
if knownNode == "" {
continue
}
knownNeighbors := resolvedNeighbors[knownNode]
type scored struct {
pubkey string
jaccard float64
}
var scores []scored
for _, cand := range e.Candidates {
candNeighbors := resolvedNeighbors[cand]
j := jaccardSimilarity(knownNeighbors, candNeighbors)
scores = append(scores, scored{cand, j})
}
if len(scores) < 2 {
continue
}
// Find best and second-best.
best, secondBest := scores[0], scores[1]
if secondBest.jaccard > best.jaccard {
best, secondBest = secondBest, best
}
for i := 2; i < len(scores); i++ {
if scores[i].jaccard > best.jaccard {
secondBest = best
best = scores[i]
} else if scores[i].jaccard > secondBest.jaccard {
secondBest = scores[i]
}
}
// Auto-resolve only if best >= 3× second-best AND enough observations.
if secondBest.jaccard == 0 {
// If second-best is 0 and best > 0, ratio is infinite → resolve.
if best.jaccard > 0 {
g.resolveEdge(key, e, knownNode, best.pubkey)
}
} else if best.jaccard/secondBest.jaccard >= affinityConfidenceRatio {
g.resolveEdge(key, e, knownNode, best.pubkey)
}
// Otherwise remain ambiguous.
}
}
// resolveEdge converts an ambiguous edge to a resolved one.
// Must be called with g.mu held.
func (g *NeighborGraph) resolveEdge(oldKey edgeKey, e *NeighborEdge, knownNode, resolvedPK string) {
// Remove old edge.
delete(g.edges, oldKey)
g.removeFromByNode(oldKey.A, e)
g.removeFromByNode(oldKey.B, e)
// Update edge.
newKey := makeEdgeKey(knownNode, resolvedPK)
e.NodeA = newKey.A
e.NodeB = newKey.B
e.Ambiguous = false
e.Resolved = true
// Merge with existing edge if any.
if existing, ok := g.edges[newKey]; ok {
existing.Count += e.Count
if e.LastSeen.After(existing.LastSeen) {
existing.LastSeen = e.LastSeen
}
if e.FirstSeen.Before(existing.FirstSeen) {
existing.FirstSeen = e.FirstSeen
}
existing.SNRSum += e.SNRSum
existing.SNRCount += e.SNRCount
for obs := range e.Observers {
existing.Observers[obs] = true
}
return
}
g.edges[newKey] = e
g.byNode[newKey.A] = append(g.byNode[newKey.A], e)
g.byNode[newKey.B] = append(g.byNode[newKey.B], e)
}
// removeFromByNode removes an edge from the byNode index for the given key.
func (g *NeighborGraph) removeFromByNode(nodeKey string, edge *NeighborEdge) {
edges := g.byNode[nodeKey]
for i, e := range edges {
if e == edge {
g.byNode[nodeKey] = append(edges[:i], edges[i+1:]...)
return
}
}
}
// jaccardSimilarity computes |A ∩ B| / |A B|.
func jaccardSimilarity(a, b map[string]bool) float64 {
if len(a) == 0 && len(b) == 0 {
return 0
}
intersection := 0
for k := range a {
if b[k] {
intersection++
}
}
union := len(a) + len(b) - intersection
if union == 0 {
return 0
}
return float64(intersection) / float64(union)
}
// parseTimestamp parses a timestamp string into time.Time.
func parseTimestamp(s string) time.Time {
// Try common formats.
for _, fmt := range []string{
time.RFC3339,
"2006-01-02T15:04:05Z",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05.000Z",
} {
if t, err := time.Parse(fmt, s); err == nil {
return t
}
}
return time.Time{}
}
-642
View File
@@ -1,642 +0,0 @@
package main
import (
"encoding/json"
"math"
"testing"
"time"
)
// ─── Helpers ───────────────────────────────────────────────────────────────────
// ngTestStore creates a minimal PacketStore with injected nodes and packets.
func ngTestStore(nodes []nodeInfo, packets []*StoreTx) *PacketStore {
if nodes == nil {
nodes = []nodeInfo{}
}
if packets == nil {
packets = []*StoreTx{}
}
ps := &PacketStore{
packets: packets,
byHash: make(map[string]*StoreTx),
byTxID: make(map[int]*StoreTx),
byObsID: make(map[int]*StoreObs),
byObserver: make(map[string][]*StoreObs),
byNode: make(map[string][]*StoreTx),
nodeHashes: make(map[string]map[string]bool),
byPayloadType: make(map[int][]*StoreTx),
rfCache: make(map[string]*cachedResult),
topoCache: make(map[string]*cachedResult),
hashCache: make(map[string]*cachedResult),
collisionCache: make(map[string]*cachedResult),
chanCache: make(map[string]*cachedResult),
distCache: make(map[string]*cachedResult),
subpathCache: make(map[string]*cachedResult),
spIndex: make(map[string]int),
}
ps.nodeCache = nodes
ps.nodePM = buildPrefixMap(nodes)
ps.nodeCacheTime = time.Now().Add(1 * time.Hour)
return ps
}
func ngIntPtr(v int) *int { return &v }
func ngFloatPtr(v float64) *float64 { return &v }
func ngMakeTx(id int, payloadType int, decodedJSON string, obs []*StoreObs) *StoreTx {
tx := &StoreTx{
ID: id,
PayloadType: ngIntPtr(payloadType),
DecodedJSON: decodedJSON,
Observations: obs,
}
return tx
}
func ngMakeObs(observerID, pathJSON, timestamp string, snr *float64) *StoreObs {
return &StoreObs{
ObserverID: observerID,
PathJSON: pathJSON,
Timestamp: timestamp,
SNR: snr,
}
}
func ngFromNodeJSON(pubkey string) string {
b, _ := json.Marshal(map[string]string{"from_node": pubkey})
return string(b)
}
var now = time.Now()
var nowStr = now.UTC().Format(time.RFC3339)
var weekAgoStr = now.Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339)
var monthAgoStr = now.Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339)
// ─── Tests ─────────────────────────────────────────────────────────────────────
func TestBuildNeighborGraph_EmptyStore(t *testing.T) {
store := ngTestStore(nil, nil)
g := BuildFromStore(store)
if len(g.edges) != 0 {
t.Errorf("expected 0 edges, got %d", len(g.edges))
}
}
func TestBuildNeighborGraph_AdvertSingleHopPath(t *testing.T) {
// ADVERT from X, path=["R1_prefix"] → edges: X↔R1 and Observer↔R1
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "r1aabbcc", Name: "R1"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["r1aa"]`, nowStr, ngFloatPtr(-10)),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
// Should have 2 edges: X↔R1 and Observer↔R1
// But since path has 1 element, path[0]==path[last], so for ADVERTs
// both edge types point to the same hop. X↔R1 and Obs↔R1 = 2 edges.
edges := g.AllEdges()
if len(edges) != 2 {
t.Fatalf("expected 2 edges, got %d", len(edges))
}
// Check X↔R1 exists
found := false
for _, e := range edges {
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") ||
(e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
found = true
}
}
if !found {
t.Error("missing originator↔path[0] edge (X↔R1)")
}
// Check Observer↔R1 exists
found = false
for _, e := range edges {
if (e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") ||
(e.NodeA == "r1aabbcc" && e.NodeB == "obs00001") {
found = true
}
}
if !found {
t.Error("missing observer↔path[last] edge (Observer↔R1)")
}
}
func TestBuildNeighborGraph_AdvertMultiHopPath(t *testing.T) {
// ADVERT from X, path=["R1","R2"] → X↔R1 and Observer↔R2
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "r1aabbcc", Name: "R1"},
{PublicKey: "r2ddeeff", Name: "R2"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
edges := g.AllEdges()
if len(edges) != 2 {
t.Fatalf("expected 2 edges, got %d", len(edges))
}
// X↔R1
hasXR1 := false
hasObsR2 := false
for _, e := range edges {
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
hasXR1 = true
}
if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") {
hasObsR2 = true
}
}
if !hasXR1 {
t.Error("missing X↔R1 edge")
}
if !hasObsR2 {
t.Error("missing Observer↔R2 edge")
}
}
func TestBuildNeighborGraph_AdvertZeroHop(t *testing.T) {
// ADVERT from X, path=[] → X↔Observer direct edge
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `[]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
edges := g.AllEdges()
if len(edges) != 1 {
t.Fatalf("expected 1 edge, got %d", len(edges))
}
e := edges[0]
if !((e.NodeA == "aaaa1111" && e.NodeB == "obs00001") || (e.NodeA == "obs00001" && e.NodeB == "aaaa1111")) {
t.Errorf("expected X↔Observer edge, got %s↔%s", e.NodeA, e.NodeB)
}
if e.Ambiguous {
t.Error("zero-hop edge should not be ambiguous")
}
}
func TestBuildNeighborGraph_NonAdvertEmptyPath(t *testing.T) {
// Non-ADVERT, path=[] → no edges
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `[]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
if len(g.edges) != 0 {
t.Errorf("expected 0 edges for non-ADVERT empty path, got %d", len(g.edges))
}
}
func TestBuildNeighborGraph_NonAdvertOnlyObserverEdge(t *testing.T) {
// Non-ADVERT with path=["R1","R2"] → only Observer↔R2, NO originator edge
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "r1aabbcc", Name: "R1"},
{PublicKey: "r2ddeeff", Name: "R2"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
edges := g.AllEdges()
if len(edges) != 1 {
t.Fatalf("expected 1 edge, got %d", len(edges))
}
e := edges[0]
if !((e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001")) {
t.Errorf("expected Observer↔R2 edge, got %s↔%s", e.NodeA, e.NodeB)
}
}
func TestBuildNeighborGraph_NonAdvertSingleHop(t *testing.T) {
// Non-ADVERT with path=["R1"] → Observer↔R1 only
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "r1aabbcc", Name: "R1"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
edges := g.AllEdges()
if len(edges) != 1 {
t.Fatalf("expected 1 edge, got %d", len(edges))
}
e := edges[0]
if !((e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "obs00001")) {
t.Errorf("expected Observer↔R1, got %s↔%s", e.NodeA, e.NodeB)
}
}
func TestBuildNeighborGraph_HashCollision(t *testing.T) {
// Two nodes share prefix "a3" → ambiguous edge
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "a3bb1111", Name: "CandidateA"},
{PublicKey: "a3bb2222", Name: "CandidateB"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["a3bb"]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
// Should have ambiguous edges
var ambigCount int
for _, e := range g.AllEdges() {
if e.Ambiguous {
ambigCount++
if len(e.Candidates) < 2 {
t.Errorf("expected >=2 candidates, got %d", len(e.Candidates))
}
}
}
if ambigCount == 0 {
t.Error("expected at least one ambiguous edge for hash collision")
}
}
func TestBuildNeighborGraph_JaccardScoring(t *testing.T) {
// Test Jaccard similarity computation directly
a := map[string]bool{"x": true, "y": true, "z": true}
b := map[string]bool{"y": true, "z": true, "w": true}
j := jaccardSimilarity(a, b)
// intersection = {y, z} = 2, union = {x, y, z, w} = 4 → 0.5
if math.Abs(j-0.5) > 0.001 {
t.Errorf("expected Jaccard 0.5, got %f", j)
}
// Empty sets
j = jaccardSimilarity(nil, nil)
if j != 0 {
t.Errorf("expected 0 for empty sets, got %f", j)
}
}
func TestBuildNeighborGraph_ConfidenceAutoResolve(t *testing.T) {
// Setup: NodeX has known neighbors N1, N2, N3 (resolved edges).
// CandidateA also has known neighbors N1, N2, N3 (high Jaccard with X).
// CandidateB has no known neighbors (Jaccard = 0).
// An ambiguous edge X↔prefix "a3" with candidates [A, B] should auto-resolve to A.
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "n1111111", Name: "N1"},
{PublicKey: "n2222222", Name: "N2"},
{PublicKey: "n3333333", Name: "N3"},
{PublicKey: "a3001111", Name: "CandidateA"},
{PublicKey: "a3002222", Name: "CandidateB"},
{PublicKey: "obs00001", Name: "Observer"},
}
// Create resolved edges: X↔N1, X↔N2, X↔N3, A↔N1, A↔N2, A↔N3
// Then an ambiguous edge X↔"a300" prefix with 3+ observations.
var txs []*StoreTx
txID := 1
// X sends ADVERTs through N1, N2, N3
for _, nhop := range []string{"n111", "n222", "n333"} {
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil),
}))
txID++
}
// CandidateA sends ADVERTs through N1, N2, N3
for _, nhop := range []string{"n111", "n222", "n333"} {
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{
ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil),
}))
txID++
}
// Ambiguous edge: X sends ADVERTs with path[0]="a300" (matches both candidates)
// Need 3+ observations for confidence threshold.
for i := 0; i < 3; i++ {
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["a300"]`, nowStr, nil),
}))
txID++
}
store := ngTestStore(nodes, txs)
g := BuildFromStore(store)
// The ambiguous edge X↔a300 should have been resolved to CandidateA
neighbors := g.Neighbors("aaaa1111")
foundA := false
for _, e := range neighbors {
other := e.NodeB
if e.NodeA != "aaaa1111" {
other = e.NodeA
}
if other == "a3001111" {
foundA = true
if e.Ambiguous {
t.Error("edge should have been resolved (not ambiguous)")
}
}
}
if !foundA {
t.Error("expected edge X↔CandidateA to be auto-resolved")
}
}
func TestBuildNeighborGraph_EqualScoresAmbiguous(t *testing.T) {
// Two candidates with identical neighbor sets → should NOT auto-resolve.
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "n1111111", Name: "N1"},
{PublicKey: "a3001111", Name: "CandidateA"},
{PublicKey: "a3002222", Name: "CandidateB"},
{PublicKey: "obs00001", Name: "Observer"},
}
var txs []*StoreTx
txID := 1
// X↔N1
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
}))
txID++
// Both candidates have same neighbor (N1)
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
}))
txID++
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3002222"), []*StoreObs{
ngMakeObs("obs00001", `["n111"]`, nowStr, nil),
}))
txID++
// Ambiguous edge with 3+ observations
for i := 0; i < 3; i++ {
txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["a300"]`, nowStr, nil),
}))
txID++
}
store := ngTestStore(nodes, txs)
g := BuildFromStore(store)
// Should remain ambiguous
var ambigFound bool
for _, e := range g.AllEdges() {
if e.Ambiguous && e.Prefix == "a300" {
ambigFound = true
}
}
if !ambigFound {
t.Error("expected ambiguous edge to remain unresolved with equal scores")
}
}
func TestBuildNeighborGraph_ObserverSelfEdgeGuard(t *testing.T) {
// Observer's own prefix in path → should NOT create self-edge.
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["obs0"]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
// Check no self-edge for observer
for _, e := range g.AllEdges() {
if e.NodeA == e.NodeB && e.NodeA == "obs00001" {
t.Error("self-edge created for observer")
}
}
}
func TestBuildNeighborGraph_OrphanPrefix(t *testing.T) {
// Path contains prefix matching zero nodes → edge recorded as unresolved.
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["ff99"]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
// Should have ambiguous edges with empty candidates.
var orphanFound bool
for _, e := range g.AllEdges() {
if e.Ambiguous && len(e.Candidates) == 0 {
orphanFound = true
if e.Prefix != "ff99" {
t.Errorf("expected prefix ff99, got %s", e.Prefix)
}
}
}
if !orphanFound {
t.Error("expected orphan prefix edge with empty candidates")
}
}
func TestAffinityScore_Fresh(t *testing.T) {
e := &NeighborEdge{Count: 100, LastSeen: time.Now()}
s := e.Score(time.Now())
if s < 0.99 || s > 1.0 {
t.Errorf("expected score ≈ 1.0, got %f", s)
}
}
func TestAffinityScore_Decayed(t *testing.T) {
e := &NeighborEdge{Count: 100, LastSeen: time.Now().Add(-7 * 24 * time.Hour)}
s := e.Score(time.Now())
// 7 days → half-life → ~0.5
if math.Abs(s-0.5) > 0.05 {
t.Errorf("expected score ≈ 0.5, got %f", s)
}
}
func TestAffinityScore_LowCount(t *testing.T) {
e := &NeighborEdge{Count: 5, LastSeen: time.Now()}
s := e.Score(time.Now())
// 5/100 = 0.05
if math.Abs(s-0.05) > 0.01 {
t.Errorf("expected score ≈ 0.05, got %f", s)
}
}
func TestAffinityScore_StaleAndLow(t *testing.T) {
e := &NeighborEdge{Count: 5, LastSeen: time.Now().Add(-30 * 24 * time.Hour)}
s := e.Score(time.Now())
// Very small
if s > 0.01 {
t.Errorf("expected score ≈ 0, got %f", s)
}
}
func TestBuildNeighborGraph_CountAccumulation(t *testing.T) {
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "r1aabbcc", Name: "R1"},
{PublicKey: "obs00001", Name: "Observer"},
}
var txs []*StoreTx
for i := 0; i < 5; i++ {
txs = append(txs, ngMakeTx(i+1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
}))
}
store := ngTestStore(nodes, txs)
g := BuildFromStore(store)
// Check count on X↔R1 edge
for _, e := range g.AllEdges() {
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
if e.Count != 5 {
t.Errorf("expected count 5, got %d", e.Count)
}
return
}
}
t.Error("X↔R1 edge not found")
}
func TestBuildNeighborGraph_MultipleObservers(t *testing.T) {
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "r1aabbcc", Name: "R1"},
{PublicKey: "obs00001", Name: "Obs1"},
{PublicKey: "obs00002", Name: "Obs2"},
}
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil),
ngMakeObs("obs00002", `["r1aa"]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
for _, e := range g.AllEdges() {
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
if len(e.Observers) != 2 {
t.Errorf("expected 2 observers, got %d", len(e.Observers))
}
if !e.Observers["obs00001"] || !e.Observers["obs00002"] {
t.Error("missing expected observer")
}
return
}
}
t.Error("X↔R1 edge not found")
}
func TestBuildNeighborGraph_TimeDecayOldObservations(t *testing.T) {
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "r1aabbcc", Name: "R1"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["r1aa"]`, monthAgoStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
for _, e := range g.AllEdges() {
if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") {
score := e.Score(time.Now())
if score > 0.05 {
t.Errorf("expected decayed score < 0.05, got %f", score)
}
return
}
}
t.Error("X↔R1 edge not found")
}
func TestBuildNeighborGraph_ADVERTOnlyConstraint(t *testing.T) {
// Non-ADVERT: should NOT create originator↔path[0] edge, only observer↔path[last].
nodes := []nodeInfo{
{PublicKey: "aaaa1111", Name: "NodeX"},
{PublicKey: "r1aabbcc", Name: "R1"},
{PublicKey: "r2ddeeff", Name: "R2"},
{PublicKey: "obs00001", Name: "Observer"},
}
tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{
ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil),
})
store := ngTestStore(nodes, []*StoreTx{tx})
g := BuildFromStore(store)
for _, e := range g.AllEdges() {
a, b := e.NodeA, e.NodeB
if (a == "aaaa1111" && b == "r1aabbcc") || (a == "r1aabbcc" && b == "aaaa1111") {
t.Error("non-ADVERT should NOT produce originator↔path[0] edge")
}
}
// Should have Observer↔R2
found := false
for _, e := range g.AllEdges() {
if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") {
found = true
}
}
if !found {
t.Error("missing Observer↔R2 edge from non-ADVERT")
}
}
func TestNeighborGraph_CacheTTL(t *testing.T) {
g := NewNeighborGraph()
if !g.IsStale() {
t.Error("new graph should be stale")
}
g.mu.Lock()
g.builtAt = time.Now()
g.mu.Unlock()
if g.IsStale() {
t.Error("just-built graph should not be stale")
}
g.mu.Lock()
g.builtAt = time.Now().Add(-2 * neighborGraphTTL)
g.mu.Unlock()
if !g.IsStale() {
t.Error("old graph should be stale")
}
}
+1 -12
View File
@@ -38,10 +38,6 @@ type Server struct {
statsMu sync.Mutex
statsCache *StatsResponse
statsCachedAt time.Time
// Neighbor affinity graph (lazy-built, cached with TTL)
neighborMu sync.Mutex
neighborGraph *NeighborGraph
}
// PerfStats tracks request performance.
@@ -132,7 +128,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/nodes/{pubkey}/health", s.handleNodeHealth).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}/paths", s.handleNodePaths).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}/analytics", s.handleNodeAnalytics).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}/neighbors", s.handleNodeNeighbors).Methods("GET")
r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET")
r.HandleFunc("/api/nodes", s.handleNodes).Methods("GET")
@@ -145,7 +140,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/analytics/hash-collisions", s.handleAnalyticsHashCollisions).Methods("GET")
r.HandleFunc("/api/analytics/subpaths", s.handleAnalyticsSubpaths).Methods("GET")
r.HandleFunc("/api/analytics/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET")
r.HandleFunc("/api/analytics/neighbor-graph", s.handleNeighborGraph).Methods("GET")
// Other endpoints
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
@@ -293,12 +287,7 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) {
}, s.cfg.NodeColors, theme.NodeColors)
themeDark := mergeMap(map[string]interface{}{}, s.cfg.ThemeDark, theme.ThemeDark)
typeColors := mergeMap(map[string]interface{}{
"ADVERT": "#22c55e", "GRP_TXT": "#3b82f6", "TXT_MSG": "#f59e0b",
"ACK": "#6b7280", "REQUEST": "#a855f7", "RESPONSE": "#06b6d4",
"TRACE": "#ec4899", "PATH": "#14b8a6", "ANON_REQ": "#f43f5e",
"UNKNOWN": "#6b7280",
}, s.cfg.TypeColors, theme.TypeColors)
typeColors := mergeMap(map[string]interface{}{}, s.cfg.TypeColors, theme.TypeColors)
var home interface{}
if theme.Home != nil {
-147
View File
@@ -454,35 +454,6 @@ func TestConfigThemeEndpoint(t *testing.T) {
}
}
func TestConfigThemeTypeColorsDefaults(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/config/theme", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
tc, ok := body["typeColors"].(map[string]interface{})
if !ok {
t.Fatal("expected typeColors object in theme response")
}
expectedTypes := []string{"ADVERT", "GRP_TXT", "TXT_MSG", "ACK", "REQUEST", "RESPONSE", "TRACE", "PATH", "ANON_REQ", "UNKNOWN"}
for _, typ := range expectedTypes {
val, exists := tc[typ]
if !exists {
t.Errorf("typeColors missing default for %s", typ)
continue
}
color, ok := val.(string)
if !ok || color == "" || color == "#000000" {
t.Errorf("typeColors[%s] should be a non-black color, got %v", typ, val)
}
}
}
func TestConfigMapEndpoint(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/config/map", nil)
@@ -2207,124 +2178,6 @@ func TestGetNodeHashSizeInfoLatestWins(t *testing.T) {
}
}
func TestGetNodeHashSizeInfoIgnoreDirectZeroHop(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
pk := "dddd111122223333444455556666777788889999aaaabbbbccccddddeeee3333"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'DirIgnore', 'repeater')", pk)
decoded := `{"name":"DirIgnore","pubKey":"` + pk + `"}`
rawFlood2B := "11" + "40" + "aabb" // FLOOD advert, hashSize=2
rawDirect0 := "12" + "00" + "aabb" // DIRECT advert, zero-hop (should be ignored)
payloadType := 4
raws := []string{rawFlood2B, rawDirect0, rawFlood2B, rawDirect0, rawFlood2B}
for i, raw := range raws {
tx := &StoreTx{
ID: 9150 + i,
RawHex: raw,
Hash: "dirignore" + strconv.Itoa(i),
FirstSeen: "2024-01-01T0" + strconv.Itoa(i) + ":00:00Z",
PayloadType: &payloadType,
DecodedJSON: decoded,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
}
info := store.GetNodeHashSizeInfo()
ni := info[pk]
if ni == nil {
t.Fatal("expected hash info for test node")
}
if ni.HashSize != 2 {
t.Errorf("HashSize=%d, want 2 (direct zero-hop adverts should be ignored)", ni.HashSize)
}
if ni.Inconsistent {
t.Error("expected hash_size_inconsistent=false when direct zero-hop adverts are ignored")
}
if len(ni.AllSizes) != 1 || !ni.AllSizes[2] {
t.Errorf("expected only 2-byte size in AllSizes, got %#v", ni.AllSizes)
}
}
func TestGetNodeHashSizeInfoOnlyDirectZeroHopIgnored(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
pk := "eeee111122223333444455556666777788889999aaaabbbbccccddddeeee4444"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'OnlyDirect', 'repeater')", pk)
decoded := `{"name":"OnlyDirect","pubKey":"` + pk + `"}`
rawDirect0 := "12" + "00" + "aabb"
payloadType := 4
tx := &StoreTx{
ID: 9160,
RawHex: rawDirect0,
Hash: "onlydirect0",
FirstSeen: "2024-01-01T00:00:00Z",
PayloadType: &payloadType,
DecodedJSON: decoded,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
info := store.GetNodeHashSizeInfo()
if ni := info[pk]; ni != nil {
t.Errorf("expected nil hash info for direct zero-hop only node, got HashSize=%d", ni.HashSize)
}
}
func TestGetNodeHashSizeInfoDirectNonZeroHopCounted(t *testing.T) {
// A DIRECT advert with non-zero hop count should NOT be skipped —
// only zero-hop DIRECT adverts misreport hash size.
db := setupTestDB(t)
seedTestData(t, db)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
pk := "ffff111122223333444455556666777788889999aaaabbbbccccddddeeee5555"
db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'DirNonZero', 'repeater')", pk)
decoded := `{"name":"DirNonZero","pubKey":"` + pk + `"}`
// DIRECT advert (route type 2 = 0x02 in bits 0-1), path byte 0x41:
// upper 2 bits = 01 → hash_size = 2, lower 6 bits = 0x01 → hop count 1 (non-zero)
rawDirectNonZero := "12" + "41" + "aabb" // header=0x12 (ADVERT|DIRECT), path=0x41
payloadType := 4
tx := &StoreTx{
ID: 9170,
RawHex: rawDirectNonZero,
Hash: "dirnonzero0",
FirstSeen: "2024-01-01T00:00:00Z",
PayloadType: &payloadType,
DecodedJSON: decoded,
}
store.packets = append(store.packets, tx)
store.byPayloadType[4] = append(store.byPayloadType[4], tx)
info := store.GetNodeHashSizeInfo()
ni := info[pk]
if ni == nil {
t.Fatal("expected hash info for DIRECT non-zero-hop node — it should NOT be skipped")
}
if ni.HashSize != 2 {
t.Errorf("HashSize=%d, want 2 (DIRECT with hop count > 0 should be counted)", ni.HashSize)
}
}
func TestGetNodeHashSizeInfoNoAdverts(t *testing.T) {
// A node with no ADVERT packets should not appear in hash size info.
db := setupTestDB(t)
-15
View File
@@ -369,11 +369,6 @@ func (s *PacketStore) indexByNode(tx *StoreTx) {
if tx.DecodedJSON == "" {
return
}
// All three target fields ("pubKey", "destPubKey", "srcPubKey") share the
// common suffix "ubKey" — skip JSON parse for packets that have none of them.
if !strings.Contains(tx.DecodedJSON, "ubKey") {
return
}
var decoded map[string]interface{}
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) != nil {
return
@@ -4542,20 +4537,10 @@ func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
if len(tx.RawHex) < 4 {
continue
}
header, err := strconv.ParseUint(tx.RawHex[:2], 16, 8)
if err != nil {
continue
}
routeType := int(header & 0x03)
pathByte, err := strconv.ParseUint(tx.RawHex[2:4], 16, 8)
if err != nil {
continue
}
// DIRECT zero-hop adverts use path byte 0x00 locally and can misreport
// multibyte repeater hash mode as 1-byte.
if routeType == RouteDirect && (pathByte&0x3F) == 0 {
continue
}
hs := int((pathByte>>6)&0x3) + 1
var d map[string]interface{}
-568
View File
@@ -1,568 +0,0 @@
# Customizer Rework Spec
## Overview
The current customizer (`public/customize.js`) suffers from fundamental state management issues documented in [issue #284](https://github.com/Kpa-clawbot/CoreScope/issues/284). State is scattered across 7 localStorage keys, CSS updates bypass the data layer, and there's no single source of truth for the effective configuration.
This spec defines a clean rework based on event-driven state management with a single data flow path. The goal: predictable state, minimal storage footprint, portable config format, and zero ambiguity about which values are active and why.
## Design Decisions
These are agreed and final. Do not reinterpret or deviate.
1. **Three state layers:** server defaults (immutable after fetch), user overrides (delta in localStorage), effective config (computed via merge, never stored directly).
2. **Single data flow:** user action → debounce (~300ms) → write delta to localStorage → read back from localStorage → merge with server defaults → apply CSS variables. No shortcuts, no optimistic CSS updates (see Decision #12 for the one exception).
3. **One localStorage key:** `cs-theme-overrides` — replaces the current 7 scattered keys (`meshcore-user-theme`, `meshcore-timestamp-mode`, `meshcore-timestamp-timezone`, `meshcore-timestamp-format`, `meshcore-timestamp-custom-format`, `meshcore-heatmap-opacity`, `meshcore-live-heatmap-opacity`).
4. **Universal format:** same shape as the server's `ThemeResponse` plus additional keys. Works identically for user export, admin `theme.json`, and user import.
5. **User overrides always win** in merge — `merge(serverDefaults, userOverrides)` = effective config.
6. **Override indicator:** shown in customizer panel ONLY when override value differs from current server default.
7. **No silent pruning:** overrides stay in localStorage until the user explicitly resets them (per-field reset or full reset). The delta may contain values that happen to match current server defaults — that's fine. User intent is preserved; nothing silently disappears.
8. **Per-field reset:** remove a single key from the delta → re-merge → re-apply CSS.
9. **Full reset:** `localStorage.removeItem('cs-theme-overrides')` → re-merge (effective = server defaults) → re-apply CSS.
10. **Export = dump delta object as JSON download. Import = validate shape, write to localStorage, trigger re-merge.**
11. **No CSS magic:** CSS variables ONLY update after the localStorage round-trip completes. No optimistic updates (see Decision #12 for the one exception).
12. **Color picker optimistic CSS exception:** For continuous inputs (color pickers, sliders), CSS is updated optimistically during `input` events for visual responsiveness. The localStorage write only happens on `change` event (mouseup/blur). On `change`, the full pipeline runs: write → read → merge → apply (which will match the optimistic state). If the user refreshes mid-drag before `change` fires, the change is lost — this is acceptable. This is the ONLY exception to the localStorage-first rule.
## Dark/Light Mode
The customizer treats light and dark mode as separate override sections:
- **`theme`** stores light mode color overrides.
- **`themeDark`** stores dark mode color overrides.
- When the user changes a color in the customizer, it writes to whichever section matches their current mode: `theme` if light, `themeDark` if dark.
- The dark/light mode toggle preference (`meshcore-theme` localStorage key) is **separate** from the delta object. It is a view preference, not a customization — it is not stored in `cs-theme-overrides`.
- The customizer UI shows color fields for the currently active mode only. Switching modes re-renders the color fields with values from the matching section.
## Presets
The existing preset themes are preserved and flow through the standard pipeline:
**Available presets:** Default, Ocean, Forest, Sunset, Monochrome.
**How presets work:**
- Clicking a preset writes its values to localStorage via the same pipeline as any other change: preset data → `writeOverrides()` → read back → merge → apply CSS.
- Presets are NOT special — they are pre-built delta objects applied through the standard flow.
- Each preset contains both `theme` (light) and `themeDark` (dark) sections, plus any other overrides the preset defines (e.g., `nodeColors`).
- **"Reset to Default"** = clear all overrides (equivalent to full reset: `localStorage.removeItem('cs-theme-overrides')` → re-merge → apply).
**Preset data format:** Same shape as the delta object. Example:
```json
{
"theme": {
"accent": "#0077b6",
"navBg": "#03045e",
"background": "#f0f7fa"
},
"themeDark": {
"accent": "#48cae4",
"navBg": "#03045e",
"background": "#0a1929"
}
}
```
Applying a preset **replaces** the entire delta (it's a `writeOverrides(presetData)`, not a merge onto existing overrides). The user can then further customize individual fields on top.
## Data Model
### Delta Object Format
The user override delta is a sparse object — it only contains fields the user has explicitly changed. The shape mirrors the server's `ThemeResponse` (from `/api/config/theme`) plus additional client-only sections:
```json
{
"branding": {
"siteName": "string — site name override",
"tagline": "string — tagline override",
"logoUrl": "string — custom logo URL",
"faviconUrl": "string — custom favicon URL"
},
"theme": {
"accent": "string — CSS color, light mode accent",
"accentHover": "string — CSS color, light mode accent hover",
"navBg": "string — CSS color, nav background",
"navBg2": "string — CSS color, nav secondary background",
"navText": "string — CSS color, nav text",
"navTextMuted": "string — CSS color, nav muted text",
"background": "string — CSS color, page background",
"text": "string — CSS color, body text",
"textMuted": "string — CSS color, muted text",
"border": "string — CSS color, borders",
"surface1": "string — CSS color, surface level 1",
"surface2": "string — CSS color, surface level 2",
"cardBg": "string — CSS color, card backgrounds",
"contentBg": "string — CSS color, content area background",
"detailBg": "string — CSS color, detail pane background",
"inputBg": "string — CSS color, input backgrounds",
"rowStripe": "string — CSS color, alternating row stripe",
"rowHover": "string — CSS color, row hover highlight",
"selectedBg": "string — CSS color, selected row background",
"statusGreen": "string — CSS color, healthy status",
"statusYellow": "string — CSS color, degraded status",
"statusRed": "string — CSS color, critical status",
"font": "string — CSS font-family for body text",
"mono": "string — CSS font-family for monospace"
},
"themeDark": {
"/* same keys as theme — dark mode overrides */"
},
"nodeColors": {
"repeater": "string — CSS color",
"companion": "string — CSS color",
"room": "string — CSS color",
"sensor": "string — CSS color",
"observer": "string — CSS color"
},
"typeColors": {
"ADVERT": "string — CSS color",
"GRP_TXT": "string — CSS color",
"TXT_MSG": "string — CSS color",
"ACK": "string — CSS color",
"REQUEST": "string — CSS color",
"RESPONSE": "string — CSS color",
"TRACE": "string — CSS color",
"PATH": "string — CSS color",
"ANON_REQ": "string — CSS color"
},
"home": {
"heroTitle": "string",
"heroSubtitle": "string",
"steps": "[array of {emoji, title, description}]",
"checklist": "[array of strings]",
"footerLinks": "[array of {label, url}]"
},
"timestamps": {
"defaultMode": "string — 'ago' | 'absolute'",
"timezone": "string — 'local' | 'utc'",
"formatPreset": "string — 'iso' | 'iso-seconds' | 'locale'",
"customFormat": "string — custom strftime-style format"
},
"heatmapOpacity": "number — 0.0 to 1.0",
"liveHeatmapOpacity": "number — 0.0 to 1.0"
}
```
**Rules:**
- All sections and keys are optional. An empty object `{}` means "no overrides."
- The `timestamps`, `heatmapOpacity`, and `liveHeatmapOpacity` keys are client-only extensions — not part of the server's `ThemeResponse`, but included in the universal format for portability.
### localStorage Key
**Key:** `cs-theme-overrides`
**Value:** JSON string of the delta object above.
**Absent key** = no overrides = effective config equals server defaults.
### Dark/Light Mode Preference
**Key:** `meshcore-theme`
**Value:** `"dark"` or `"light"` (or absent = follow system preference).
**This key is NOT part of the delta object.** It controls which mode is active, not which colors are used. The delta stores overrides for both modes independently in `theme` and `themeDark`.
## Data Flow Diagrams
### Page Load
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Fetch │ │ Read localStorage │ │ Migration check │
│ /api/config/ │ │ cs-theme-overrides│ │ (one-time) │
│ theme │ └────────┬─────────┘ └────────┬────────┘
└──────┬──────┘ │ │
│ │ ┌────────────────────┘
▼ ▼ ▼
serverDefaults userOverrides (possibly migrated)
│ │
▼ ▼
┌──────────────────────────────────────┐
│ computeEffective(server, userOverrides) │
└──────────────┬───────────────────────┘
┌──────────────────────────────────────┐
│ window.SITE_CONFIG = effective │ ← atomic assignment
└──────────────┬───────────────────────┘
┌──────────────────────┐
│ applyCSS(effective) │ ← sets CSS vars on :root for current mode
└──────────────────────┘
┌──────────────────────────────┐
│ dispatch 'theme-changed' │ ← bare signal, no payload
└──────────────────────────────┘
```
### User Change (e.g., picks new accent color)
```
User action (input/click)
debounce(300ms)
setOverride('theme', 'accent', '#ff0000')
├─► readOverrides() ← read current delta from localStorage
│ │
│ ▼
├─► update delta object ← set delta.theme.accent = '#ff0000'
│ │
│ ▼
├─► writeOverrides(delta) ← serialize & write to localStorage
│ │
│ ▼
├─► readOverrides() ← read BACK from localStorage (round-trip)
│ │
│ ▼
├─► computeEffective(server, delta)
│ │
│ ▼
├─► window.SITE_CONFIG = effective ← atomic assignment
│ │
│ ▼
└─► applyCSS(effective) ← CSS vars updated on :root
dispatch 'theme-changed'
```
**Color picker / slider exception:** During continuous `input` events (drag), CSS is updated optimistically (directly setting `--var` on `:root`) without the localStorage round-trip. The full pipeline above only runs on the `change` event (mouseup/blur).
### Per-Field Reset
```
User clicks reset icon on a field
clearOverride('theme', 'accent')
├─► readOverrides()
├─► delete delta.theme.accent
├─► if delta.theme is empty, delete delta.theme
├─► writeOverrides(delta)
├─► readOverrides() ← round-trip
├─► computeEffective(server, delta)
├─► window.SITE_CONFIG = effective
└─► applyCSS(effective)
dispatch 'theme-changed'
```
### Full Reset
```
User clicks "Reset All"
localStorage.removeItem('cs-theme-overrides')
computeEffective(server, {}) ← no overrides = server defaults
window.SITE_CONFIG = effective
applyCSS(effective)
dispatch 'theme-changed'
```
### Export
```
User clicks "Export"
readOverrides()
JSON.stringify(delta, null, 2)
trigger download as .json file
```
### Import
```
User selects .json file
parse JSON
validateShape(parsed) ← check structure, validate values
├─► invalid → show error, abort
▼ valid
writeOverrides(parsed)
readOverrides() ← round-trip
computeEffective(server, delta)
window.SITE_CONFIG = effective
applyCSS(effective)
dispatch 'theme-changed'
```
## Function Signatures
### `readOverrides() → object`
Reads `cs-theme-overrides` from localStorage, parses as JSON. Returns empty object `{}` on missing key, parse error, or non-object value. Never throws.
### `writeOverrides(delta: object) → void`
Serializes `delta` to JSON and writes to `cs-theme-overrides` in localStorage. If `delta` is empty (`{}`), removes the key entirely.
**Validation on write:**
- Color values must match: `#hex` (3, 4, 6, or 8 digit), `rgb()`, `rgba()`, `hsl()`, `hsla()`, or CSS named colors. Invalid color values are rejected (not written) with `console.warn`.
- Numeric values (`heatmapOpacity`, `liveHeatmapOpacity`) must be finite numbers in the range 01. Invalid values are rejected with `console.warn`.
- Timestamp enum values are validated against known options (`defaultMode`: `'ago'`/`'absolute'`; `timezone`: `'local'`/`'utc'`; `formatPreset`: `'iso'`/`'iso-seconds'`/`'locale'`). Invalid values are rejected with `console.warn`.
**Quota error handling:**
- Wrap `localStorage.setItem` in try/catch.
- On `QuotaExceededError`: show a visible warning to the user ("Storage full — changes may not be saved"), log to console.
- Do NOT silently swallow the error.
### `computeEffective(serverConfig: object, userOverrides: object) → object`
Deep merges `userOverrides` onto `serverConfig`. For each section (e.g., `theme`, `nodeColors`), if `userOverrides` has the section, its keys override the corresponding `serverConfig` keys. Top-level non-object keys (e.g., `heatmapOpacity`) are directly overridden.
Returns a new object — neither input is mutated.
**Merge rules:**
- Object sections: shallow merge per section (`Object.assign({}, server.theme, user.theme)`)
- Array sections (e.g., `home.steps`): full replacement (user array wins entirely, no element-level merge)
- Scalar sections (e.g., `heatmapOpacity`): direct replacement
After computing the effective config, writes it to `window.SITE_CONFIG` atomically (single assignment, not piecemeal mutations).
### `applyCSS(effectiveConfig: object) → void`
Maps effective config values to CSS custom properties on `:root`. Behavior:
1. Reads the current mode (light/dark) from the `meshcore-theme` localStorage key, falling back to system preference (`prefers-color-scheme`).
2. Applies the matching section's values: `theme` for light mode, `themeDark` for dark mode.
3. Also applies mode-independent values: node colors as `--node-{role}`, type colors as `--type-{name}`, font families as `--font-body` and `--font-mono`.
4. Does NOT generate dual CSS rule blocks — only the current mode's values are applied to `:root`.
5. On dark/light mode toggle, `applyCSS` is called again to re-apply the correct section.
Updates the `<style>` element (create if absent, reuse if present). Dispatches a `theme-changed` CustomEvent on `window` after applying.
### `theme-changed` Event
- `theme-changed` is a bare `CustomEvent` with no payload (matches current behavior).
- After each merge cycle, the effective config is written to `window.SITE_CONFIG` atomically (single assignment).
- `window.SITE_CONFIG` is the canonical readable source for effective config throughout the app. All existing listeners that read from `SITE_CONFIG` continue to work without changes.
### `setOverride(section: string, key: string, value: any) → void`
Sets a single override. For nested sections (e.g., `section='theme'`, `key='accent'`), sets `delta[section][key] = value`. For top-level scalars (e.g., `section=null`, `key='heatmapOpacity'`), sets `delta[key] = value`.
Follows the full data flow: read → update → write → read-back → merge → apply CSS → dispatch `theme-changed`. Debounced at ~300ms (the debounce wraps the write-through-to-CSS portion).
### `clearOverride(section: string, key: string) → void`
Removes a single key from the delta. If the section becomes empty after removal, removes the section too. Triggers the full data flow (no debounce — resets should feel instant).
### `migrateOldKeys() → object | null`
One-time migration. Checks for any of the 7 legacy localStorage keys. If found:
1. Reads all legacy values
2. Maps them into the new delta format (see Migration Plan)
3. Writes the merged delta to `cs-theme-overrides`
4. Removes all 7 legacy keys
5. Returns the migrated delta
Returns `null` if no legacy keys found.
### `validateShape(obj: any) → { valid: boolean, errors: string[] }`
Validates that an imported object conforms to the expected shape:
- Must be a plain object
- Top-level keys must be from the known set: `branding`, `theme`, `themeDark`, `nodeColors`, `typeColors`, `home`, `timestamps`, `heatmapOpacity`, `liveHeatmapOpacity`
- Section values must be objects (where expected) or correct scalar types
- Color values are validated: must match `#hex` (3, 4, 6, or 8 digit), `rgb()`, `rgba()`, `hsl()`, `hsla()`, or CSS named colors
- Numeric values (`heatmapOpacity`, `liveHeatmapOpacity`) must be finite numbers in range 01
- Timestamp enum values validated against known options
Unknown top-level keys cause a warning but don't fail validation (forward compatibility).
## Migration Plan
On first page load, before the normal init flow:
1. Check if `cs-theme-overrides` already exists → if yes, skip migration.
2. Check if ANY of the 7 legacy keys exist in localStorage.
3. If legacy keys found, build a delta object using the exact mapping below:
### Field-by-Field Migration Mapping
```
meshcore-user-theme (JSON) → parse, map directly:
.branding → delta.branding
.theme → delta.theme
.themeDark → delta.themeDark
.nodeColors → delta.nodeColors
.typeColors → delta.typeColors
.home → delta.home
(any other keys are dropped)
meshcore-timestamp-mode → delta.timestamps.defaultMode
meshcore-timestamp-timezone → delta.timestamps.timezone
meshcore-timestamp-format → delta.timestamps.formatPreset
meshcore-timestamp-custom-format → delta.timestamps.customFormat
meshcore-heatmap-opacity → delta.heatmapOpacity (parseFloat)
meshcore-live-heatmap-opacity → delta.liveHeatmapOpacity (parseFloat)
```
4. Write the assembled delta to `cs-theme-overrides`.
5. Delete all 7 legacy keys.
6. Continue with normal init.
**Edge cases:**
- If `meshcore-user-theme` contains invalid JSON, skip it (log a warning to console).
- If a legacy value is empty string or null, skip that field.
- Migration runs exactly once — the presence of `cs-theme-overrides` (even as `{}`) prevents re-migration.
## `allowCustomFormat` — User Preferences Trump
The server-side `allowCustomFormat` gate is not enforced client-side. If a user imports a delta with a custom format, it's applied regardless. The server controls what formats are available in the UI (whether the custom format input field is shown), but does not block stored preferences.
## Override Indicator UX
In the customizer panel, each field that has an active override (value differs from server default) shows a visual indicator:
- **Indicator:** A small dot or icon (e.g., `●` or a reset arrow `↺`) adjacent to the field label.
- **Color:** Use the accent color to draw attention without being noisy.
- **Behavior:** Clicking the indicator resets that single field (calls `clearOverride`).
- **Tooltip:** "Reset to server default" or "This value differs from the server default."
- **Absence:** Fields matching the server default show no indicator — clean and minimal.
**Section-level indicator:** If any field in a section (e.g., "Theme Colors") is overridden, the tab/section header shows a count badge (e.g., "Theme Colors (3)").
**"Reset All" button:** Always visible at bottom of panel. Confirms before executing (`localStorage.removeItem` + re-merge).
## UX Requirements
### Browser-Local Banner
The customizer panel must display a persistent, always-visible notice:
> **"These settings are saved in your browser only and don't affect other users."**
This is NOT a tooltip, NOT a dismissible popup — it must be always visible in the panel header or footer area. Users must understand at a glance that their changes are local.
### Auto-Save Indicator
Show a persistent status in the customizer panel footer, Google Docs style — subtle but always present:
- **Default state:** "All changes saved" (muted text)
- **During debounce:** "Saving..." (muted text)
- **On quota error:** "⚠️ Storage full — changes may not be saved" (red text, persistent until resolved)
The indicator reflects the actual state of the localStorage write, not just the UI action.
## Server Compatibility
The delta format is intentionally shaped to be a valid subset of the server's `theme.json` admin config file. This means:
- **User export → admin import:** An admin can take a user's exported JSON and drop it into `theme.json` as server defaults. The `timestamps`, `heatmapOpacity`, and `liveHeatmapOpacity` keys are ignored by the current server (it doesn't read them from `theme.json`), but they don't cause errors.
- **Admin config → user import:** A `theme.json` file can be imported as user overrides. Unknown server-only keys are ignored by the client.
- **Round-trip safe:** Export → import produces identical delta (assuming no server default changes between operations).
The server's `ThemeResponse` struct currently returns: `branding`, `theme`, `themeDark`, `nodeColors`, `typeColors`, `home`. The client-only extensions (`timestamps`, `heatmapOpacity`, `liveHeatmapOpacity`) are additive — they extend the format without conflicting.
## Testing Requirements
### Unit Tests (Node.js, no browser required)
1. **`readOverrides`**
- Returns `{}` when key is absent
- Returns `{}` when key contains invalid JSON
- Returns `{}` when key contains a non-object (string, array, number)
- Returns parsed object when key contains valid JSON object
2. **`writeOverrides`**
- Writes serialized JSON to localStorage
- Removes key when delta is empty `{}`
- Round-trips correctly (write → read = identical object)
- Rejects invalid color values with console.warn
- Rejects out-of-range numeric values with console.warn
- Rejects invalid timestamp enum values with console.warn
- Handles QuotaExceededError gracefully (warns user, does not throw)
3. **`computeEffective`**
- Returns server defaults when overrides is `{}`
- Overrides a single key in a section
- Overrides multiple keys across sections
- Does not mutate either input
- Handles missing sections in overrides gracefully
- Array values (e.g., `home.steps`) are fully replaced, not merged
- Top-level scalars (`heatmapOpacity`) are directly replaced
4. **`setOverride` / `clearOverride`**
- Setting a value stores it in the delta
- Clearing a key removes it from delta
- Clearing the last key in a section removes the section
- Full data flow executes (CSS vars updated)
5. **`migrateOldKeys`**
- Migrates all 7 keys correctly using exact field mapping
- Handles partial migration (only some keys present)
- Handles invalid JSON in `meshcore-user-theme`
- Removes all legacy keys after migration
- Skips migration if `cs-theme-overrides` already exists
- Returns null when no legacy keys found
- Drops unknown keys from `meshcore-user-theme`
6. **`validateShape`**
- Accepts valid delta objects
- Accepts empty object
- Rejects non-objects (string, array, null)
- Warns on unknown top-level keys (doesn't reject)
- Validates section types (object vs scalar)
- Rejects invalid color values
- Rejects out-of-range opacity values
- Rejects invalid timestamp enum values
### Browser/E2E Tests (Playwright)
1. **Customizer opens and shows current values** — fields reflect effective config.
2. **Changing a color updates CSS variable** — after debounce, `:root` has new value.
3. **Override indicator appears** when value differs from server default.
4. **Per-field reset** removes override, reverts to server default, indicator disappears.
5. **Full reset** clears all overrides, all fields show server defaults.
6. **Export** downloads a JSON file with current delta.
7. **Import** applies overrides from uploaded JSON file.
8. **Migration** — set legacy keys, reload, verify they're migrated and removed.
9. **Preset application** — clicking a preset applies its colors, fields update.
10. **Dark/light mode toggle** — switching mode re-applies correct section's CSS vars.
11. **Browser-local banner** — verify persistent notice is visible in customizer panel.
12. **Auto-save indicator** — verify status text updates during and after changes.
## What's NOT In Scope
- **Undo/redo stack** — could be added as P2. For v1, per-field reset to server default is the only revert mechanism.
- **Cross-tab synchronization** — two tabs editing simultaneously may clobber each other's changes. Acceptable for v1.
- **Server-side timestamp config** (`allowCustomFormat` gate) — remains server-only, not exposed in the customizer delta. The server controls UI availability but does not block stored preferences (see `allowCustomFormat` section above).
- **Admin import endpoint** — no server API for uploading `theme.json` via the UI. Admins edit the file directly. Future work.
- **Map config overrides** (`mapDefaults.center`, `mapDefaults.zoom`) — separate concern, not part of theme. Future work.
- **Geo-filter config** — server-only. Not in scope.
- **Per-page layout preferences** (column widths, sort orders) — separate from theming. Future work.
+85 -16
View File
@@ -136,6 +136,13 @@ function getTimestampCustomFormat() {
function pad2(v) { return String(v).padStart(2, '0'); }
function pad3(v) { return String(v).padStart(3, '0'); }
function mergeUserHomeConfig(siteConfig, userTheme) {
if (!siteConfig || !userTheme || !userTheme.home || typeof userTheme.home !== 'object') return siteConfig;
const serverHome = (siteConfig.home && typeof siteConfig.home === 'object') ? siteConfig.home : {};
siteConfig.home = Object.assign({}, serverHome, userTheme.home);
return siteConfig;
}
function formatIsoLike(d, timezone, includeMs) {
const useUtc = timezone === 'utc';
const year = useUtc ? d.getUTCFullYear() : d.getFullYear();
@@ -787,30 +794,92 @@ window.addEventListener('DOMContentLoaded', () => {
debouncedOnWS(function () { updateNavStats(); });
// --- Theme Customization ---
// Fetch theme config and apply via customizer v2 pipeline
// Fetch theme config and apply branding/colors before first render
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
// Normalize timestamp defaults
cfg = cfg || {};
if (!cfg.timestamps) cfg.timestamps = {};
const tsCfg = cfg.timestamps;
window.SITE_CONFIG = cfg || {};
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
const tsCfg = window.SITE_CONFIG.timestamps;
if (tsCfg.defaultMode !== 'absolute' && tsCfg.defaultMode !== 'ago') tsCfg.defaultMode = 'ago';
if (tsCfg.timezone !== 'utc' && tsCfg.timezone !== 'local') tsCfg.timezone = 'local';
if (tsCfg.formatPreset !== 'iso' && tsCfg.formatPreset !== 'iso-seconds' && tsCfg.formatPreset !== 'locale') tsCfg.formatPreset = 'iso';
if (typeof tsCfg.customFormat !== 'string') tsCfg.customFormat = '';
tsCfg.allowCustomFormat = tsCfg.allowCustomFormat === true;
// Customizer v2: set server defaults and run full pipeline
// (reads localStorage overrides → merges → sets SITE_CONFIG → applies CSS → dispatches theme-changed)
if (window._customizerV2) {
window._customizerV2.init(cfg);
} else {
// Fallback if customize-v2.js didn't load
window.SITE_CONFIG = cfg;
// User's localStorage preferences take priority over server config
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
window._SITE_CONFIG_ORIGINAL_HOME = JSON.parse(JSON.stringify(window.SITE_CONFIG.home || {}));
mergeUserHomeConfig(window.SITE_CONFIG, userTheme);
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
if (!userTheme.theme && !userTheme.themeDark) {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const themeData = dark ? { ...(cfg.theme || {}), ...(cfg.themeDark || {}) } : (cfg.theme || {});
const root = document.documentElement.style;
const varMap = {
accent: '--accent', accentHover: '--accent-hover',
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
selectedBg: '--selected-bg', sectionBg: '--section-bg',
font: '--font', mono: '--mono'
};
for (const [key, cssVar] of Object.entries(varMap)) {
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
}
// Derived vars
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
// Nav gradient
if (themeData.navBg) {
const nav = document.querySelector('.top-nav');
if (nav) nav.style.background = `linear-gradient(135deg, ${themeData.navBg} 0%, ${themeData.navBg2 || themeData.navBg} 50%, ${themeData.navBg} 100%)`;
}
}
}).catch(() => {
window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } };
if (window._customizerV2) window._customizerV2.init(window.SITE_CONFIG);
}).finally(() => {
// Apply node color overrides (skip if user has local preferences)
if (cfg.nodeColors && !userTheme.nodeColors) {
for (const [role, color] of Object.entries(cfg.nodeColors)) {
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = color;
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
}
}
// Apply type color overrides (skip if user has local preferences)
if (cfg.typeColors && !userTheme.typeColors) {
for (const [type, color] of Object.entries(cfg.typeColors)) {
if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = color;
}
if (window.syncBadgeColors) window.syncBadgeColors();
}
// Apply branding (skip if user has local preferences)
if (cfg.branding && !userTheme.branding) {
if (cfg.branding.siteName) {
document.title = cfg.branding.siteName;
const brandText = document.querySelector('.brand-text');
if (brandText) brandText.textContent = cfg.branding.siteName;
}
if (cfg.branding.logoUrl) {
const brandIcon = document.querySelector('.brand-icon');
if (brandIcon) {
const img = document.createElement('img');
img.src = cfg.branding.logoUrl;
img.alt = cfg.branding.siteName || 'Logo';
img.style.height = '24px';
img.style.width = 'auto';
brandIcon.replaceWith(img);
}
}
if (cfg.branding.faviconUrl) {
const favicon = document.querySelector('link[rel="icon"]');
if (favicon) favicon.href = cfg.branding.faviconUrl;
}
}
}).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } }; }).finally(() => {
if (!location.hash || location.hash === '#/') location.hash = '#/home';
else navigate();
});
File diff suppressed because it is too large Load Diff
+5
View File
@@ -100,6 +100,11 @@
selectedBg: '--selected-bg',
font: '--font',
mono: '--mono',
// Hex breakdown section colors
sectionHeaderBg: '--section-header-bg',
sectionTransportBg: '--section-transport-bg',
sectionPathBg: '--section-path-bg',
sectionPayloadBg: '--section-payload-bg',
};
/* ── Theme Presets ── */
+1 -2
View File
@@ -86,14 +86,13 @@
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=__BUST__"></script>
<script src="customize-v2.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="customize.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=__BUST__"></script>
<script src="hop-resolver.js?v=__BUST__"></script>
<script src="hop-display.js?v=__BUST__"></script>
<script src="app.js?v=__BUST__"></script>
<script src="home.js?v=__BUST__"></script>
<script src="packet-filter.js?v=__BUST__"></script>
<script src="packet-helpers.js?v=__BUST__"></script>
<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>
+6 -44
View File
@@ -1,10 +1,6 @@
(function() {
'use strict';
// getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file)
var getParsedPath = window.getParsedPath;
var getParsedDecoded = window.getParsedDecoded;
// Status color helpers (read from CSS variables for theme support)
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
@@ -435,8 +431,8 @@
}
function dbPacketToLive(pkt) {
const raw = getParsedDecoded(pkt);
const hops = getParsedPath(pkt);
const raw = JSON.parse(pkt.decoded_json || '{}');
const hops = JSON.parse(pkt.path_json || '[]');
const typeName = raw.type || pkt.payload_type_name || 'UNKNOWN';
return {
id: pkt.id, hash: pkt.hash,
@@ -485,13 +481,8 @@
}
});
function packetTimestamp(pkt) {
return new Date(pkt.timestamp || pkt.created_at || Date.now()).getTime();
}
if (typeof window !== 'undefined') window._live_packetTimestamp = packetTimestamp;
function bufferPacket(pkt) {
pkt._ts = packetTimestamp(pkt);
pkt._ts = Date.now();
const entry = { ts: pkt._ts, pkt };
VCR.buffer.push(entry);
// Keep buffer capped at ~2000 — adjust playhead to avoid stale indices (#63)
@@ -1449,7 +1440,7 @@
for (const op of group.packets) {
let opHops = [];
if (op.path_json) {
try { opHops = getParsedPath(op); } catch {}
try { opHops = typeof op.path_json === 'string' ? JSON.parse(op.path_json) : op.path_json; } catch {}
} else if (op.decoded?.path?.hops) {
opHops = op.decoded.path.hops;
}
@@ -1595,20 +1586,6 @@
window._liveNodeMarkers = function() { return nodeMarkers; };
window._liveNodeData = function() { return nodeData; };
window._vcrFormatTime = vcrFormatTime;
window._liveDbPacketToLive = dbPacketToLive;
window._liveExpandToBufferEntries = expandToBufferEntries;
window._liveSEG_MAP = SEG_MAP;
window._liveBufferPacket = bufferPacket;
window._liveVCR = function() { return VCR; };
window._liveGetFavoritePubkeys = getFavoritePubkeys;
window._livePacketInvolvesFavorite = packetInvolvesFavorite;
window._liveIsNodeFavorited = isNodeFavorited;
window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml;
window._liveResolveHopPositions = resolveHopPositions;
window._liveVcrSpeedCycle = vcrSpeedCycle;
window._liveVcrPause = vcrPause;
window._liveVcrResumeLive = vcrResumeLive;
window._liveVcrSetMode = vcrSetMode;
async function replayRecent() {
try {
@@ -1733,7 +1710,7 @@
for (const fp of packets) {
let fpHops = [];
if (fp.path_json) {
try { fpHops = getParsedPath(fp); } catch {}
try { fpHops = typeof fp.path_json === 'string' ? JSON.parse(fp.path_json) : fp.path_json; } catch {}
} else if (fp.decoded?.path?.hops) {
fpHops = fp.decoded.path.hops;
}
@@ -1770,7 +1747,7 @@
var qp = qd.payload || {};
var hops;
if (qpkt.path_json) {
try { hops = getParsedPath(qpkt); } catch (e) { hops = qd.path?.hops || []; }
try { hops = typeof qpkt.path_json === 'string' ? JSON.parse(qpkt.path_json) : qpkt.path_json; } catch (e) { hops = qd.path?.hops || []; }
} else {
hops = qd.path?.hops || [];
}
@@ -1957,7 +1934,6 @@
let lastPulse = performance.now();
const pulseStart = lastPulse;
function animatePulse(now) {
if (!animLayer) return;
if (now - pulseStart > 2000) {
try { animLayer.removeLayer(ring); } catch {}
return;
@@ -2202,10 +2178,6 @@
const startTime = performance.now();
function tick(now) {
if (!animLayer || !pathsLayer) {
if (onComplete) onComplete();
return;
}
const elapsed = now - startTime;
const t = Math.min(1, elapsed / DURATION_MS);
const lat = from[0] + (to[0] - from[0]) * t;
@@ -2250,11 +2222,6 @@
// Fade out
const fadeStart = performance.now();
function fadeOut(now) {
if (!animLayer || !pathsLayer) {
charMarkers.length = 0;
if (onComplete) onComplete();
return;
}
const ft = Math.min(1, (now - fadeStart) / 300);
if (ft >= 1) {
for (const cm of charMarkers) try { animLayer.removeLayer(cm.marker); } catch {}
@@ -2302,10 +2269,6 @@
let lastStep = performance.now();
function animateLine(now) {
if (!animLayer || !pathsLayer) {
if (onComplete) onComplete();
return;
}
const elapsed = now - lastStep;
if (elapsed >= 33) {
const ticks = Math.min(Math.floor(elapsed / 33), 4);
@@ -2334,7 +2297,6 @@
let fadeOp = mainOpacity;
let lastFade = performance.now();
function animateFade(now) {
if (!pathsLayer) return;
const fadeElapsed = now - lastFade;
if (fadeElapsed >= 52) {
const fadeTicks = Math.min(Math.floor(fadeElapsed / 52), 4);
+12 -23
View File
@@ -749,30 +749,21 @@
selectedReferenceNode = pubkey;
neighborPubkeys = new Set();
try {
// Use affinity-based neighbor API (server-side disambiguation) instead of
// client-side path walking which fails on hash collisions (#484)
const data = await api('/nodes/' + pubkey + '/neighbors?min_count=3');
for (const n of (data.neighbors || [])) {
if (n.pubkey) neighborPubkeys.add(n.pubkey);
// For ambiguous edges, include all candidates (better to show extra than miss)
if (n.candidates) n.candidates.forEach(function(c) { if (c.pubkey) neighborPubkeys.add(c.pubkey); });
}
// If affinity data is insufficient, fall back to client-side path walking
if (neighborPubkeys.size === 0) {
const pathData = await api('/nodes/' + pubkey + '/paths');
const paths = pathData.paths || [];
for (const p of paths) {
const hops = p.hops || [];
for (var i = 0; i < hops.length; i++) {
if (hops[i].pubkey === pubkey) {
if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
}
const data = await api('/nodes/' + pubkey + '/paths');
const paths = data.paths || [];
for (const p of paths) {
const hops = p.hops || [];
// Find the reference node in the path; direct neighbors are adjacent hops
for (let i = 0; i < hops.length; i++) {
if (hops[i].pubkey === pubkey) {
if (i > 0 && hops[i - 1].pubkey) neighborPubkeys.add(hops[i - 1].pubkey);
if (i < hops.length - 1 && hops[i + 1].pubkey) neighborPubkeys.add(hops[i + 1].pubkey);
}
}
// (Redundant block removed — the main loop above already handles first/last hops)
}
} catch (e) {
console.warn('Failed to fetch neighbors for', pubkey, ':', e);
console.warn('Failed to fetch neighbor paths for', pubkey, '— neighbor filter may be incomplete:', e);
neighborPubkeys = new Set();
}
// Update sidebar UI
@@ -788,9 +779,8 @@
if (cb) cb.checked = true;
renderMarkers();
}
// Expose for popup onclick and testing
// Expose for popup onclick
window._mapSelectRefNode = selectReferenceNode;
window._mapGetNeighborPubkeys = function() { return neighborPubkeys ? Array.from(neighborPubkeys) : []; };
function buildPopup(node) {
const key = node.public_key ? truncate(node.public_key, 16) : '—';
@@ -849,7 +839,6 @@
selectedReferenceNode = null;
neighborPubkeys = null;
delete window._mapSelectRefNode;
delete window._mapGetNeighborPubkeys;
}
function toggleHeatmap(on) {
-43
View File
@@ -1,43 +0,0 @@
/* === CoreScope — packet-helpers.js (shared packet utilities) === */
'use strict';
/**
* Cached JSON.parse helpers for packet data (issue #387).
* Avoids repeated parsing of path_json / decoded_json on the same packet object.
* Results are cached as _parsedPath / _parsedDecoded properties on the packet.
*
* Handles pre-parsed objects (non-string values) gracefully returns them as-is.
*/
window.getParsedPath = function getParsedPath(p) {
if (p._parsedPath !== undefined) return p._parsedPath;
var raw = p.path_json;
if (typeof raw !== 'string') {
p._parsedPath = Array.isArray(raw) ? raw : [];
return p._parsedPath;
}
try { p._parsedPath = JSON.parse(raw) || []; } catch (e) { p._parsedPath = []; }
return p._parsedPath;
};
/**
* Clear cached _parsedPath/_parsedDecoded from a packet object.
* Must be called after spreading a parent packet into an observation/child,
* otherwise the child inherits stale cached values from the parent (issue #504).
*/
window.clearParsedCache = function clearParsedCache(p) {
delete p._parsedPath;
delete p._parsedDecoded;
return p;
};
window.getParsedDecoded = function getParsedDecoded(p) {
if (p._parsedDecoded !== undefined) return p._parsedDecoded;
var raw = p.decoded_json;
if (typeof raw !== 'string') {
p._parsedDecoded = (raw && typeof raw === 'object') ? raw : {};
return p._parsedDecoded;
}
try { p._parsedDecoded = JSON.parse(raw) || {}; } catch (e) { p._parsedDecoded = {}; }
return p._parsedDecoded;
};
+27 -27
View File
@@ -43,10 +43,6 @@
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
// getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file)
const getParsedPath = window.getParsedPath;
const getParsedDecoded = window.getParsedDecoded;
// --- Virtual scroll state ---
const VSCROLL_ROW_HEIGHT = 36; // estimated row height in px
const VSCROLL_BUFFER = 30; // extra rows above/below viewport
@@ -269,7 +265,6 @@
if (obs) {
expandedHashes.add(h);
const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp};
clearParsedCache(obsPacket);
selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id);
} else {
selectPacket(data.packet.id, h, data);
@@ -325,7 +320,7 @@
panel.appendChild(content);
const pkt = data.packet;
try {
const hops = getParsedPath(pkt);
const hops = JSON.parse(pkt.path_json || '[]');
const newHops = hops.filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
} catch {}
@@ -372,7 +367,7 @@
// Resolve any new hops, then update and re-render
const newHops = new Set();
for (const p of filtered) {
try { getParsedPath(p).forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
}
(newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => {
if (groupByHash) {
@@ -502,7 +497,7 @@
await Promise.all(multiObs.map(async (p) => {
try {
const d = await api(`/packets/${p.hash}`);
if (d?.observations) p._children = d.observations.map(o => clearParsedCache({...d.packet, ...o, _isObservation: true}));
if (d?.observations) p._children = d.observations.map(o => ({...d.packet, ...o, _isObservation: true}));
} catch {}
}));
// Flatten: replace grouped packets with individual observations
@@ -521,7 +516,7 @@
// Pre-resolve all path hops to node names
const allHops = new Set();
for (const p of packets) {
try { getParsedPath(p).forEach(h => allHops.add(h)); } catch {}
try { const path = JSON.parse(p.path_json || '[]'); path.forEach(h => allHops.add(h)); } catch {}
}
if (allHops.size) await resolveHops([...allHops]);
@@ -530,7 +525,7 @@
for (const p of packets) {
if (!p.observer_id) continue;
try {
const path = getParsedPath(p);
const path = JSON.parse(p.path_json || '[]');
const ambiguous = path.filter(h => hopNameCache[h]?.ambiguous);
if (ambiguous.length) {
if (!hopsByObserver[p.observer_id]) hopsByObserver[p.observer_id] = new Set();
@@ -838,7 +833,7 @@
try {
const data = await api(`/packets/${p.hash}`);
if (data?.packet && data.observations) {
p._children = data.observations.map(o => clearParsedCache({...data.packet, ...o, _isObservation: true}));
p._children = data.observations.map(o => ({...data.packet, ...o, _isObservation: true}));
p._fetchedData = data;
}
} catch {}
@@ -851,7 +846,7 @@
// Resolve any new hops from updated header paths
const newHops = new Set();
for (const p of packets) {
try { getParsedPath(p).forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
try { JSON.parse(p.path_json || '[]').forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {}
}
if (newHops.size) await resolveHops([...newHops]);
renderTableRows();
@@ -1011,7 +1006,6 @@
if (child) {
const parentData = group._fetchedData;
const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child;
if (parentData) { clearParsedCache(obsPacket); }
selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id);
}
}
@@ -1065,7 +1059,7 @@
<td class="col-observer">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
<td class="col-details">${getDetailPreview(getParsedDecoded(p))}</td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
</tr>`;
if (isExpanded && p._children) {
let visibleChildren = p._children;
@@ -1078,7 +1072,8 @@
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
const childHashBytes = ((parseInt(c.raw_hex?.slice(2, 4), 16) || 0) >> 6) + 1;
const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : '';
const childPath = getParsedPath(c);
let childPath = [];
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
const childPathStr = renderPath(childPath, c.observer_id);
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : ''}</td>
@@ -1090,7 +1085,7 @@
<td class="col-observer">${truncate(obsName(c.observer_id), 16)}</td>
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
<td class="col-rpt"></td>
<td class="col-details">${getDetailPreview(getParsedDecoded(c))}</td>
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json || '{}'); } catch { return {}; } })())}</td>
</tr>`;
}
}
@@ -1099,8 +1094,9 @@
// Build HTML for a single flat (ungrouped) packet row
function buildFlatRowHtml(p) {
const decoded = getParsedDecoded(p);
const pathHops = getParsedPath(p);
let decoded, pathHops = [];
try { decoded = JSON.parse(p.decoded_json || '{}'); } catch {}
try { pathHops = JSON.parse(p.path_json || '[]') || []; } catch {}
const region = p.observer_id ? (observerMap.get(p.observer_id)?.iata || '') : '';
const typeName = payloadTypeName(p.payload_type);
const typeClass = payloadTypeColor(p.payload_type);
@@ -1418,7 +1414,7 @@
// Resolve path hops for detail view
const pkt = data.packet;
try {
const hops = getParsedPath(pkt);
const hops = JSON.parse(pkt.path_json || '[]');
const newHops = hops.filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
} catch {}
@@ -1436,8 +1432,10 @@
const pkt = data.packet;
const breakdown = data.breakdown || {};
const ranges = breakdown.ranges || [];
const decoded = getParsedDecoded(pkt);
const pathHops = getParsedPath(pkt);
let decoded;
try { decoded = JSON.parse(pkt.decoded_json); } catch { decoded = {}; }
let pathHops;
try { pathHops = JSON.parse(pkt.path_json || '[]') || []; } catch { pathHops = []; }
// Resolve sender GPS — from packet directly, or from known node in DB
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
@@ -1613,8 +1611,10 @@
const replayPackets = [];
if (obs.length > 1) {
for (const o of obs) {
const oPath = getParsedPath(o);
const oDec = getParsedDecoded(o);
let oPath;
try { oPath = JSON.parse(o.path_json || '[]'); } catch { oPath = pathHops; }
let oDec;
try { oDec = JSON.parse(o.decoded_json || '{}'); } catch { oDec = decoded; }
replayPackets.push({
id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex,
_ts: new Date(o.timestamp).getTime(),
@@ -1916,7 +1916,7 @@
let obsSortMode = localStorage.getItem('meshcore-obs-sort') || SORT_OBSERVER;
function getPathHopCount(c) {
try { return getParsedPath(c).length; } catch { return 0; }
try { return JSON.parse(c.path_json || '[]').length; } catch { return 0; }
}
function sortGroupChildren(group) {
@@ -1981,7 +1981,7 @@
if (!pkt) return;
const group = packets.find(p => p.hash === hash);
if (group && data.observations) {
group._children = data.observations.map(o => clearParsedCache({...pkt, ...o, _isObservation: true}));
group._children = data.observations.map(o => ({...pkt, ...o, _isObservation: true}));
group._fetchedData = data;
// Sort children based on current sort mode
sortGroupChildren(group);
@@ -1989,7 +1989,7 @@
// Resolve any new hops from children
const childHops = new Set();
for (const c of (group?._children || [])) {
try { getParsedPath(c).forEach(h => childHops.add(h)); } catch {}
try { JSON.parse(c.path_json || '[]').forEach(h => childHops.add(h)); } catch {}
}
const newHops = [...childHops].filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
@@ -2053,7 +2053,7 @@
const data = await api(`/packets/${param}`);
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet ${param} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
const hops = [];
try { hops.push(...getParsedPath(data.packet)); } catch {}
try { const ph = JSON.parse(data.packet.path_json || '[]'); hops.push(...ph); } catch {}
const newHops = hops.filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
const container = document.createElement('div');
+16 -4
View File
@@ -30,6 +30,10 @@
--content-bg: var(--surface-0);
--card-bg: var(--surface-1);
--hover-bg: rgba(0,0,0, 0.04);
--section-header-bg: rgba(243,139,168,0.18);
--section-transport-bg: rgba(137,180,250,0.18);
--section-path-bg: rgba(166,227,161,0.18);
--section-payload-bg: rgba(249,226,175,0.18);
}
/* DARK THEME VARIABLES KEEP BOTH BLOCKS IN SYNC
@@ -56,6 +60,10 @@
--selected-bg: #1e3a5f;
--hover-bg: rgba(255,255,255, 0.06);
--section-bg: #1e1e34;
--section-header-bg: rgba(243,139,168,0.15);
--section-transport-bg: rgba(137,180,250,0.15);
--section-path-bg: rgba(166,227,161,0.15);
--section-payload-bg: rgba(249,226,175,0.15);
}
}
/* ⚠️ DARK THEME VARIABLES — KEEP IN SYNC with @media block above */
@@ -79,6 +87,10 @@
--selected-bg: #1e3a5f;
--hover-bg: rgba(255,255,255, 0.06);
--section-bg: #1e1e34;
--section-header-bg: rgba(243,139,168,0.15);
--section-transport-bg: rgba(137,180,250,0.15);
--section-path-bg: rgba(166,227,161,0.15);
--section-payload-bg: rgba(249,226,175,0.15);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -375,10 +387,10 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
background: var(--section-bg, #eef2ff); font-weight: 700; font-size: 11px;
text-transform: uppercase; letter-spacing: .5px; color: var(--accent);
}
.field-table .section-header td { background: rgba(243,139,168,0.18); }
.field-table .section-transport td { background: rgba(137,180,250,0.18); }
.field-table .section-path td { background: rgba(166,227,161,0.18); }
.field-table .section-payload td { background: rgba(249,226,175,0.18); }
.field-table .section-header td { background: var(--section-header-bg); }
.field-table .section-transport td { background: var(--section-transport-bg); }
.field-table .section-path td { background: var(--section-path-bg); }
.field-table .section-payload td { background: var(--section-payload-bg); }
/* === Path display === */
.path-hops {
-408
View File
@@ -1,408 +0,0 @@
/* Unit tests for customizer v2 core functions */
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
function makeSandbox() {
const storage = {};
const localStorage = {
_data: storage,
getItem(k) { return k in storage ? storage[k] : null; },
setItem(k, v) { storage[k] = String(v); },
removeItem(k) { delete storage[k]; },
clear() { for (const k in storage) delete storage[k]; }
};
const ctx = {
window: {
addEventListener: () => {},
dispatchEvent: () => {},
SITE_CONFIG: {},
_SITE_CONFIG_ORIGINAL_HOME: null,
},
document: {
readyState: 'loading',
createElement: (tag) => ({
id: '', textContent: '', innerHTML: '', className: '',
setAttribute: () => {}, appendChild: () => {},
style: {}, addEventListener: () => {},
querySelectorAll: () => [], querySelector: () => null,
}),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
documentElement: {
style: { setProperty: () => {}, removeProperty: () => {}, getPropertyValue: () => '' },
dataset: { theme: 'dark' },
getAttribute: () => 'dark',
},
},
console,
localStorage,
setTimeout: (fn) => fn(),
clearTimeout: () => {},
Date, Math, Array, Object, JSON, String, Number, Boolean,
parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
MutationObserver: class { observe() {} },
HashChangeEvent: class {},
CustomEvent: class CustomEvent { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } },
getComputedStyle: () => ({ getPropertyValue: () => '' }),
};
ctx.window.localStorage = localStorage;
ctx.self = ctx.window;
return ctx;
}
function loadCustomizer() {
const ctx = makeSandbox();
const code = fs.readFileSync('public/customize-v2.js', 'utf8');
vm.createContext(ctx);
vm.runInContext(code, ctx, { filename: 'customize-v2.js' });
return { ctx, api: ctx.window._customizerV2, ls: ctx.localStorage };
}
console.log('\n📋 Customizer V2 — Core Function Tests\n');
// ── readOverrides ──
console.log('readOverrides:');
test('returns {} when key is absent', () => {
const { api } = loadCustomizer();
const result = api.readOverrides();
assert.strictEqual(JSON.stringify(result), '{}');
});
test('returns {} when key contains invalid JSON', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', 'not json{{{');
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
});
test('returns {} when key contains a non-object (string)', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '"just a string"');
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
});
test('returns {} when key contains an array', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '[1,2,3]');
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
});
test('returns {} when key contains a number', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '42');
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
});
test('returns parsed object when valid', () => {
const { api, ls } = loadCustomizer();
const data = { theme: { accent: '#ff0000' } };
ls.setItem('cs-theme-overrides', JSON.stringify(data));
assert.deepStrictEqual(api.readOverrides(), data);
});
// ── writeOverrides ──
console.log('\nwriteOverrides:');
test('writes serialized JSON to localStorage', () => {
const { api, ls } = loadCustomizer();
const data = { theme: { accent: '#ff0000' } };
api.writeOverrides(data);
assert.deepStrictEqual(JSON.parse(ls.getItem('cs-theme-overrides')), data);
});
test('removes key when delta is empty {}', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '{"theme":{}}');
api.writeOverrides({});
assert.strictEqual(ls.getItem('cs-theme-overrides'), null);
});
test('round-trips correctly (write → read = identical)', () => {
const { api } = loadCustomizer();
const data = { theme: { accent: '#abc', text: '#def' }, nodeColors: { repeater: '#111' } };
api.writeOverrides(data);
assert.deepStrictEqual(api.readOverrides(), data);
});
test('strips invalid color values silently', () => {
const { api, ls } = loadCustomizer();
api.writeOverrides({ theme: { accent: 'not-a-color' } });
// Invalid color is stripped by _validateDelta; remaining empty object is stored as '{}'
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
assert.strictEqual(stored.theme, undefined);
});
test('strips out-of-range opacity', () => {
const { api, ls } = loadCustomizer();
api.writeOverrides({ heatmapOpacity: 1.5 });
const stored1 = JSON.parse(ls.getItem('cs-theme-overrides'));
assert.strictEqual(stored1.heatmapOpacity, undefined);
api.writeOverrides({ heatmapOpacity: -0.1 });
const stored2 = JSON.parse(ls.getItem('cs-theme-overrides'));
assert.strictEqual(stored2.heatmapOpacity, undefined);
});
test('accepts valid opacity', () => {
const { api, ls } = loadCustomizer();
api.writeOverrides({ heatmapOpacity: 0.5 });
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
assert.strictEqual(stored.heatmapOpacity, 0.5);
});
// ── computeEffective ──
console.log('\ncomputeEffective:');
test('returns server defaults when overrides is {}', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa', text: '#bbb' }, nodeColors: { repeater: '#ccc' } };
const result = api.computeEffective(defaults, {});
assert.deepStrictEqual(result, defaults);
});
test('overrides a single key in a section', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa', text: '#bbb' } };
const result = api.computeEffective(defaults, { theme: { accent: '#ff0000' } });
assert.strictEqual(result.theme.accent, '#ff0000');
assert.strictEqual(result.theme.text, '#bbb');
});
test('overrides multiple keys across sections', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
const result = api.computeEffective(defaults, { theme: { accent: '#111' }, nodeColors: { repeater: '#222' } });
assert.strictEqual(result.theme.accent, '#111');
assert.strictEqual(result.nodeColors.repeater, '#222');
});
test('does not mutate either input', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa' } };
const overrides = { theme: { accent: '#bbb' } };
const defCopy = JSON.stringify(defaults);
const ovrCopy = JSON.stringify(overrides);
api.computeEffective(defaults, overrides);
assert.strictEqual(JSON.stringify(defaults), defCopy);
assert.strictEqual(JSON.stringify(overrides), ovrCopy);
});
test('handles missing sections in overrides gracefully', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
const result = api.computeEffective(defaults, { theme: { accent: '#ccc' } });
assert.strictEqual(result.nodeColors.repeater, '#bbb');
});
test('array values in home are fully replaced, not merged', () => {
const { api } = loadCustomizer();
const defaults = { home: { steps: [{ emoji: '1', title: 'a', description: 'b' }], heroTitle: 'X' } };
const overrides = { home: { steps: [{ emoji: '2', title: 'c', description: 'd' }, { emoji: '3', title: 'e', description: 'f' }] } };
const result = api.computeEffective(defaults, overrides);
assert.strictEqual(result.home.steps.length, 2);
assert.strictEqual(result.home.steps[0].emoji, '2');
assert.strictEqual(result.home.heroTitle, 'X'); // untouched
});
test('top-level scalars are directly replaced', () => {
const { api } = loadCustomizer();
const defaults = { heatmapOpacity: 0.5 };
const result = api.computeEffective(defaults, { heatmapOpacity: 0.8 });
assert.strictEqual(result.heatmapOpacity, 0.8);
});
// ── validateShape ──
console.log('\nvalidateShape:');
test('accepts valid delta objects', () => {
const { api } = loadCustomizer();
const result = api.validateShape({ theme: { accent: '#fff' }, heatmapOpacity: 0.5 });
assert.strictEqual(result.valid, true);
});
test('accepts empty object', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape({}).valid, true);
});
test('rejects non-objects (string)', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape('hello').valid, false);
});
test('rejects non-objects (array)', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape([1, 2]).valid, false);
});
test('rejects non-objects (null)', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape(null).valid, false);
});
test('warns on unknown top-level keys', () => {
const { api } = loadCustomizer();
const result = api.validateShape({ unknownKey: {} });
// Unknown keys produce a console.warn but validateShape still returns valid
assert.strictEqual(result.valid, true);
assert.strictEqual(result.errors.length, 0);
});
test('validates section types (rejects non-object section)', () => {
const { api } = loadCustomizer();
const result = api.validateShape({ theme: 'not an object' });
assert.strictEqual(result.valid, false);
});
test('accepts valid rgb() color values in theme', () => {
const { api } = loadCustomizer();
const result = api.validateShape({ theme: { accent: 'rgb(1,2,3)' } });
assert.strictEqual(result.valid, true);
});
test('rejects out-of-range opacity values', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape({ heatmapOpacity: 2.0 }).valid, false);
assert.strictEqual(api.validateShape({ liveHeatmapOpacity: -1 }).valid, false);
});
// ── migrateOldKeys ──
console.log('\nmigrateOldKeys:');
test('migrates all 7 keys correctly', () => {
const { api, ls } = loadCustomizer();
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, branding: { siteName: 'Test' } }));
ls.setItem('meshcore-timestamp-mode', 'absolute');
ls.setItem('meshcore-timestamp-timezone', 'utc');
ls.setItem('meshcore-timestamp-format', 'iso-seconds');
ls.setItem('meshcore-timestamp-custom-format', 'YYYY-MM-DD');
ls.setItem('meshcore-heatmap-opacity', '0.7');
ls.setItem('meshcore-live-heatmap-opacity', '0.3');
const result = api.migrateOldKeys();
assert.strictEqual(result.theme.accent, '#f00');
assert.strictEqual(result.branding.siteName, 'Test');
assert.strictEqual(result.timestamps.defaultMode, 'absolute');
assert.strictEqual(result.timestamps.timezone, 'utc');
assert.strictEqual(result.heatmapOpacity, 0.7);
assert.strictEqual(result.liveHeatmapOpacity, 0.3);
// Legacy keys removed
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
// New key written
assert.notStrictEqual(ls.getItem('cs-theme-overrides'), null);
});
test('handles partial migration (only some keys)', () => {
const { api, ls } = loadCustomizer();
ls.setItem('meshcore-timestamp-mode', 'ago');
const result = api.migrateOldKeys();
assert.strictEqual(result.timestamps.defaultMode, 'ago');
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
});
test('handles invalid JSON in meshcore-user-theme', () => {
const { api, ls } = loadCustomizer();
ls.setItem('meshcore-user-theme', '{bad json');
const result = api.migrateOldKeys();
// Should not crash, returns delta (possibly empty besides what was valid)
assert(result !== null);
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
});
test('skips migration if cs-theme-overrides already exists', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '{"theme":{}}');
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' } }));
const result = api.migrateOldKeys();
assert.strictEqual(result, null);
// Legacy key NOT removed (migration skipped entirely)
assert.notStrictEqual(ls.getItem('meshcore-user-theme'), null);
});
test('returns null when no legacy keys found', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.migrateOldKeys(), null);
});
test('drops unknown keys from meshcore-user-theme', () => {
const { api, ls } = loadCustomizer();
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, unknownStuff: 'hi' }));
const result = api.migrateOldKeys();
assert.strictEqual(result.theme.accent, '#f00');
assert.strictEqual(result.unknownStuff, undefined);
});
// ── THEME_CSS_MAP completeness ──
console.log('\nTHEME_CSS_MAP:');
test('includes surface3 mapping', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.THEME_CSS_MAP.surface3, '--surface-3');
});
test('includes sectionBg mapping', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.THEME_CSS_MAP.sectionBg, '--section-bg');
});
test('matches all keys from old app.js varMap', () => {
const { api } = loadCustomizer();
const expectedKeys = [
'accent', 'accentHover', 'navBg', 'navBg2', 'navText', 'navTextMuted',
'background', 'text', 'textMuted', 'border',
'statusGreen', 'statusYellow', 'statusRed',
'surface1', 'surface2', 'surface3',
'cardBg', 'contentBg', 'inputBg',
'rowStripe', 'rowHover', 'detailBg',
'selectedBg', 'sectionBg',
'font', 'mono'
];
for (const key of expectedKeys) {
assert(key in api.THEME_CSS_MAP, `Missing key: ${key}`);
}
});
// ── _isOverridden tests ──
console.log('\n_isOverridden (value comparison):');
test('returns false when no overrides exist', () => {
const { api } = loadCustomizer();
api.init({ theme: { accent: '#aaa' } });
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
});
test('returns false when override matches server default', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#aaa' } }));
api.init({ theme: { accent: '#aaa' } });
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
});
test('returns true when override differs from server default', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
api.init({ theme: { accent: '#aaa' } });
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
});
test('returns false for key not in overrides', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
api.init({ theme: { accent: '#aaa', border: '#ccc' } });
assert.strictEqual(api.isOverridden('theme', 'border'), false);
});
test('returns true when server has no default for overridden key', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
api.init({});
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
});
// ── Summary ──
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
+50 -396
View File
@@ -85,7 +85,7 @@ async function run() {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
await page.evaluate(() => {
localStorage.removeItem('cs-theme-overrides');
localStorage.removeItem('meshcore-user-theme');
window.SITE_CONFIG = window.SITE_CONFIG || {};
window.SITE_CONFIG.home = {
heroTitle: 'Server Hero (E2E)',
@@ -122,18 +122,18 @@ async function run() {
const homeTab = page.locator('.cust-tab[data-tab="home"]');
await homeTab.waitFor({ state: 'visible', timeout: 10000 });
await homeTab.click();
const heroInput = page.locator('[data-cv2-field="home.heroTitle"]');
const heroInput = page.locator('#cust-heroTitle');
if (await heroInput.count() === 0) {
console.log(' ⏭️ home.heroTitle input not found — TODO: requires running server');
console.log(' ⏭️ #cust-heroTitle not found — TODO: requires running server');
return;
}
await heroInput.waitFor({ state: 'visible', timeout: 10000 });
await heroInput.fill(editedHero);
await page.waitForTimeout(700); // debounce is 300ms, allow margin
await page.waitForTimeout(700); // autoSave debounce is 500ms
await page.reload({ waitUntil: 'domcontentloaded' });
const persistedHero = await page.evaluate(() => {
try {
const saved = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
const saved = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
return saved && saved.home ? saved.home.heroTitle : '';
} catch {
return '';
@@ -543,6 +543,51 @@ async function run() {
assert(hasChannelHash, 'Undecrypted GRP_TXT detail should show "Channel Hash"');
});
// --- Group: Hex breakdown colors (#329) ---
await test('Packet detail hex dump shows color-coded sections', async () => {
// Find any packet with raw_hex via API
const hash = await page.evaluate(async () => {
try {
const res = await fetch('/api/packets?limit=100');
const data = await res.json();
for (const p of (data.packets || [])) {
if (p.raw_hex && p.raw_hex.length > 10) return p.hash;
}
} catch {}
return null;
});
if (!hash) { console.log(' ⏭️ Skipped (no packets with raw_hex found)'); return; }
await page.goto(`${BASE}/#/packets/${hash}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const panel = document.getElementById('pktRight');
if (!panel || panel.classList.contains('empty')) return false;
return panel.textContent.length > 50 && !panel.textContent.includes('Loading');
}, { timeout: 8000 });
// Verify hex dump has colored spans (not monochrome)
const coloredSpans = await page.$$eval('.hex-dump span[class*="hex-"]', els => els.length);
assert(coloredSpans > 0, 'Hex dump should have color-coded spans with hex-* classes');
});
await test('Packet detail shows hex legend with color swatches', async () => {
// Re-use the packet detail page from previous test
const legendItems = await page.$$eval('.hex-legend .legend-item, .hex-legend span', els => els.length);
assert(legendItems > 0, 'Hex legend should show color swatch items');
});
await test('Field breakdown table has tinted section rows', async () => {
// Check that section-row elements have per-section color classes
const sectionClasses = await page.$$eval('.field-table .section-row', rows =>
rows.map(r => r.className)
);
assert(sectionClasses.length > 0, 'Field table should have section rows');
const hasTinted = sectionClasses.some(c =>
c.includes('section-header') || c.includes('section-transport') ||
c.includes('section-path') || c.includes('section-payload')
);
assert(hasTinted, 'Section rows should have tinted color classes (section-header, section-path, etc.)');
});
// --- Group: Analytics page (test 8 + sub-tabs) ---
// Test 8: Analytics page loads with overview
@@ -1015,397 +1060,6 @@ async function run() {
assert(hexDump, 'Hex dump should be visible after selecting a packet');
});
// --- Group: Customizer v2 E2E tests ---
await test('Customizer v2: setOverride persists and applies CSS', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
// Clear any existing overrides
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
// Set an override via the API
const result = await page.evaluate(() => {
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
// Wait for debounce
return new Promise(resolve => setTimeout(() => {
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
const cssVal = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
resolve({ stored, cssVal });
}, 500));
});
assert(!result.error, result.error || '');
assert(result.stored.theme && result.stored.theme.accent === '#ff0000',
'Override not persisted to localStorage');
assert(result.cssVal === '#ff0000',
`CSS variable --accent expected #ff0000 but got "${result.cssVal}"`);
// Cleanup
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
});
await test('Customizer v2: clearOverride resets to server default', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const result = await page.evaluate(() => {
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
// Get the server default accent
window._customizerV2.setOverride('theme', 'accent', '#ff0000');
return new Promise(resolve => setTimeout(() => {
window._customizerV2.clearOverride('theme', 'accent');
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
const hasAccent = stored.theme && stored.theme.hasOwnProperty('accent');
resolve({ hasAccent });
}, 500));
});
assert(!result.error, result.error || '');
assert(!result.hasAccent, 'accent should be removed from overrides after clearOverride');
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
});
await test('Customizer v2: full reset clears all overrides', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const result = await page.evaluate(() => {
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' }, nodeColors: { repeater: '#00ff00' } }));
// Simulate full reset
localStorage.removeItem('cs-theme-overrides');
const stored = localStorage.getItem('cs-theme-overrides');
return { stored };
});
assert(!result.error, result.error || '');
assert(result.stored === null, 'cs-theme-overrides should be null after full reset');
});
await test('Customizer v2: export produces valid JSON', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const result = await page.evaluate(() => {
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
// Set some overrides
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#123456' } }));
const delta = window._customizerV2.readOverrides();
const json = JSON.stringify(delta, null, 2);
try { JSON.parse(json); return { valid: true, hasAccent: delta.theme && delta.theme.accent === '#123456' }; }
catch { return { valid: false }; }
});
assert(!result.error, result.error || '');
assert(result.valid, 'Exported JSON must be valid');
assert(result.hasAccent, 'Exported JSON must contain the stored override');
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
});
await test('Customizer v2: import applies overrides', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const result = await page.evaluate(() => {
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
localStorage.removeItem('cs-theme-overrides');
const importData = { theme: { accent: '#abcdef' }, nodeColors: { repeater: '#112233' } };
const validation = window._customizerV2.validateShape(importData);
if (!validation.valid) return { error: 'Validation failed: ' + validation.errors.join(', ') };
window._customizerV2.writeOverrides(importData);
const stored = window._customizerV2.readOverrides();
return { accent: stored.theme && stored.theme.accent, repeater: stored.nodeColors && stored.nodeColors.repeater };
});
assert(!result.error, result.error || '');
assert(result.accent === '#abcdef', 'Imported accent should be #abcdef');
assert(result.repeater === '#112233', 'Imported repeater should be #112233');
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
});
await test('Customizer v2: migration from legacy keys', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const result = await page.evaluate(() => {
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
// Clear new key so migration can run
localStorage.removeItem('cs-theme-overrides');
// Set legacy keys
localStorage.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#aabb01' }, branding: { siteName: 'LegacyName' } }));
localStorage.setItem('meshcore-timestamp-mode', 'absolute');
localStorage.setItem('meshcore-heatmap-opacity', '0.5');
// Run migration
const migrated = window._customizerV2.migrateOldKeys();
const stored = window._customizerV2.readOverrides();
const legacyGone = localStorage.getItem('meshcore-user-theme') === null &&
localStorage.getItem('meshcore-timestamp-mode') === null &&
localStorage.getItem('meshcore-heatmap-opacity') === null;
return {
migrated: !!migrated,
accent: stored.theme && stored.theme.accent,
siteName: stored.branding && stored.branding.siteName,
tsMode: stored.timestamps && stored.timestamps.defaultMode,
opacity: stored.heatmapOpacity,
legacyGone
};
});
assert(!result.error, result.error || '');
assert(result.migrated, 'migrateOldKeys should return non-null');
assert(result.accent === '#aabb01', 'Theme accent should be migrated');
assert(result.siteName === 'LegacyName', 'Branding should be migrated');
assert(result.tsMode === 'absolute', 'Timestamp mode should be migrated');
assert(result.opacity === 0.5, 'Heatmap opacity should be migrated');
assert(result.legacyGone, 'Legacy keys should be removed after migration');
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
});
await test('Customizer v2: browser-local banner visible', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
// Open customizer
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
const btn = await page.$(toggleSel);
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
await btn.click();
await page.waitForSelector('.cv2-local-banner', { timeout: 5000 });
const bannerText = await page.$eval('.cv2-local-banner', el => el.textContent);
assert(bannerText.includes('browser only'), `Banner should mention "browser only" but got "${bannerText}"`);
});
await test('Customizer v2: auto-save status indicator', async () => {
// Panel should already be open from previous test
const statusEl = await page.$('#cv2-save-status');
if (!statusEl) { console.log(' ⏭️ Save status element not found'); return; }
const statusText = await page.$eval('#cv2-save-status', el => el.textContent);
assert(statusText.includes('saved') || statusText.includes('Saving'),
`Status should show save state but got "${statusText}"`);
});
await test('Customizer v2: override indicator appears and disappears', async () => {
// Set override BEFORE page load so _renderTheme sees it during init
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
// Force light mode so theme tab renders 'theme' section (not 'themeDark')
localStorage.setItem('meshcore-theme', 'light');
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
});
// Reload so customizer v2 initializes with the override in place
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
// Ensure light mode is active (CI headless may default to dark)
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
const result = await page.evaluate(() => {
if (!window._customizerV2) return { error: 'customizerV2 not loaded' };
return { ok: true };
});
assert(!result.error, result.error || '');
// Open customizer and check for override dot
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
const btn = await page.$(toggleSel);
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
await btn.click();
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
// Click theme tab
const themeTab = await page.$('.cust-tab[data-tab="theme"]');
if (themeTab) await themeTab.click();
await page.waitForTimeout(200);
// Check for override dot
const dots = await page.$$('.cv2-override-dot');
assert(dots.length > 0, 'Override dot should be visible when overrides exist');
// Clear overrides and reload to verify dots disappear
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const btn2 = await page.$(toggleSel);
if (btn2) await btn2.click();
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
const themeTab2 = await page.$('.cust-tab[data-tab="theme"]');
if (themeTab2) await themeTab2.click();
await page.waitForTimeout(200);
const dotsAfter = await page.$$('.cv2-override-dot');
assert(dotsAfter.length === 0, 'Override dots should disappear after clearing overrides');
});
await test('Customizer v2: presets apply through standard pipeline', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
const toggleSel = '#customizeToggle, button[title*="ustom" i], [class*="customize"]';
const btn = await page.$(toggleSel);
if (!btn) { console.log(' ⏭️ Customizer toggle not found'); return; }
await btn.click();
await page.waitForSelector('.cust-overlay', { timeout: 5000 });
// Click theme tab
const themeTab = await page.$('.cust-tab[data-tab="theme"]');
if (themeTab) await themeTab.click();
await page.waitForTimeout(200);
// Click ocean preset
const oceanBtn = await page.$('.cust-preset-btn[data-preset="ocean"]');
if (!oceanBtn) { console.log(' ⏭️ Ocean preset button not found'); return; }
await oceanBtn.click();
await page.waitForTimeout(300);
const result = await page.evaluate(() => {
const stored = JSON.parse(localStorage.getItem('cs-theme-overrides') || '{}');
const cssAccent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim();
return { hasTheme: !!stored.theme, cssAccent };
});
assert(result.hasTheme, 'Preset should write theme to localStorage');
assert(result.cssAccent.length > 0, 'CSS accent should be set after preset');
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
});
await test('Customizer v2: page load applies overrides from localStorage', async () => {
// Set overrides BEFORE navigating
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
localStorage.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ee1122' } }));
});
// Reload to trigger init with overrides
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
await page.waitForTimeout(500); // allow pipeline to run
const cssAccent = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim()
);
assert(cssAccent === '#ee1122', `Page load should apply override accent #ee1122 but got "${cssAccent}"`);
await page.evaluate(() => localStorage.removeItem('cs-theme-overrides'));
});
// --- Group: Show Neighbors (#484 fix) ---
await test('Show Neighbors populates neighborPubkeys from affinity API', async () => {
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
const neighborPubkey1 = '1111111111111111111111111111111111111111111111111111111111111111';
const neighborPubkey2 = '2222222222222222222222222222222222222222222222222222222222222222';
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
node: testPubkey,
neighbors: [
{ pubkey: neighborPubkey1, prefix: '11', name: 'Neighbor-1', role: 'repeater', count: 50, score: 0.9, ambiguous: false },
{ pubkey: neighborPubkey2, prefix: '22', name: 'Neighbor-2', role: 'companion', count: 20, score: 0.7, ambiguous: false }
],
total_observations: 70
})
});
});
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
const result = await page.evaluate(async (args) => {
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no _mapSelectRefNode' };
await window._mapSelectRefNode(args.pk, 'TestNode');
return { neighbors: window._mapGetNeighborPubkeys() };
}, { pk: testPubkey });
assert(!result.error, result.error || '');
assert(result.neighbors.includes(neighborPubkey1), 'Should contain neighbor1');
assert(result.neighbors.includes(neighborPubkey2), 'Should contain neighbor2');
assert(result.neighbors.length === 2, `Expected 2 neighbors, got ${result.neighbors.length}`);
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
});
await test('Show Neighbors resolves correct node on hash collision via affinity API', async () => {
const nodeA = 'c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4';
const nodeB = 'c0f1a2b3000000000000000000000000000000000000000000000000000000ff';
const neighborR1 = 'r1aaaaaa000000000000000000000000000000000000000000000000000000aa';
const neighborR2 = 'r2bbbbbb000000000000000000000000000000000000000000000000000000bb';
const neighborR4 = 'r4dddddd000000000000000000000000000000000000000000000000000000dd';
await page.route(`**/api/nodes/${nodeA}/neighbors*`, route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
node: nodeA,
neighbors: [
{ pubkey: neighborR1, prefix: 'R1', name: 'Repeater-R1', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
{ pubkey: neighborR2, prefix: 'R2', name: 'Repeater-R2', role: 'repeater', count: 80, score: 0.85, ambiguous: false }
],
total_observations: 180
})
});
});
await page.route(`**/api/nodes/${nodeB}/neighbors*`, route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
node: nodeB,
neighbors: [
{ pubkey: neighborR4, prefix: 'R4', name: 'Repeater-R4', role: 'repeater', count: 60, score: 0.75, ambiguous: false }
],
total_observations: 60
})
});
});
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
// Select Node A — should get R1, R2 but NOT R4
const resultA = await page.evaluate(async (pk) => {
await window._mapSelectRefNode(pk, 'NodeA');
return window._mapGetNeighborPubkeys();
}, nodeA);
assert(resultA.includes(neighborR1), 'Node A should have R1');
assert(resultA.includes(neighborR2), 'Node A should have R2');
assert(!resultA.includes(neighborR4), 'Node A should NOT have R4');
// Select Node B — should get R4 but NOT R1, R2
const resultB = await page.evaluate(async (pk) => {
await window._mapSelectRefNode(pk, 'NodeB');
return window._mapGetNeighborPubkeys();
}, nodeB);
assert(resultB.includes(neighborR4), 'Node B should have R4');
assert(!resultB.includes(neighborR1), 'Node B should NOT have R1');
assert(!resultB.includes(neighborR2), 'Node B should NOT have R2');
await page.unroute(`**/api/nodes/${nodeA}/neighbors*`);
await page.unroute(`**/api/nodes/${nodeB}/neighbors*`);
});
await test('Show Neighbors falls back to path walking when affinity API returns empty', async () => {
const testPubkey = 'fallbacktest0000000000000000000000000000000000000000000000000000';
const hopBefore = 'aaaa000000000000000000000000000000000000000000000000000000000000';
const hopAfter = 'bbbb000000000000000000000000000000000000000000000000000000000000';
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ node: testPubkey, neighbors: [], total_observations: 0 })
});
});
await page.route(`**/api/nodes/${testPubkey}/paths*`, route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
paths: [{
hops: [
{ pubkey: hopBefore, name: 'HopBefore' },
{ pubkey: testPubkey, name: 'Self' },
{ pubkey: hopAfter, name: 'HopAfter' }
]
}]
})
});
});
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
const result = await page.evaluate(async (pk) => {
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no-function' };
await window._mapSelectRefNode(pk, 'FallbackNode');
return { neighbors: window._mapGetNeighborPubkeys() };
}, testPubkey);
assert(!result.error, result.error || '');
assert(result.neighbors.includes(hopBefore), 'Fallback should find hopBefore');
assert(result.neighbors.includes(hopAfter), 'Fallback should find hopAfter');
assert(result.neighbors.length === 2, `Expected 2 fallback neighbors, got ${result.neighbors.length}`);
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
await page.unroute(`**/api/nodes/${testPubkey}/paths*`);
});
// Extract frontend coverage if instrumented server is running
try {
const coverage = await page.evaluate(() => window.__coverage__);
+238 -975
View File
File diff suppressed because it is too large Load Diff
+1 -51
View File
@@ -75,54 +75,4 @@ test('no setInterval remains in animation hot path', () => {
});
console.log(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
/* === Null-guard coverage for rAF callbacks === */
const src2 = fs.readFileSync('public/live.js', 'utf8');
let p2 = 0, f2 = 0;
function test2(name, fn) {
try { fn(); p2++; console.log(`${name}`); }
catch (e) { f2++; console.log(`${name}: ${e.message}`); }
}
console.log('\n=== Null guards on rAF animation callbacks ===');
test2('animatePath tick() has null guard', () => {
// tick is inside animatePath, after "function tick(now)"
const tickStart = src2.indexOf('function tick(now)');
const tickBody = src2.substring(tickStart, tickStart + 200);
assert.ok(tickBody.includes('!animLayer || !pathsLayer'), 'tick() missing animLayer/pathsLayer null guard');
});
test2('animatePath fadeOut() has null guard', () => {
const fadeOutStart = src2.indexOf('function fadeOut(now)');
const fadeOutBody = src2.substring(fadeOutStart, fadeOutStart + 200);
assert.ok(fadeOutBody.includes('!animLayer || !pathsLayer'), 'fadeOut() missing animLayer/pathsLayer null guard');
});
test2('drawAnimatedLine animateLine() has null guard', () => {
const lineStart = src2.indexOf('function animateLine(now)');
const lineBody = src2.substring(lineStart, lineStart + 200);
assert.ok(lineBody.includes('!animLayer || !pathsLayer'), 'animateLine() missing animLayer/pathsLayer null guard');
});
test2('drawAnimatedLine animateFade() has null guard', () => {
const fadeStart = src2.indexOf('function animateFade(now)');
const fadeBody = src2.substring(fadeStart, fadeStart + 200);
assert.ok(fadeBody.includes('!pathsLayer'), 'animateFade() missing pathsLayer null guard');
});
test2('pulseNode animatePulse() has null guard', () => {
const pulseStart = src2.indexOf('function animatePulse(now)');
const pulseBody = src2.substring(pulseStart, pulseStart + 200);
assert.ok(pulseBody.includes('!animLayer'), 'animatePulse() missing animLayer null guard');
});
test2('ghostPulse has null guard', () => {
const ghostStart = src2.indexOf('function ghostPulse(now)');
const ghostBody = src2.substring(ghostStart, ghostStart + 200);
assert.ok(ghostBody.includes('!animLayer'), 'ghostPulse() missing animLayer null guard');
});
console.log(`\n${p2} passed, ${f2} failed\n`);
if (f2 > 0) process.exit(1);
process.exit(failed > 0 ? 1 : 0);
-853
View File
@@ -1,853 +0,0 @@
/* Unit tests for live.js functions (tested via VM sandbox)
* Part of #344 live.js coverage
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
const pendingTests = [];
function test(name, fn) {
try {
const out = fn();
if (out && typeof out.then === 'function') {
pendingTests.push(
out.then(() => { passed++; console.log(`${name}`); })
.catch((e) => { failed++; console.log(`${name}: ${e.message}`); })
);
return;
}
passed++; console.log(`${name}`);
} catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// --- Browser-like sandbox ---
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
document: {
readyState: 'complete',
createElement: (tag) => ({
tagName: tag, id: '', textContent: '', innerHTML: '', style: {},
classList: { add() {}, remove() {}, contains() { return false; } },
setAttribute() {}, getAttribute() { return null; },
addEventListener() {}, focus() {},
getContext: () => ({
clearRect() {}, fillRect() {}, beginPath() {}, arc() {}, fill() {},
scale() {}, fillStyle: '', font: '', fillText() {},
}),
offsetWidth: 200, offsetHeight: 40, width: 0, height: 0,
}),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
createElementNS: () => ({
tagName: 'svg', id: '', textContent: '', innerHTML: '', style: {},
setAttribute() {}, getAttribute() { return null; },
}),
documentElement: { getAttribute: () => null, setAttribute: () => {} },
body: { appendChild: () => {}, removeChild: () => {}, contains: () => false },
hidden: false,
},
console,
Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
Error, TypeError, Map, Set, Promise, URLSearchParams,
parseInt, parseFloat, isNaN, isFinite,
encodeURIComponent, decodeURIComponent,
setTimeout: () => 0, clearTimeout: () => {},
setInterval: () => 0, clearInterval: () => {},
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
requestAnimationFrame: (cb) => setTimeout(cb, 0),
cancelAnimationFrame: () => {},
localStorage: (() => {
const store = {};
return {
getItem: k => store[k] !== undefined ? store[k] : null,
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; },
};
})(),
location: { hash: '', protocol: 'https:', host: 'localhost' },
CustomEvent: class CustomEvent {},
addEventListener: () => {},
dispatchEvent: () => {},
getComputedStyle: () => ({ getPropertyValue: () => '' }),
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
navigator: {},
visualViewport: null,
MutationObserver: function() { this.observe = () => {}; this.disconnect = () => {}; },
WebSocket: function() { this.close = () => {}; },
IATA_COORDS_GEO: {},
};
vm.createContext(ctx);
return ctx;
}
function loadInCtx(ctx, file) {
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
function makeLeafletMock() {
return {
circleMarker: () => {
const m = {
addTo() { return m; }, bindTooltip() { return m; }, on() { return m; },
setRadius() {}, setStyle() {}, setLatLng() {},
getLatLng() { return { lat: 0, lng: 0 }; },
_baseColor: '', _baseSize: 5, _glowMarker: null, remove() {},
};
return m;
},
polyline: () => { const p = { addTo() { return p; }, setStyle() {}, remove() {} }; return p; },
polygon: () => { const p = { addTo() { return p; }, remove() {} }; return p; },
map: () => {
const m = {
setView() { return m; }, addLayer() { return m; }, on() { return m; },
getZoom() { return 11; }, getCenter() { return { lat: 37, lng: -122 }; },
getBounds() { return { contains: () => true }; }, fitBounds() { return m; },
invalidateSize() {}, remove() {}, hasLayer() { return false; }, removeLayer() {},
};
return m;
},
layerGroup: () => {
const g = {
addTo() { return g; }, addLayer() {}, removeLayer() {},
clearLayers() {}, hasLayer() { return true; }, eachLayer() {},
};
return g;
},
tileLayer: () => ({ addTo() { return this; } }),
control: { attribution: () => ({ addTo() {} }) },
DomUtil: { addClass() {}, removeClass() {} },
};
}
function addLiveGlobals(ctx) {
ctx.L = makeLeafletMock();
ctx.registerPage = () => {};
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.connectWS = () => {};
ctx.api = () => Promise.resolve([]);
ctx.invalidateApiCache = () => {};
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.getFavorites = () => [];
ctx.isFavorite = () => false;
ctx.HopResolver = { init() {}, resolve: () => ({}), ready: () => false };
ctx.MeshAudio = null;
ctx.RegionFilter = { init() {}, getSelected: () => null, onRegionChange: () => {} };
}
function makeLiveSandbox({ withAppJs = false } = {}) {
const ctx = makeSandbox();
addLiveGlobals(ctx);
loadInCtx(ctx, 'public/roles.js');
if (withAppJs) loadInCtx(ctx, 'public/app.js');
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
console.error('live.js load error:', e.message);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
return ctx;
}
// ===== dbPacketToLive =====
console.log('\n=== live.js: dbPacketToLive ===');
{
const ctx = makeLiveSandbox();
const dbPacketToLive = ctx.window._liveDbPacketToLive;
assert.ok(dbPacketToLive, '_liveDbPacketToLive must be exposed');
test('converts basic DB packet to live format', () => {
const pkt = {
id: 42, hash: 'abc123',
raw_hex: 'deadbeef',
path_json: '["hop1","hop2"]',
decoded_json: '{"type":"GRP_TXT","text":"hello"}',
timestamp: '2024-06-15T12:00:00Z',
snr: 7.5, rssi: -85, observer_name: 'ObsA',
};
const result = dbPacketToLive(pkt);
assert.strictEqual(result.id, 42);
assert.strictEqual(result.hash, 'abc123');
assert.strictEqual(result.raw, 'deadbeef');
assert.strictEqual(result.snr, 7.5);
assert.strictEqual(result.rssi, -85);
assert.strictEqual(result.observer, 'ObsA');
assert.strictEqual(result.decoded.header.payloadTypeName, 'GRP_TXT');
assert.strictEqual(result.decoded.payload.text, 'hello');
assert.deepStrictEqual(result.decoded.path.hops, ['hop1', 'hop2']);
assert.strictEqual(result._ts, new Date('2024-06-15T12:00:00Z').getTime());
});
test('handles null decoded_json', () => {
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.deepStrictEqual(result.decoded.path.hops, []);
});
test('uses payload_type_name as fallback', () => {
const pkt = { id: 2, hash: 'y', decoded_json: '{}', path_json: '[]', timestamp: '2024-01-01T00:00:00Z', payload_type_name: 'ADVERT' };
const result = dbPacketToLive(pkt);
assert.strictEqual(result.decoded.header.payloadTypeName, 'ADVERT');
});
test('uses created_at as timestamp fallback', () => {
const pkt = { id: 3, hash: 'z', decoded_json: '{}', path_json: '[]', created_at: '2024-03-01T06:00:00Z' };
const result = dbPacketToLive(pkt);
assert.strictEqual(result._ts, new Date('2024-03-01T06:00:00Z').getTime());
});
}
// ===== expandToBufferEntries =====
console.log('\n=== live.js: expandToBufferEntries ===');
{
const ctx = makeLiveSandbox();
const expand = ctx.window._liveExpandToBufferEntries;
assert.ok(expand, '_liveExpandToBufferEntries must be exposed');
test('single packet without observations returns one entry', () => {
const pkts = [{
id: 1, hash: 'h1', timestamp: '2024-06-15T12:00:00Z',
decoded_json: '{"type":"GRP_TXT"}', path_json: '[]',
}];
const entries = expand(pkts);
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].pkt.id, 1);
assert.strictEqual(entries[0].ts, new Date('2024-06-15T12:00:00Z').getTime());
});
test('packet with observations expands to one entry per observation', () => {
const pkts = [{
id: 10, hash: 'h10', timestamp: '2024-06-15T12:00:00Z',
decoded_json: '{"type":"ADVERT"}', path_json: '[]', raw_hex: 'ff',
observations: [
{ timestamp: '2024-06-15T12:00:01Z', snr: 5, observer_name: 'O1' },
{ timestamp: '2024-06-15T12:00:02Z', snr: 8, observer_name: 'O2' },
{ timestamp: '2024-06-15T12:00:03Z', snr: 3, observer_name: 'O3' },
],
}];
const entries = expand(pkts);
assert.strictEqual(entries.length, 3);
assert.strictEqual(entries[0].pkt.observer, 'O1');
assert.strictEqual(entries[1].pkt.observer, 'O2');
assert.strictEqual(entries[2].pkt.observer, 'O3');
// All should share the same hash
assert.strictEqual(entries[0].pkt.hash, 'h10');
assert.strictEqual(entries[2].pkt.hash, 'h10');
// Entries should be in chronological order
assert.ok(entries[0].ts < entries[1].ts, 'entry 0 should be before entry 1');
assert.ok(entries[1].ts < entries[2].ts, 'entry 1 should be before entry 2');
});
test('empty observations array treated as no observations', () => {
const pkts = [{
id: 5, hash: 'h5', timestamp: '2024-01-01T00:00:00Z',
decoded_json: '{}', path_json: '[]', observations: [],
}];
const entries = expand(pkts);
assert.strictEqual(entries.length, 1);
});
test('multiple packets expand independently', () => {
const pkts = [
{ id: 1, hash: 'h1', timestamp: '2024-01-01T00:00:00Z', decoded_json: '{}', path_json: '[]' },
{
id: 2, hash: 'h2', timestamp: '2024-01-01T00:00:00Z', decoded_json: '{}', path_json: '[]', raw_hex: 'aa',
observations: [
{ timestamp: '2024-01-01T00:00:01Z', observer_name: 'X' },
{ timestamp: '2024-01-01T00:00:02Z', observer_name: 'Y' },
],
},
];
const entries = expand(pkts);
assert.strictEqual(entries.length, 3);
});
}
// ===== SEG_MAP (7-segment display) =====
console.log('\n=== live.js: SEG_MAP ===');
{
const ctx = makeLiveSandbox();
const SEG_MAP = ctx.window._liveSEG_MAP;
assert.ok(SEG_MAP, '_liveSEG_MAP must be exposed');
test('all digits 0-9 are mapped', () => {
for (let i = 0; i <= 9; i++) {
assert.ok(SEG_MAP[String(i)] !== undefined, `digit ${i} must be in SEG_MAP`);
assert.ok(SEG_MAP[String(i)] > 0, `digit ${i} must have non-zero segments`);
}
});
test('digit 8 lights all 7 segments and no others', () => {
// 0x7F = 0b01111111 — all 7 segment bits on, MSB (colon) off
const val = SEG_MAP['8'];
assert.strictEqual(val & 0x7F, 0x7F, 'all 7 segment bits should be set');
assert.strictEqual(val & 0x80, 0, 'colon bit should not be set for a digit');
});
test('colon only sets the MSB (dot/colon indicator)', () => {
const val = SEG_MAP[':'];
assert.strictEqual(val & 0x80, 0x80, 'MSB (colon bit) should be set');
assert.strictEqual(val & 0x7F, 0, 'no segment bits should be set for colon');
});
test('space lights no segments', () => {
assert.strictEqual(SEG_MAP[' '], 0x00, 'space should have no bits set');
});
test('digit 1 lights fewer segments than digit 8', () => {
// Behavioral: 1 has fewer segments lit than 8
const ones = (n) => { let c = 0; while (n) { c += n & 1; n >>= 1; } return c; };
assert.ok(ones(SEG_MAP['1']) < ones(SEG_MAP['8']),
'digit 1 should have fewer segment bits than digit 8');
});
test('VCR mode letters are mapped with non-zero segments', () => {
for (const ch of ['P', 'A', 'U', 'S', 'E', 'L', 'I', 'V']) {
assert.ok(SEG_MAP[ch] !== undefined, `${ch} must be in SEG_MAP`);
assert.ok(SEG_MAP[ch] > 0, `${ch} must have non-zero segments`);
}
});
}
// ===== VCR state machine =====
console.log('\n=== live.js: VCR state machine ===');
{
const ctx = makeLiveSandbox();
const VCR = ctx.window._liveVCR;
const vcrSetMode = ctx.window._liveVcrSetMode;
const vcrPause = ctx.window._liveVcrPause;
const vcrSpeedCycle = ctx.window._liveVcrSpeedCycle;
assert.ok(VCR, '_liveVCR must be exposed');
test('VCR initial mode is LIVE', () => {
assert.strictEqual(VCR().mode, 'LIVE');
});
test('vcrSetMode changes mode', () => {
vcrSetMode('PAUSED');
assert.strictEqual(VCR().mode, 'PAUSED');
assert.ok(VCR().frozenNow != null, 'frozenNow should be set when not LIVE');
});
test('vcrSetMode LIVE clears frozenNow', () => {
vcrSetMode('LIVE');
assert.strictEqual(VCR().mode, 'LIVE');
assert.strictEqual(VCR().frozenNow, null);
});
test('vcrPause stops replay and sets PAUSED', () => {
vcrSetMode('LIVE');
vcrPause();
assert.strictEqual(VCR().mode, 'PAUSED');
assert.strictEqual(VCR().missedCount, 0);
});
test('vcrPause is idempotent', () => {
vcrPause();
const frozen1 = VCR().frozenNow;
assert.strictEqual(VCR().mode, 'PAUSED', 'mode should be PAUSED after first call');
vcrPause();
assert.strictEqual(VCR().frozenNow, frozen1);
assert.strictEqual(VCR().mode, 'PAUSED', 'mode should stay PAUSED after second call');
});
test('vcrSpeedCycle cycles through 1,2,4,8', () => {
vcrSetMode('LIVE');
VCR().speed = 1;
vcrSpeedCycle();
assert.strictEqual(VCR().speed, 2);
vcrSpeedCycle();
assert.strictEqual(VCR().speed, 4);
vcrSpeedCycle();
assert.strictEqual(VCR().speed, 8);
vcrSpeedCycle();
assert.strictEqual(VCR().speed, 1); // wraps around
});
const vcrResumeLive = ctx.window._liveVcrResumeLive;
assert.ok(vcrResumeLive, '_liveVcrResumeLive must be exposed');
test('vcrResumeLive transitions from PAUSED to LIVE', () => {
vcrPause();
assert.strictEqual(VCR().mode, 'PAUSED');
assert.ok(VCR().frozenNow != null, 'frozenNow should be set when paused');
vcrResumeLive();
assert.strictEqual(VCR().mode, 'LIVE');
assert.strictEqual(VCR().frozenNow, null, 'frozenNow should be cleared');
assert.strictEqual(VCR().playhead, -1, 'playhead should reset to -1');
assert.strictEqual(VCR().speed, 1, 'speed should reset to 1');
assert.strictEqual(VCR().missedCount, 0, 'missedCount should be 0');
});
}
// ===== getFavoritePubkeys =====
console.log('\n=== live.js: getFavoritePubkeys ===');
{
const ctx = makeLiveSandbox();
const getFavPubkeys = ctx.window._liveGetFavoritePubkeys;
assert.ok(getFavPubkeys, '_liveGetFavoritePubkeys must be exposed');
test('returns empty array when no favorites stored', () => {
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
const result = getFavPubkeys();
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 0);
});
test('reads from meshcore-favorites', () => {
ctx.localStorage.setItem('meshcore-favorites', '["pk1","pk2"]');
ctx.localStorage.removeItem('meshcore-my-nodes');
const result = getFavPubkeys();
assert.ok(result.includes('pk1'));
assert.ok(result.includes('pk2'));
});
test('reads from meshcore-my-nodes pubkeys', () => {
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.setItem('meshcore-my-nodes', '[{"pubkey":"mynode1"},{"pubkey":"mynode2"}]');
const result = getFavPubkeys();
assert.ok(result.includes('mynode1'));
assert.ok(result.includes('mynode2'));
});
test('merges both sources', () => {
ctx.localStorage.setItem('meshcore-favorites', '["fav1"]');
ctx.localStorage.setItem('meshcore-my-nodes', '[{"pubkey":"mine1"}]');
const result = getFavPubkeys();
assert.ok(result.includes('fav1'));
assert.ok(result.includes('mine1'));
assert.strictEqual(result.length, 2);
});
test('handles corrupt localStorage gracefully', () => {
ctx.localStorage.setItem('meshcore-favorites', 'not json');
ctx.localStorage.setItem('meshcore-my-nodes', '{bad}');
const result = getFavPubkeys();
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 0, 'corrupt data should yield empty array');
});
test('filters out falsy values', () => {
ctx.localStorage.setItem('meshcore-favorites', '["pk1",null,"",false,"pk2"]');
ctx.localStorage.removeItem('meshcore-my-nodes');
const result = getFavPubkeys();
assert.ok(!result.includes(null));
assert.ok(!result.includes(''));
assert.strictEqual(result.length, 2);
});
}
// ===== packetInvolvesFavorite =====
console.log('\n=== live.js: packetInvolvesFavorite ===');
{
const ctx = makeLiveSandbox();
// Clean localStorage to avoid leakage from prior test sections
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
const involves = ctx.window._livePacketInvolvesFavorite;
assert.ok(involves, '_livePacketInvolvesFavorite must be exposed');
test('returns false when no favorites', () => {
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
const pkt = { decoded: { header: {}, payload: { pubKey: 'abc' } } };
assert.strictEqual(involves(pkt), false);
});
test('matches sender pubKey', () => {
ctx.localStorage.setItem('meshcore-favorites', '["sender123"]');
const pkt = { decoded: { header: {}, payload: { pubKey: 'sender123' } } };
assert.strictEqual(involves(pkt), true);
});
test('matches hop prefix', () => {
ctx.localStorage.setItem('meshcore-favorites', '["abcdef1234567890"]');
const pkt = { decoded: { header: {}, payload: {}, path: { hops: ['abcd'] } } };
assert.strictEqual(involves(pkt), true);
});
test('does not match unrelated hop', () => {
ctx.localStorage.setItem('meshcore-favorites', '["abcdef1234567890"]');
const pkt = { decoded: { header: {}, payload: {}, path: { hops: ['ffff'] } } };
assert.strictEqual(involves(pkt), false);
});
test('handles missing decoded fields gracefully', () => {
ctx.localStorage.setItem('meshcore-favorites', '["xyz"]');
const pkt = {};
assert.strictEqual(involves(pkt), false);
});
}
// ===== isNodeFavorited =====
console.log('\n=== live.js: isNodeFavorited ===');
{
const ctx = makeLiveSandbox();
// Clean localStorage to avoid leakage from prior test sections
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
const isFav = ctx.window._liveIsNodeFavorited;
assert.ok(isFav, '_liveIsNodeFavorited must be exposed');
test('returns true when pubkey is in favorites', () => {
ctx.localStorage.setItem('meshcore-favorites', '["pk1","pk2"]');
assert.strictEqual(isFav('pk1'), true);
});
test('returns false when pubkey not in favorites', () => {
ctx.localStorage.setItem('meshcore-favorites', '["pk1"]');
assert.strictEqual(isFav('pk99'), false);
});
test('returns false with empty favorites', () => {
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
assert.strictEqual(isFav('pk1'), false);
});
}
// ===== formatLiveTimestampHtml =====
console.log('\n=== live.js: formatLiveTimestampHtml ===');
{
const ctx = makeLiveSandbox({ withAppJs: true });
const fmt = ctx.window._liveFormatLiveTimestampHtml;
assert.ok(fmt, '_liveFormatLiveTimestampHtml must be exposed');
test('formats a recent ISO timestamp', () => {
const iso = new Date(Date.now() - 30000).toISOString();
const html = fmt(iso);
assert.ok(html.includes('timestamp-text'), 'should contain timestamp-text span');
assert.ok(html.includes('title='), 'should have tooltip');
});
test('handles null input', () => {
const html = fmt(null);
assert.ok(typeof html === 'string');
assert.ok(html.includes('—'), 'null input should render em-dash fallback');
});
test('handles numeric timestamp', () => {
const html = fmt(Date.now() - 60000);
assert.ok(typeof html === 'string');
assert.ok(html.includes('timestamp-text'), 'numeric timestamp should produce timestamp-text span');
assert.ok(html.includes('title='), 'numeric timestamp should have tooltip');
});
test('future timestamp shows warning icon', () => {
const future = new Date(Date.now() + 120000).toISOString();
const html = fmt(future);
assert.ok(html.includes('timestamp-future-icon'), 'should show future warning');
});
}
// ===== resolveHopPositions =====
console.log('\n=== live.js: resolveHopPositions ===');
{
const ctx = makeLiveSandbox();
const resolve = ctx.window._liveResolveHopPositions;
const nodeData = ctx.window._liveNodeData();
const nodeMarkers = ctx.window._liveNodeMarkers();
assert.ok(resolve, '_liveResolveHopPositions must be exposed');
test('returns empty array for empty hops', () => {
const result = resolve([], {});
assert.deepStrictEqual(result, []);
});
test('returns sender position when payload has pubKey + coords', () => {
const payload = { pubKey: 'sender1', name: 'Sender', lat: 37.5, lon: -122.0 };
// No nodes in nodeData, so hops won't resolve
const result = resolve([], payload);
// With empty hops, the function still adds the sender as an anchor point.
assert.ok(Array.isArray(result), 'should return an array');
assert.strictEqual(result.length, 1, 'sender coords should produce one anchor position');
assert.strictEqual(result[0].pos[0], 37.5, 'anchor should use sender lat');
assert.strictEqual(result[0].pos[1], -122.0, 'anchor should use sender lon');
assert.strictEqual(result[0].name, 'Sender', 'anchor should use sender name');
assert.strictEqual(result[0].known, true, 'sender with coords should be marked as known');
});
test('resolves known node from nodeData', () => {
// Add a node to nodeData
nodeData['nodeA_pubkey'] = { public_key: 'nodeA_pubkey', name: 'NodeA', lat: 37.3, lon: -122.0 };
nodeData['nodeB_pubkey'] = { public_key: 'nodeB_pubkey', name: 'NodeB', lat: 38.0, lon: -121.0 };
// Need HopResolver to resolve the hop prefix — set on both ctx and window
const mockResolver = {
init() {},
ready() { return true; },
resolve(hops) {
const map = {};
for (const h of hops) {
if (h === 'nodeA') map[h] = { name: 'NodeA', pubkey: 'nodeA_pubkey' };
else if (h === 'nodeB') map[h] = { name: 'NodeB', pubkey: 'nodeB_pubkey' };
else map[h] = { name: null, pubkey: null };
}
return map;
},
};
ctx.HopResolver = mockResolver;
ctx.window.HopResolver = mockResolver;
// Need at least 2 known nodes for ghost mode to not filter down
const result = resolve(['nodeA', 'nodeB'], {});
assert.ok(result.length >= 2, `expected >= 2 positions, got ${result.length}`);
const foundA = result.find(r => r.key === 'nodeA_pubkey');
assert.ok(foundA, 'should resolve nodeA to nodeA_pubkey');
assert.strictEqual(foundA.pos[0], 37.3);
assert.strictEqual(foundA.pos[1], -122.0);
assert.strictEqual(foundA.known, true);
delete nodeData['nodeA_pubkey'];
delete nodeData['nodeB_pubkey'];
});
test('ghost hops get interpolated positions between known nodes', () => {
// Set up: two known nodes, one unknown hop between them
nodeData['n1'] = { public_key: 'n1', name: 'N1', lat: 37.0, lon: -122.0 };
nodeData['n2'] = { public_key: 'n2', name: 'N2', lat: 38.0, lon: -121.0 };
const mockResolver = {
init() {},
ready() { return true; },
resolve(hops) {
const map = {};
for (const h of hops) {
if (h === 'h1') map[h] = { name: 'N1', pubkey: 'n1' };
else if (h === 'h3') map[h] = { name: 'N2', pubkey: 'n2' };
else map[h] = { name: null, pubkey: null };
}
return map;
},
};
ctx.HopResolver = mockResolver;
ctx.window.HopResolver = mockResolver;
const result = resolve(['h1', 'h2', 'h3'], {});
assert.ok(result.length >= 2, `should have at least 2 positions, got ${result.length}`);
// Check that the ghost hop got an interpolated position
const ghost = result.find(r => r.ghost);
assert.ok(ghost, 'ghost hop should be present in resolved positions — if missing, interpolation logic changed');
assert.ok(ghost.pos[0] > 37.0 && ghost.pos[0] < 38.0, 'ghost lat should be interpolated');
assert.ok(ghost.pos[1] > -122.0 && ghost.pos[1] < -121.0, 'ghost lon should be interpolated');
delete nodeData['n1'];
delete nodeData['n2'];
});
}
// ===== bufferPacket and VCR buffer management =====
console.log('\n=== live.js: bufferPacket / VCR buffer ===');
{
const ctx = makeLiveSandbox();
const bufferPacket = ctx.window._liveBufferPacket;
const VCR = ctx.window._liveVCR;
assert.ok(bufferPacket, '_liveBufferPacket must be exposed');
test('bufferPacket adds entry to VCR buffer', () => {
const initialLen = VCR().buffer.length;
const pkt = { hash: 'test1', decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: {} } };
bufferPacket(pkt);
assert.strictEqual(VCR().buffer.length, initialLen + 1);
const last = VCR().buffer[VCR().buffer.length - 1];
assert.strictEqual(last.pkt.hash, 'test1');
assert.ok(last.ts > 0);
});
test('bufferPacket sets _ts on packet', () => {
const pkt = { hash: 'test2', decoded: { header: {}, payload: {} } };
const before = Date.now();
bufferPacket(pkt);
const after = Date.now();
assert.ok(pkt._ts >= before && pkt._ts <= after, `_ts should be between ${before} and ${after}, got ${pkt._ts}`);
});
test('VCR buffer caps at ~2000 entries', () => {
// Fill buffer past 2000
VCR().buffer.length = 0;
for (let i = 0; i < 2100; i++) {
VCR().buffer.push({ ts: Date.now(), pkt: { hash: 'fill' + i } });
}
// Next bufferPacket triggers trim: 2100+1=2101 > 2000 → splice(0, 500) → 1601
const pkt = { hash: 'overflow', decoded: { header: {}, payload: {} } };
bufferPacket(pkt);
assert.strictEqual(VCR().buffer.length, 1601, `buffer should be 2101 - 500 = 1601, got ${VCR().buffer.length}`);
});
test('bufferPacket increments missedCount when PAUSED', () => {
ctx.window._liveVcrSetMode('PAUSED');
VCR().missedCount = 0;
const pkt = { hash: 'missed1', decoded: { header: {}, payload: {} } };
bufferPacket(pkt);
assert.strictEqual(VCR().missedCount, 1);
bufferPacket({ hash: 'missed2', decoded: { header: {}, payload: {} } });
assert.strictEqual(VCR().missedCount, 2);
ctx.window._liveVcrSetMode('LIVE');
});
test('bufferPacket handles malformed packet without decoded field', () => {
const before = VCR().buffer.length;
// Packet with no decoded field at all — should not throw, and should still be buffered
bufferPacket({ hash: 'malformed1' });
assert.strictEqual(VCR().buffer.length, before + 1, 'malformed packet should still be added to buffer');
});
test('bufferPacket handles packet with null decoded', () => {
const before = VCR().buffer.length;
bufferPacket({ hash: 'malformed2', decoded: null });
assert.strictEqual(VCR().buffer.length, before + 1, 'packet with null decoded should still be added to buffer');
});
}
// ===== VCR frozenNow behavior =====
console.log('\n=== live.js: VCR frozenNow ===');
{
const ctx = makeLiveSandbox();
const VCR = ctx.window._liveVCR;
const setMode = ctx.window._liveVcrSetMode;
test('frozenNow is set on first non-LIVE mode', () => {
setMode('LIVE');
assert.strictEqual(VCR().frozenNow, null);
setMode('PAUSED');
const t1 = VCR().frozenNow;
assert.ok(t1 > 0);
// Should NOT change on subsequent non-LIVE mode changes
setMode('REPLAY');
assert.strictEqual(VCR().frozenNow, t1, 'frozenNow should not change if already set');
});
test('frozenNow cleared on LIVE', () => {
setMode('PAUSED');
assert.ok(VCR().frozenNow != null);
setMode('LIVE');
assert.strictEqual(VCR().frozenNow, null);
});
}
// ===== Source-level checks for live.js safety guards =====
// NOTE: These src.includes() checks are intentionally brittle — they verify that specific
// safety guards exist in the source code TODAY. They will break on whitespace/rename refactors,
// which is an acceptable tradeoff: a failing test forces the developer to verify the guard
// still exists in its new form. For critical guards (animation limits, null checks), prefer
// behavioral tests where feasible (see bufferPacket and VCR sections above).
console.log('\n=== live.js: source-level safety checks ===');
{
const src = fs.readFileSync('public/live.js', 'utf8');
test('renderPacketTree null-checks packets array', () => {
assert.ok(src.includes('if (!packets || !packets.length) return;'),
'renderPacketTree must guard null/empty packets');
});
test('animatePath guards MAX_CONCURRENT_ANIMS', () => {
assert.ok(src.includes('if (activeAnims >= MAX_CONCURRENT_ANIMS) return;'),
'animatePath must respect concurrent animation limit');
});
test('animatePath guards null animLayer/pathsLayer', () => {
assert.ok(src.includes('if (!animLayer || !pathsLayer) return;'),
'animatePath must guard null layers');
});
test('pulseNode guards null animLayer/nodesLayer', () => {
assert.ok(src.includes('if (!animLayer || !nodesLayer) return;'),
'pulseNode must guard null layers');
});
test('nextHop guards null animLayer', () => {
assert.ok(src.includes('if (!animLayer) return;'),
'nextHop must guard null animLayer before drawing');
});
test('VCR buffer trim adjusts playhead', () => {
assert.ok(src.includes('VCR.playhead = Math.max(0, VCR.playhead - trimCount)'),
'buffer trim must adjust playhead to prevent stale indices');
});
test('tab hidden skips animations', () => {
assert.ok(src.includes('if (_tabHidden)'),
'bufferPacket should skip animation when tab is hidden');
});
test('visibility change clears propagation buffer', () => {
assert.ok(src.includes('propagationBuffer.clear()'),
'tab restore should clear propagation buffer');
});
test('connectWS has reconnect on close', () => {
assert.ok(src.includes('ws.onclose = () => setTimeout(connectWS, WS_RECONNECT_MS)'),
'WebSocket should auto-reconnect on close');
});
test('addNodeMarker avoids duplicates', () => {
assert.ok(src.includes('if (nodeMarkers[n.public_key]) return nodeMarkers[n.public_key]'),
'addNodeMarker should return existing marker if already exists');
});
test('matrix mode saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-matrix-mode'"),
'matrix toggle should persist to localStorage');
});
test('matrix rain saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-matrix-rain'"),
'matrix rain toggle should persist to localStorage');
});
test('realistic propagation saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-realistic-propagation'"),
'realistic propagation toggle should persist to localStorage');
});
test('favorites filter saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-favorites-only'"),
'favorites filter toggle should persist to localStorage');
});
test('ghost hops saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-ghost-hops'"),
'ghost hops toggle should persist to localStorage');
});
test('clearNodeMarkers resets HopResolver', () => {
assert.ok(src.includes('if (window.HopResolver) HopResolver.init([])'),
'clearNodeMarkers should reset HopResolver');
});
test('rescaleMarkers reads zoom from map', () => {
assert.ok(src.includes('const zoom = map.getZoom()'),
'rescaleMarkers should read current zoom level');
});
test('startReplay pre-aggregates by hash', () => {
assert.ok(src.includes('const hashGroups = new Map()'),
'startReplay should group buffer entries by hash');
});
test('orientation change retries resize with delays', () => {
assert.ok(src.includes('[50, 200, 500, 1000, 2000].forEach'),
'orientation change handler should retry resize at multiple intervals');
});
test('VCR rewind deduplicates buffer entries by ID', () => {
assert.ok(src.includes('const existingIds = new Set(VCR.buffer.map(b => b.pkt.id)'),
'vcrRewind should dedup by packet ID');
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);
console.log(` live.js tests: ${passed} passed, ${failed} failed`);
console.log(`${'═'.repeat(40)}\n`);
if (failed > 0) process.exit(1);
}).catch((e) => {
console.error('Failed waiting for async tests:', e);
process.exit(1);
});
-242
View File
@@ -1,242 +0,0 @@
/**
* Show Neighbors E2E tests (#484 fix)
* Tests that selectReferenceNode() uses the affinity API instead of client-side path walking.
* Usage: CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:13590 node test-show-neighbors.js
*/
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
const results = [];
async function test(name, fn) {
try {
await fn();
results.push({ name, pass: true });
console.log(`${name}`);
} catch (err) {
results.push({ name, pass: false, error: err.message });
console.log(`${name}: ${err.message}`);
}
}
function assert(condition, msg) {
if (!condition) throw new Error(msg || 'Assertion failed');
}
async function run() {
console.log('Launching Chromium...');
const launchOpts = { headless: true, args: ['--no-sandbox', '--disable-gpu'] };
if (process.env.CHROMIUM_PATH) launchOpts.executablePath = process.env.CHROMIUM_PATH;
const browser = await chromium.launch(launchOpts);
const page = await browser.newPage();
console.log(`\nRunning Show Neighbors tests against ${BASE}\n`);
await test('Show Neighbors calls affinity API and populates neighborPubkeys', async () => {
const testPubkey = 'aabbccdd11223344556677889900aabbccddeeff00112233445566778899001122';
const neighborPubkey1 = '1111111111111111111111111111111111111111111111111111111111111111';
const neighborPubkey2 = '2222222222222222222222222222222222222222222222222222222222222222';
let apiCalled = false;
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
apiCalled = true;
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
node: testPubkey,
neighbors: [
{ pubkey: neighborPubkey1, prefix: '11', name: 'Neighbor-1', role: 'repeater', count: 50, score: 0.9, ambiguous: false },
{ pubkey: neighborPubkey2, prefix: '22', name: 'Neighbor-2', role: 'companion', count: 20, score: 0.7, ambiguous: false }
],
total_observations: 70
})
});
});
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
const result = await page.evaluate(async (args) => {
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no _mapSelectRefNode function' };
if (typeof window._mapGetNeighborPubkeys !== 'function') return { error: 'no _mapGetNeighborPubkeys function' };
await window._mapSelectRefNode(args.pk, 'TestNode');
return { neighbors: window._mapGetNeighborPubkeys() };
}, { pk: testPubkey });
assert(!result.error, result.error || '');
assert(apiCalled, 'The /neighbors API should have been called');
assert(result.neighbors.includes(neighborPubkey1), `Should contain neighbor1, got: ${JSON.stringify(result.neighbors)}`);
assert(result.neighbors.includes(neighborPubkey2), `Should contain neighbor2, got: ${JSON.stringify(result.neighbors)}`);
assert(result.neighbors.length === 2, `Should have exactly 2 neighbors, got ${result.neighbors.length}`);
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
});
await test('Show Neighbors resolves correct node on hash collision via affinity API', async () => {
const nodeA = 'c0dedad4208acb6cbe44b848943fc6d3c5d43cf38a21e48b43826a70862980e4';
const nodeB = 'c0f1a2b3000000000000000000000000000000000000000000000000000000ff';
const neighborR1 = 'r1aaaaaa000000000000000000000000000000000000000000000000000000aa';
const neighborR2 = 'r2bbbbbb000000000000000000000000000000000000000000000000000000bb';
const neighborR4 = 'r4dddddd000000000000000000000000000000000000000000000000000000dd';
await page.route(`**/api/nodes/${nodeA}/neighbors*`, route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
node: nodeA,
neighbors: [
{ pubkey: neighborR1, prefix: 'R1', name: 'Repeater-R1', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
{ pubkey: neighborR2, prefix: 'R2', name: 'Repeater-R2', role: 'repeater', count: 80, score: 0.85, ambiguous: false }
],
total_observations: 180
})
});
});
await page.route(`**/api/nodes/${nodeB}/neighbors*`, route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
node: nodeB,
neighbors: [
{ pubkey: neighborR4, prefix: 'R4', name: 'Repeater-R4', role: 'repeater', count: 60, score: 0.75, ambiguous: false }
],
total_observations: 60
})
});
});
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
// Select Node A — should get R1, R2 but NOT R4
const resultA = await page.evaluate(async (pk) => {
await window._mapSelectRefNode(pk, 'NodeA');
return window._mapGetNeighborPubkeys();
}, nodeA);
assert(resultA.includes(neighborR1), 'Node A should have R1 as neighbor');
assert(resultA.includes(neighborR2), 'Node A should have R2 as neighbor');
assert(!resultA.includes(neighborR4), 'Node A should NOT have R4 (that belongs to Node B)');
// Select Node B — should get R4 but NOT R1, R2
const resultB = await page.evaluate(async (pk) => {
await window._mapSelectRefNode(pk, 'NodeB');
return window._mapGetNeighborPubkeys();
}, nodeB);
assert(resultB.includes(neighborR4), 'Node B should have R4 as neighbor');
assert(!resultB.includes(neighborR1), 'Node B should NOT have R1 (that belongs to Node A)');
assert(!resultB.includes(neighborR2), 'Node B should NOT have R2 (that belongs to Node A)');
await page.unroute(`**/api/nodes/${nodeA}/neighbors*`);
await page.unroute(`**/api/nodes/${nodeB}/neighbors*`);
});
await test('Show Neighbors falls back to path walking when affinity API returns empty', async () => {
const testPubkey = 'fallbacktest0000000000000000000000000000000000000000000000000000';
const hopBefore = 'aaaa000000000000000000000000000000000000000000000000000000000000';
const hopAfter = 'bbbb000000000000000000000000000000000000000000000000000000000000';
let neighborApiCalled = false;
let pathsApiCalled = false;
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
neighborApiCalled = true;
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ node: testPubkey, neighbors: [], total_observations: 0 })
});
});
await page.route(`**/api/nodes/${testPubkey}/paths*`, route => {
pathsApiCalled = true;
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
paths: [{
hops: [
{ pubkey: hopBefore, name: 'HopBefore' },
{ pubkey: testPubkey, name: 'Self' },
{ pubkey: hopAfter, name: 'HopAfter' }
]
}]
})
});
});
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
const result = await page.evaluate(async (pk) => {
if (typeof window._mapSelectRefNode !== 'function') return { error: 'no-function' };
await window._mapSelectRefNode(pk, 'FallbackNode');
return { neighbors: window._mapGetNeighborPubkeys() };
}, testPubkey);
assert(!result.error, result.error || '');
assert(neighborApiCalled, 'Should try neighbor API first');
assert(pathsApiCalled, 'Should fall back to paths API when neighbors empty');
assert(result.neighbors.includes(hopBefore), 'Fallback should find hopBefore as neighbor');
assert(result.neighbors.includes(hopAfter), 'Fallback should find hopAfter as neighbor');
assert(result.neighbors.length === 2, `Fallback should find exactly 2 neighbors, got ${result.neighbors.length}`);
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
await page.unroute(`**/api/nodes/${testPubkey}/paths*`);
});
await test('Show Neighbors includes ambiguous candidates in neighborPubkeys', async () => {
const testPubkey = 'ambigtest000000000000000000000000000000000000000000000000000000';
const candidate1 = 'a3b4c500000000000000000000000000000000000000000000000000000000';
const candidate2 = 'a3f0e100000000000000000000000000000000000000000000000000000000';
const knownNeighbor = 'b7e8f9a000000000000000000000000000000000000000000000000000000000';
await page.route(`**/api/nodes/${testPubkey}/neighbors*`, route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
node: testPubkey,
neighbors: [
{ pubkey: knownNeighbor, prefix: 'B7', name: 'Known-Neighbor', role: 'repeater', count: 100, score: 0.95, ambiguous: false },
{ pubkey: null, prefix: 'A3', name: null, role: null, count: 12, score: 0.08, ambiguous: true,
candidates: [
{ pubkey: candidate1, name: 'Node-Alpha', role: 'companion' },
{ pubkey: candidate2, name: 'Node-Beta', role: 'companion' }
]
}
],
total_observations: 112
})
});
});
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
const result = await page.evaluate(async (pk) => {
await window._mapSelectRefNode(pk, 'AmbigNode');
return window._mapGetNeighborPubkeys();
}, testPubkey);
// Should include the known neighbor AND both ambiguous candidates
assert(result.includes(knownNeighbor), 'Should include known neighbor');
assert(result.includes(candidate1), 'Should include ambiguous candidate 1');
assert(result.includes(candidate2), 'Should include ambiguous candidate 2');
assert(result.length === 3, `Should have 3 neighbors (1 known + 2 candidates), got ${result.length}`);
await page.unroute(`**/api/nodes/${testPubkey}/neighbors*`);
});
await browser.close();
const passed = results.filter(r => r.pass).length;
const failed = results.filter(r => !r.pass).length;
console.log(`\n${passed}/${results.length} tests passed${failed ? `, ${failed} failed` : ''}`);
process.exit(failed > 0 ? 1 : 0);
}
run().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});