mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 23:11:41 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31745f9edc | |||
| a89b577ce5 | |||
| 6cd616bcef | |||
| 1d1cd46d3b | |||
| bc92b8b5c9 | |||
| 016b87b33c | |||
| 889107a5e1 | |||
| 50f94603c1 | |||
| b799f54700 | |||
| d5b300a8ba |
+157
-28
@@ -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,
|
||||
@@ -397,6 +438,94 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HexRange represents a labeled byte range for the hex breakdown visualization.
|
||||
type HexRange struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// Breakdown holds colored byte ranges returned by the packet detail endpoint.
|
||||
type Breakdown struct {
|
||||
Ranges []HexRange `json:"ranges"`
|
||||
}
|
||||
|
||||
// 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/packets.js).
|
||||
func BuildBreakdown(hexString string) *Breakdown {
|
||||
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
|
||||
|
||||
// Header byte
|
||||
ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"})
|
||||
|
||||
// Transport codes
|
||||
if f.hasTransport {
|
||||
ranges = append(ranges, HexRange{Start: f.transportOffset, End: f.transportOffset + 3, Label: "Transport Codes"})
|
||||
}
|
||||
|
||||
// Path length byte
|
||||
ranges = append(ranges, HexRange{Start: f.pathOffset, End: f.pathOffset, Label: "Path Length"})
|
||||
|
||||
// Path hops
|
||||
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"})
|
||||
}
|
||||
|
||||
if f.payloadOffset >= len(buf) {
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
|
||||
// Payload — break ADVERT into named sub-fields; everything else is one Payload range
|
||||
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 := ps + 100
|
||||
if appStart < len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: appStart, End: appStart, Label: "Flags"})
|
||||
appFlags := buf[appStart]
|
||||
fOff := appStart + 1
|
||||
if appFlags&0x10 != 0 && fOff+8 <= len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 3, Label: "Latitude"})
|
||||
ranges = append(ranges, HexRange{Start: fOff + 4, End: fOff + 7, Label: "Longitude"})
|
||||
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: f.payloadOffset, End: len(buf) - 1, Label: "Payload"})
|
||||
}
|
||||
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
|
||||
// ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars).
|
||||
func ComputeContentHash(rawHex string) string {
|
||||
buf, err := hex.DecodeString(rawHex)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -93,3 +94,323 @@ func TestDecodePacket_FloodHasNoCodes(t *testing.T) {
|
||||
t.Error("expected no transport codes for FLOOD route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_InvalidHex(t *testing.T) {
|
||||
b := BuildBreakdown("not-hex!")
|
||||
if len(b.Ranges) != 0 {
|
||||
t.Errorf("expected empty ranges for invalid hex, got %d", len(b.Ranges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_TooShort(t *testing.T) {
|
||||
b := BuildBreakdown("11") // 1 byte — no path byte
|
||||
if len(b.Ranges) != 0 {
|
||||
t.Errorf("expected empty ranges for too-short packet, got %d", len(b.Ranges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_FloodNonAdvert(t *testing.T) {
|
||||
// Header 0x15: route=1/FLOOD, payload=5/GRP_TXT
|
||||
// PathByte 0x01: 1 hop, 1-byte hash
|
||||
// PathHop: AA
|
||||
// Payload: FF0011
|
||||
b := BuildBreakdown("1501AAFFFF00")
|
||||
labels := rangeLabels(b.Ranges)
|
||||
expect := []string{"Header", "Path Length", "Path", "Payload"}
|
||||
if !equalLabels(labels, expect) {
|
||||
t.Errorf("expected labels %v, got %v", expect, labels)
|
||||
}
|
||||
// Verify byte positions
|
||||
assertRange(t, b.Ranges, "Header", 0, 0)
|
||||
assertRange(t, b.Ranges, "Path Length", 1, 1)
|
||||
assertRange(t, b.Ranges, "Path", 2, 2)
|
||||
assertRange(t, b.Ranges, "Payload", 3, 5)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_TransportFlood(t *testing.T) {
|
||||
// Header 0x14: route=0/TRANSPORT_FLOOD, payload=5/GRP_TXT
|
||||
// TransportCodes: AABBCCDD (4 bytes)
|
||||
// PathByte 0x01: 1 hop, 1-byte hash
|
||||
// PathHop: EE
|
||||
// Payload: FF00
|
||||
b := BuildBreakdown("14AABBCCDD01EEFF00")
|
||||
assertRange(t, b.Ranges, "Header", 0, 0)
|
||||
assertRange(t, b.Ranges, "Transport Codes", 1, 4)
|
||||
assertRange(t, b.Ranges, "Path Length", 5, 5)
|
||||
assertRange(t, b.Ranges, "Path", 6, 6)
|
||||
assertRange(t, b.Ranges, "Payload", 7, 8)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_FloodNoHops(t *testing.T) {
|
||||
// Header 0x15: FLOOD/GRP_TXT; PathByte 0x00: 0 hops; Payload: AABB
|
||||
b := BuildBreakdown("150000AABB")
|
||||
assertRange(t, b.Ranges, "Header", 0, 0)
|
||||
assertRange(t, b.Ranges, "Path Length", 1, 1)
|
||||
// No Path range since hashCount=0
|
||||
for _, r := range b.Ranges {
|
||||
if r.Label == "Path" {
|
||||
t.Error("expected no Path range for zero-hop packet")
|
||||
}
|
||||
}
|
||||
assertRange(t, b.Ranges, "Payload", 2, 4)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertBasic(t *testing.T) {
|
||||
// Header 0x11: FLOOD/ADVERT
|
||||
// PathByte 0x01: 1 hop, 1-byte hash
|
||||
// PathHop: AA
|
||||
// Payload: 100 bytes (PubKey32 + Timestamp4 + Signature64) + Flags=0x02 (repeater, no extras)
|
||||
pubkey := strings.Repeat("AB", 32)
|
||||
ts := "00000000" // 4 bytes
|
||||
sig := strings.Repeat("CD", 64)
|
||||
flags := "02"
|
||||
hex := "1101AA" + pubkey + ts + sig + flags
|
||||
b := BuildBreakdown(hex)
|
||||
assertRange(t, b.Ranges, "Header", 0, 0)
|
||||
assertRange(t, b.Ranges, "Path Length", 1, 1)
|
||||
assertRange(t, b.Ranges, "Path", 2, 2)
|
||||
assertRange(t, b.Ranges, "PubKey", 3, 34)
|
||||
assertRange(t, b.Ranges, "Timestamp", 35, 38)
|
||||
assertRange(t, b.Ranges, "Signature", 39, 102)
|
||||
assertRange(t, b.Ranges, "Flags", 103, 103)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertWithLocation(t *testing.T) {
|
||||
// flags=0x12: hasLocation bit set
|
||||
pubkey := strings.Repeat("00", 32)
|
||||
ts := "00000000"
|
||||
sig := strings.Repeat("00", 64)
|
||||
flags := "12" // 0x10 = hasLocation
|
||||
latBytes := "00000000"
|
||||
lonBytes := "00000000"
|
||||
hex := "1101AA" + pubkey + ts + sig + flags + latBytes + lonBytes
|
||||
b := BuildBreakdown(hex)
|
||||
assertRange(t, b.Ranges, "Latitude", 104, 107)
|
||||
assertRange(t, b.Ranges, "Longitude", 108, 111)
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertWithName(t *testing.T) {
|
||||
// flags=0x82: hasName bit set
|
||||
pubkey := strings.Repeat("00", 32)
|
||||
ts := "00000000"
|
||||
sig := strings.Repeat("00", 64)
|
||||
flags := "82" // 0x80 = hasName
|
||||
name := "4E6F6465" // "Node" in hex
|
||||
hex := "1101AA" + pubkey + ts + sig + flags + name
|
||||
b := BuildBreakdown(hex)
|
||||
assertRange(t, b.Ranges, "Name", 104, 107)
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func rangeLabels(ranges []HexRange) []string {
|
||||
out := make([]string, len(ranges))
|
||||
for i, r := range ranges {
|
||||
out[i] = r.Label
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func equalLabels(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantEnd int) {
|
||||
t.Helper()
|
||||
for _, r := range ranges {
|
||||
if r.Label == label {
|
||||
if r.Start != wantStart || r.End != wantEnd {
|
||||
t.Errorf("range %q: want [%d,%d], got [%d,%d]", label, wantStart, wantEnd, r.Start, r.End)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("range %q not found in %v", label, rangeLabels(ranges))
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -761,10 +761,11 @@ func (s *Server) handlePacketDetail(w http.ResponseWriter, r *http.Request) {
|
||||
pathHops = []interface{}{}
|
||||
}
|
||||
|
||||
rawHex, _ := packet["raw_hex"].(string)
|
||||
writeJSON(w, PacketDetailResponse{
|
||||
Packet: packet,
|
||||
Path: pathHops,
|
||||
Breakdown: struct{}{},
|
||||
Breakdown: BuildBreakdown(rawHex),
|
||||
ObservationCount: observationCount,
|
||||
Observations: mapSliceToObservations(observations),
|
||||
})
|
||||
|
||||
+1
-1
@@ -289,7 +289,7 @@ type PacketTimestampsResponse struct {
|
||||
type PacketDetailResponse struct {
|
||||
Packet interface{} `json:"packet"`
|
||||
Path []interface{} `json:"path"`
|
||||
Breakdown interface{} `json:"breakdown"`
|
||||
Breakdown *Breakdown `json:"breakdown"`
|
||||
ObservationCount int `json:"observation_count"`
|
||||
Observations []ObservationResp `json:"observations,omitempty"`
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
||||
STAGING_COMPOSE_FILE="docker-compose.staging.yml"
|
||||
|
||||
# Build metadata — exported so docker compose build picks them up via args
|
||||
export APP_VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown")
|
||||
export APP_VERSION=$(git describe --tags --match "v*" 2>/dev/null || echo "unknown")
|
||||
export GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
@@ -512,7 +512,7 @@ cmd_setup() {
|
||||
|
||||
# Default to latest release tag (instead of staying on master)
|
||||
if ! is_done "version_pin"; then
|
||||
git fetch origin --tags 2>/dev/null || true
|
||||
git fetch origin --tags --force 2>/dev/null || true
|
||||
local latest_tag
|
||||
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
||||
if [ -n "$latest_tag" ]; then
|
||||
@@ -1317,7 +1317,7 @@ cmd_update() {
|
||||
local version="${1:-}"
|
||||
|
||||
info "Fetching latest changes and tags..."
|
||||
git fetch origin --tags
|
||||
git fetch origin --tags --force
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
# No arg: checkout latest release tag
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meshcore-analyzer",
|
||||
"version": "3.3.0",
|
||||
"version": "0.0.0-use-git-tags",
|
||||
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -959,4 +959,14 @@
|
||||
window._nodesIsAdvertMessage = isAdvertMessage;
|
||||
window._nodesGetAllNodes = function() { return _allNodes; };
|
||||
window._nodesSetAllNodes = function(n) { _allNodes = n; };
|
||||
window._nodesToggleSort = toggleSort;
|
||||
window._nodesSortNodes = sortNodes;
|
||||
window._nodesSortArrow = sortArrow;
|
||||
window._nodesGetSortState = function() { return sortState; };
|
||||
window._nodesSetSortState = function(s) { sortState = s; };
|
||||
window._nodesSyncClaimedToFavorites = syncClaimedToFavorites;
|
||||
window._nodesRenderNodeTimestampHtml = renderNodeTimestampHtml;
|
||||
window._nodesRenderNodeTimestampText = renderNodeTimestampText;
|
||||
window._nodesGetStatusInfo = getStatusInfo;
|
||||
window._nodesGetStatusTooltip = getStatusTooltip;
|
||||
})();
|
||||
|
||||
+43
-8
@@ -35,6 +35,11 @@
|
||||
let hopNameCache = {};
|
||||
let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true';
|
||||
let filtersBuilt = false;
|
||||
let _renderTimer = null;
|
||||
function scheduleRender() {
|
||||
clearTimeout(_renderTimer);
|
||||
_renderTimer = setTimeout(() => renderTableRows(), 200);
|
||||
}
|
||||
const PANEL_WIDTH_KEY = 'meshcore-panel-width';
|
||||
const PANEL_CLOSE_HTML = '<button class="panel-close-btn" title="Close detail pane (Esc)">✕</button>';
|
||||
|
||||
@@ -327,6 +332,7 @@
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
if (packetsPaused) {
|
||||
pauseBuffer.push(...msgs);
|
||||
if (pauseBuffer.length > 2000) pauseBuffer = pauseBuffer.slice(-2000);
|
||||
const btn = document.getElementById('pktPauseBtn');
|
||||
if (btn) btn.textContent = '▶ ' + pauseBuffer.length;
|
||||
return;
|
||||
@@ -383,6 +389,7 @@
|
||||
// Update expanded children if this group is expanded
|
||||
if (expandedHashes.has(h) && existing._children) {
|
||||
existing._children.unshift(p);
|
||||
if (existing._children.length > 200) existing._children.length = 200;
|
||||
sortGroupChildren(existing);
|
||||
}
|
||||
} else {
|
||||
@@ -403,11 +410,16 @@
|
||||
if (h) hashIndex.set(h, newGroup);
|
||||
}
|
||||
}
|
||||
// Re-sort by latest DESC
|
||||
// Re-sort by latest DESC, then evict oldest beyond the limit
|
||||
packets.sort((a, b) => (b.latest || '').localeCompare(a.latest || ''));
|
||||
if (packets.length > PACKET_LIMIT) {
|
||||
const evicted = packets.splice(PACKET_LIMIT);
|
||||
for (const p of evicted) { if (p.hash) hashIndex.delete(p.hash); }
|
||||
}
|
||||
} else {
|
||||
// Flat mode: prepend
|
||||
// Flat mode: prepend, then evict oldest beyond the limit
|
||||
packets = filtered.concat(packets);
|
||||
if (packets.length > PACKET_LIMIT) packets.length = PACKET_LIMIT;
|
||||
}
|
||||
totalCount += filtered.length;
|
||||
// Debounce WS-triggered renders to avoid rapid full rebuilds
|
||||
@@ -418,6 +430,7 @@
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
clearTimeout(_renderTimer);
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
detachVScrollListener();
|
||||
@@ -1676,7 +1689,7 @@
|
||||
let rows = '';
|
||||
|
||||
// Header section
|
||||
rows += sectionRow('Header');
|
||||
rows += sectionRow('Header', 'section-header');
|
||||
rows += fieldRow(0, 'Header Byte', '0x' + (buf.slice(0, 2) || '??'), `Route: ${routeTypeName(pkt.route_type)}, Payload: ${payloadTypeName(pkt.payload_type)}`);
|
||||
const pathByte0 = parseInt(buf.slice(2, 4), 16);
|
||||
const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1);
|
||||
@@ -1686,7 +1699,7 @@
|
||||
// Transport codes
|
||||
let off = 2;
|
||||
if (pkt.route_type === 0 || pkt.route_type === 3) {
|
||||
rows += sectionRow('Transport Codes');
|
||||
rows += sectionRow('Transport Codes', 'section-transport');
|
||||
rows += fieldRow(off, 'Next Hop', buf.slice(off * 2, (off + 2) * 2), '');
|
||||
rows += fieldRow(off + 2, 'Last Hop', buf.slice((off + 2) * 2, (off + 4) * 2), '');
|
||||
off += 4;
|
||||
@@ -1694,7 +1707,7 @@
|
||||
|
||||
// Path
|
||||
if (pathHops.length > 0) {
|
||||
rows += sectionRow('Path (' + pathHops.length + ' hops)');
|
||||
rows += sectionRow('Path (' + pathHops.length + ' hops)', 'section-path');
|
||||
const pathByte = parseInt(buf.slice(2, 4), 16);
|
||||
const hashSize = (pathByte >> 6) + 1;
|
||||
for (let i = 0; i < pathHops.length; i++) {
|
||||
@@ -1706,7 +1719,7 @@
|
||||
}
|
||||
|
||||
// Payload
|
||||
rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type));
|
||||
rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type), 'section-payload');
|
||||
|
||||
if (decoded.type === 'ADVERT') {
|
||||
rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1));
|
||||
@@ -1756,8 +1769,8 @@
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function sectionRow(label) {
|
||||
return `<tr class="section-row"><td colspan="4">${label}</td></tr>`;
|
||||
function sectionRow(label, cls) {
|
||||
return `<tr class="section-row${cls ? ' ' + cls : ''}"><td colspan="4">${label}</td></tr>`;
|
||||
}
|
||||
function fieldRow(offset, name, value, desc) {
|
||||
return `<tr><td class="mono">${offset}</td><td>${name}</td><td class="mono">${value}</td><td class="text-muted">${desc || ''}</td></tr>`;
|
||||
@@ -2009,6 +2022,28 @@
|
||||
});
|
||||
|
||||
// Standalone packet detail page: #/packet/123 or #/packet/HASH
|
||||
// Expose pure functions for unit testing (vm.createContext pattern)
|
||||
if (typeof window !== 'undefined') {
|
||||
window._packetsTestAPI = {
|
||||
typeName,
|
||||
obsName,
|
||||
getDetailPreview,
|
||||
sortGroupChildren,
|
||||
getPathHopCount,
|
||||
renderDecodedPacket,
|
||||
kv,
|
||||
buildFieldTable,
|
||||
sectionRow,
|
||||
fieldRow,
|
||||
renderTimestampCell,
|
||||
renderPath,
|
||||
_getRowCount,
|
||||
_cumulativeRowOffsets,
|
||||
buildGroupRowHtml,
|
||||
buildFlatRowHtml,
|
||||
};
|
||||
}
|
||||
|
||||
registerPage('packet-detail', {
|
||||
init: async (app, routeParam) => {
|
||||
const param = routeParam;
|
||||
|
||||
@@ -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,6 +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: 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3033,6 +3033,511 @@ console.log('\n=== channels.js: formatHashHex (issue #465) ===');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ===== packets.js: memory bounds =====
|
||||
{
|
||||
console.log('\nPackets page — memory bounds:');
|
||||
const src = fs.readFileSync('public/packets.js', 'utf8');
|
||||
|
||||
test('pauseBuffer is capped at 2000 entries', () => {
|
||||
assert.ok(src.includes('pauseBuffer.length > 2000'),
|
||||
'pauseBuffer cap check must be present');
|
||||
assert.ok(src.includes('pauseBuffer = pauseBuffer.slice(-2000)'),
|
||||
'pauseBuffer must be trimmed to last 2000 entries');
|
||||
});
|
||||
|
||||
test('packets array is trimmed to PACKET_LIMIT after WS update in grouped mode', () => {
|
||||
assert.ok(src.includes('packets.length > PACKET_LIMIT'),
|
||||
'grouped mode must check packets length against PACKET_LIMIT');
|
||||
assert.ok(src.includes('packets.splice(PACKET_LIMIT)'),
|
||||
'grouped mode must splice packets to PACKET_LIMIT');
|
||||
});
|
||||
|
||||
test('evicted packets are removed from hashIndex', () => {
|
||||
assert.ok(/const evicted = packets\.splice\(PACKET_LIMIT\)[\s\S]{0,200}hashIndex\.delete\(p\.hash\)/.test(src),
|
||||
'after splice, evicted entries must be deleted from hashIndex');
|
||||
});
|
||||
|
||||
test('packets array is trimmed to PACKET_LIMIT after WS update in flat mode', () => {
|
||||
assert.ok(/packets = filtered\.concat\(packets\)[\s\S]{0,100}packets\.length = PACKET_LIMIT/.test(src),
|
||||
'flat mode must truncate packets to PACKET_LIMIT after prepend');
|
||||
});
|
||||
|
||||
test('_children is capped at 200 on WebSocket prepend', () => {
|
||||
assert.ok(src.includes('existing._children.length > 200'),
|
||||
'_children cap check must be present');
|
||||
assert.ok(src.includes('existing._children.length = 200'),
|
||||
'_children must be truncated to 200');
|
||||
});
|
||||
|
||||
test('observerMap is built from observers array in loadObservers', () => {
|
||||
assert.ok(src.includes('observerMap = new Map(observers.map(o => [o.id, o]))'),
|
||||
'observerMap must be built as id→observer Map in loadObservers');
|
||||
});
|
||||
|
||||
test('observerMap is reset in destroy', () => {
|
||||
assert.ok(src.includes('observerMap = new Map()'),
|
||||
'destroy must reset observerMap to empty Map');
|
||||
});
|
||||
|
||||
test('WS handler debounces render via _wsRenderTimer', () => {
|
||||
const wsBlock = src.slice(src.indexOf('wsHandler = debouncedOnWS'), src.indexOf('function destroy()'));
|
||||
assert.ok(wsBlock.includes('_wsRenderTimer'),
|
||||
'WS handler must debounce renders via _wsRenderTimer');
|
||||
assert.ok(wsBlock.includes('clearTimeout(_wsRenderTimer)'),
|
||||
'WS handler must clear pending timer before scheduling new render');
|
||||
assert.ok(/setTimeout\(function \(\) \{ renderTableRows\(\); \}/.test(wsBlock),
|
||||
'WS handler must schedule renderTableRows via setTimeout');
|
||||
});
|
||||
|
||||
test('destroy clears _wsRenderTimer', () => {
|
||||
const destroyBlock = src.slice(src.indexOf('function destroy()'), src.indexOf('function destroy()') + 500);
|
||||
assert.ok(destroyBlock.includes('clearTimeout(_wsRenderTimer)'),
|
||||
'destroy must clear _wsRenderTimer to prevent stale renders after navigation');
|
||||
});
|
||||
}
|
||||
// ===== NODES.JS: shared sandbox factory =====
|
||||
function makeNodesSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
ctx.registerPage = () => {};
|
||||
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, getRegionParam: () => '', offChange: () => {} };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
if (opts.liveGetFavorites) {
|
||||
ctx.getFavorites = () => {
|
||||
try { return JSON.parse(ctx.localStorage.getItem('meshcore-favorites') || '[]'); } catch(e) { return []; }
|
||||
};
|
||||
} else {
|
||||
ctx.getFavorites = () => [];
|
||||
}
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.connectWS = () => {};
|
||||
ctx.HopResolver = { init: () => {}, resolve: () => ({}), ready: () => false };
|
||||
ctx.api = () => Promise.resolve({ nodes: [], counts: {} });
|
||||
ctx.CLIENT_TTL = { nodeList: 90000, nodeDetail: 240000, nodeHealth: 240000 };
|
||||
ctx.initTabBar = () => {};
|
||||
ctx.makeColumnsResizable = () => {};
|
||||
ctx.debounce = (fn) => fn;
|
||||
ctx.Set = Set;
|
||||
loadInCtx(ctx, 'public/nodes.js');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ===== NODES.JS: toggleSort / sortNodes / sortArrow (P0 coverage) =====
|
||||
console.log('\n=== nodes.js: toggleSort / sortNodes / sortArrow ===');
|
||||
{
|
||||
// --- toggleSort ---
|
||||
test('toggleSort switches direction on same column', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
ctx.window._nodesToggleSort('name');
|
||||
assert.strictEqual(ctx.window._nodesGetSortState().direction, 'desc');
|
||||
});
|
||||
|
||||
test('toggleSort to different column sets default direction', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
ctx.window._nodesToggleSort('last_seen');
|
||||
const s = ctx.window._nodesGetSortState();
|
||||
assert.strictEqual(s.column, 'last_seen');
|
||||
assert.strictEqual(s.direction, 'desc'); // last_seen defaults desc
|
||||
});
|
||||
|
||||
test('toggleSort to name column defaults asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
ctx.window._nodesToggleSort('name');
|
||||
const s = ctx.window._nodesGetSortState();
|
||||
assert.strictEqual(s.column, 'name');
|
||||
assert.strictEqual(s.direction, 'asc');
|
||||
});
|
||||
|
||||
test('toggleSort to advert_count defaults desc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
ctx.window._nodesToggleSort('advert_count');
|
||||
assert.strictEqual(ctx.window._nodesGetSortState().direction, 'desc');
|
||||
});
|
||||
|
||||
test('toggleSort to role defaults asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
ctx.window._nodesToggleSort('role');
|
||||
assert.strictEqual(ctx.window._nodesGetSortState().direction, 'asc');
|
||||
});
|
||||
|
||||
test('toggleSort persists to localStorage', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesToggleSort('name');
|
||||
const stored = JSON.parse(ctx.localStorage.getItem('meshcore-nodes-sort'));
|
||||
assert.strictEqual(stored.column, 'name');
|
||||
});
|
||||
|
||||
// --- sortNodes ---
|
||||
test('sortNodes by name asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'Charlie', public_key: 'c' },
|
||||
{ name: 'Alpha', public_key: 'a' },
|
||||
{ name: 'Bravo', public_key: 'b' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Alpha');
|
||||
assert.strictEqual(result[1].name, 'Bravo');
|
||||
assert.strictEqual(result[2].name, 'Charlie');
|
||||
});
|
||||
|
||||
test('sortNodes by name desc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'desc' });
|
||||
const arr = [
|
||||
{ name: 'Alpha', public_key: 'a' },
|
||||
{ name: 'Charlie', public_key: 'c' },
|
||||
{ name: 'Bravo', public_key: 'b' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Charlie');
|
||||
assert.strictEqual(result[2].name, 'Alpha');
|
||||
});
|
||||
|
||||
test('sortNodes by name puts unnamed last (asc)', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: null, public_key: 'x' },
|
||||
{ name: 'Alpha', public_key: 'a' },
|
||||
{ name: '', public_key: 'y' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Alpha');
|
||||
});
|
||||
|
||||
test('sortNodes by last_seen desc (most recent first)', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
const now = Date.now();
|
||||
const arr = [
|
||||
{ name: 'Old', last_heard: new Date(now - 100000).toISOString() },
|
||||
{ name: 'New', last_heard: new Date(now).toISOString() },
|
||||
{ name: 'Mid', last_heard: new Date(now - 50000).toISOString() },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'New');
|
||||
assert.strictEqual(result[2].name, 'Old');
|
||||
});
|
||||
|
||||
test('sortNodes by last_seen asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'asc' });
|
||||
const now = Date.now();
|
||||
const arr = [
|
||||
{ name: 'New', last_heard: new Date(now).toISOString() },
|
||||
{ name: 'Old', last_heard: new Date(now - 100000).toISOString() },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Old');
|
||||
assert.strictEqual(result[1].name, 'New');
|
||||
});
|
||||
|
||||
test('sortNodes by last_seen falls back to last_seen when last_heard missing', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
const now = Date.now();
|
||||
const arr = [
|
||||
{ name: 'A', last_seen: new Date(now - 100000).toISOString() },
|
||||
{ name: 'B', last_heard: new Date(now).toISOString() },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'B');
|
||||
});
|
||||
|
||||
test('sortNodes by last_seen handles missing timestamps', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'last_seen', direction: 'desc' });
|
||||
const arr = [
|
||||
{ name: 'NoTime' },
|
||||
{ name: 'HasTime', last_heard: new Date().toISOString() },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'HasTime');
|
||||
});
|
||||
|
||||
test('sortNodes by advert_count desc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'advert_count', direction: 'desc' });
|
||||
const arr = [
|
||||
{ name: 'Low', advert_count: 5 },
|
||||
{ name: 'High', advert_count: 100 },
|
||||
{ name: 'Mid', advert_count: 50 },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'High');
|
||||
assert.strictEqual(result[2].name, 'Low');
|
||||
});
|
||||
|
||||
test('sortNodes by advert_count asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'advert_count', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'High', advert_count: 100 },
|
||||
{ name: 'Low', advert_count: 5 },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Low');
|
||||
});
|
||||
|
||||
test('sortNodes by role asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'role', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'A', role: 'sensor' },
|
||||
{ name: 'B', role: 'companion' },
|
||||
{ name: 'C', role: 'repeater' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].role, 'companion');
|
||||
assert.strictEqual(result[1].role, 'repeater');
|
||||
assert.strictEqual(result[2].role, 'sensor');
|
||||
});
|
||||
|
||||
test('sortNodes by public_key asc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'public_key', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'C', public_key: 'ccc' },
|
||||
{ name: 'A', public_key: 'aaa' },
|
||||
{ name: 'B', public_key: 'bbb' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].public_key, 'aaa');
|
||||
assert.strictEqual(result[2].public_key, 'ccc');
|
||||
});
|
||||
|
||||
test('sortNodes handles unknown column gracefully', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'nonexistent', direction: 'asc' });
|
||||
const arr = [{ name: 'A' }, { name: 'B' }];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result.length, 2); // no crash
|
||||
});
|
||||
|
||||
test('sortNodes with empty array', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const result = ctx.window._nodesSortNodes([]);
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
test('sortNodes name case-insensitive', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const arr = [
|
||||
{ name: 'bravo' },
|
||||
{ name: 'Alpha' },
|
||||
];
|
||||
const result = ctx.window._nodesSortNodes([...arr]);
|
||||
assert.strictEqual(result[0].name, 'Alpha');
|
||||
assert.strictEqual(result[1].name, 'bravo');
|
||||
});
|
||||
|
||||
// --- sortArrow ---
|
||||
test('sortArrow returns arrow for active column', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
const html = ctx.window._nodesSortArrow('name');
|
||||
assert.ok(html.includes('▲'));
|
||||
assert.ok(html.includes('sort-arrow'));
|
||||
});
|
||||
|
||||
test('sortArrow returns down arrow for desc', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'desc' });
|
||||
const html = ctx.window._nodesSortArrow('name');
|
||||
assert.ok(html.includes('▼'));
|
||||
});
|
||||
|
||||
test('sortArrow returns empty for inactive column', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
ctx.window._nodesSetSortState({ column: 'name', direction: 'asc' });
|
||||
assert.strictEqual(ctx.window._nodesSortArrow('role'), '');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== NODES.JS: syncClaimedToFavorites =====
|
||||
console.log('\n=== nodes.js: syncClaimedToFavorites ===');
|
||||
{
|
||||
|
||||
test('syncClaimedToFavorites adds claimed pubkeys to favorites', () => {
|
||||
const ctx = makeNodesSandbox({ liveGetFavorites: true });
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', JSON.stringify([
|
||||
{ pubkey: 'key1' }, { pubkey: 'key2' }
|
||||
]));
|
||||
ctx.localStorage.setItem('meshcore-favorites', JSON.stringify(['key1']));
|
||||
ctx.window._nodesSyncClaimedToFavorites();
|
||||
const favs = JSON.parse(ctx.localStorage.getItem('meshcore-favorites'));
|
||||
assert.ok(favs.includes('key1'));
|
||||
assert.ok(favs.includes('key2'));
|
||||
assert.strictEqual(favs.length, 2);
|
||||
});
|
||||
|
||||
test('syncClaimedToFavorites no-ops when all claimed already favorited', () => {
|
||||
const ctx = makeNodesSandbox({ liveGetFavorites: true });
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', JSON.stringify([{ pubkey: 'key1' }]));
|
||||
ctx.localStorage.setItem('meshcore-favorites', JSON.stringify(['key1', 'key2']));
|
||||
ctx.window._nodesSyncClaimedToFavorites();
|
||||
const favs = JSON.parse(ctx.localStorage.getItem('meshcore-favorites'));
|
||||
assert.deepStrictEqual(favs, ['key1', 'key2']); // unchanged
|
||||
});
|
||||
|
||||
test('syncClaimedToFavorites handles empty my-nodes', () => {
|
||||
const ctx = makeNodesSandbox({ liveGetFavorites: true });
|
||||
ctx.localStorage.setItem('meshcore-my-nodes', '[]');
|
||||
ctx.localStorage.setItem('meshcore-favorites', '["key1"]');
|
||||
ctx.window._nodesSyncClaimedToFavorites();
|
||||
const favs = JSON.parse(ctx.localStorage.getItem('meshcore-favorites'));
|
||||
assert.deepStrictEqual(favs, ['key1']); // unchanged
|
||||
});
|
||||
|
||||
test('syncClaimedToFavorites handles missing localStorage keys', () => {
|
||||
const ctx = makeNodesSandbox({ liveGetFavorites: true });
|
||||
// No meshcore-my-nodes or meshcore-favorites set
|
||||
ctx.window._nodesSyncClaimedToFavorites(); // should not crash
|
||||
});
|
||||
}
|
||||
|
||||
// ===== NODES.JS: renderNodeTimestampHtml / renderNodeTimestampText =====
|
||||
console.log('\n=== nodes.js: renderNodeTimestampHtml / renderNodeTimestampText ===');
|
||||
{
|
||||
|
||||
test('renderNodeTimestampHtml returns HTML with tooltip', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const d = new Date(Date.now() - 300000).toISOString();
|
||||
const html = ctx.window._nodesRenderNodeTimestampHtml(d);
|
||||
assert.ok(html.includes('timestamp-text'), 'should have timestamp-text class');
|
||||
assert.ok(html.includes('title='), 'should have tooltip');
|
||||
});
|
||||
|
||||
test('renderNodeTimestampHtml marks future timestamps', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const d = new Date(Date.now() + 120000).toISOString();
|
||||
const html = ctx.window._nodesRenderNodeTimestampHtml(d);
|
||||
assert.ok(html.includes('timestamp-future-icon'), 'future timestamp should show warning');
|
||||
});
|
||||
|
||||
test('renderNodeTimestampHtml handles null', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const html = ctx.window._nodesRenderNodeTimestampHtml(null);
|
||||
assert.ok(html.includes('—'), 'null should produce dash');
|
||||
});
|
||||
|
||||
test('renderNodeTimestampText returns plain text', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const d = new Date(Date.now() - 300000).toISOString();
|
||||
const text = ctx.window._nodesRenderNodeTimestampText(d);
|
||||
assert.ok(!text.includes('<'), 'should be plain text, not HTML');
|
||||
assert.ok(text.includes('5m ago') || text.includes('ago') || /^\d{4}/.test(text), 'should be a readable timestamp');
|
||||
});
|
||||
|
||||
test('renderNodeTimestampText handles null', () => {
|
||||
const ctx = makeNodesSandbox();
|
||||
const text = ctx.window._nodesRenderNodeTimestampText(null);
|
||||
assert.strictEqual(text, '—');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== NODES.JS: getStatusInfo edge cases (P0 coverage expansion) =====
|
||||
console.log('\n=== nodes.js: getStatusInfo edge cases ===');
|
||||
{
|
||||
|
||||
const ctx = makeNodesSandbox();
|
||||
const gsi = ctx.window._nodesGetStatusInfo;
|
||||
const gst = ctx.window._nodesGetStatusTooltip;
|
||||
|
||||
test('getStatusInfo with _lastHeard prefers it over last_heard', () => {
|
||||
const recent = new Date().toISOString();
|
||||
const old = new Date(Date.now() - 96 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'repeater', last_heard: old, _lastHeard: recent });
|
||||
assert.strictEqual(info.status, 'active');
|
||||
});
|
||||
|
||||
test('getStatusInfo with no timestamps returns stale', () => {
|
||||
const info = gsi({ role: 'companion' });
|
||||
assert.strictEqual(info.status, 'stale');
|
||||
assert.strictEqual(info.lastHeardMs, 0);
|
||||
});
|
||||
|
||||
test('getStatusInfo uses last_seen as fallback', () => {
|
||||
const recent = new Date().toISOString();
|
||||
const info = gsi({ role: 'repeater', last_seen: recent });
|
||||
assert.strictEqual(info.status, 'active');
|
||||
});
|
||||
|
||||
test('getStatusInfo room uses infrastructure threshold (72h)', () => {
|
||||
const d48h = new Date(Date.now() - 48 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'room', last_heard: d48h });
|
||||
assert.strictEqual(info.status, 'active'); // 48h < 72h threshold
|
||||
});
|
||||
|
||||
test('getStatusInfo room stale at 96h', () => {
|
||||
const d96h = new Date(Date.now() - 96 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'room', last_heard: d96h });
|
||||
assert.strictEqual(info.status, 'stale');
|
||||
});
|
||||
|
||||
test('getStatusInfo sensor stale at 25h', () => {
|
||||
const d25h = new Date(Date.now() - 25 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'sensor', last_heard: d25h });
|
||||
assert.strictEqual(info.status, 'stale');
|
||||
});
|
||||
|
||||
test('getStatusInfo returns explanation for active node', () => {
|
||||
const info = gsi({ role: 'repeater', last_heard: new Date().toISOString() });
|
||||
assert.ok(info.explanation.includes('Last heard'));
|
||||
});
|
||||
|
||||
test('getStatusInfo returns explanation for stale companion', () => {
|
||||
const d48h = new Date(Date.now() - 48 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'companion', last_heard: d48h });
|
||||
assert.ok(info.explanation.includes('companions'));
|
||||
});
|
||||
|
||||
test('getStatusInfo returns explanation for stale repeater', () => {
|
||||
const d96h = new Date(Date.now() - 96 * 3600000).toISOString();
|
||||
const info = gsi({ role: 'repeater', last_heard: d96h });
|
||||
assert.ok(info.explanation.includes('repeaters'));
|
||||
});
|
||||
|
||||
test('getStatusInfo roleColor defaults to gray for unknown role', () => {
|
||||
const info = gsi({ role: 'unknown_role', last_heard: new Date().toISOString() });
|
||||
assert.strictEqual(info.roleColor, '#6b7280');
|
||||
});
|
||||
|
||||
// --- getStatusTooltip edge cases ---
|
||||
test('getStatusTooltip active room mentions 72h', () => {
|
||||
assert.ok(gst('room', 'active').includes('72h'));
|
||||
});
|
||||
|
||||
test('getStatusTooltip stale room mentions offline', () => {
|
||||
assert.ok(gst('room', 'stale').includes('offline'));
|
||||
});
|
||||
|
||||
test('getStatusTooltip active sensor mentions 24h', () => {
|
||||
assert.ok(gst('sensor', 'active').includes('24h'));
|
||||
});
|
||||
|
||||
test('getStatusTooltip stale repeater mentions offline', () => {
|
||||
assert.ok(gst('repeater', 'stale').includes('offline'));
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
+763
@@ -0,0 +1,763 @@
|
||||
/* Unit tests for packets.js functions (tested via VM sandbox) */
|
||||
'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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a browser-like sandbox with all deps packets.js needs
|
||||
function makeSandbox() {
|
||||
const registeredPages = {};
|
||||
const ctx = {
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
innerWidth: 1200,
|
||||
PacketFilter: null,
|
||||
},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
createElement: (tag) => ({
|
||||
tagName: tag.toUpperCase(), id: '', textContent: '', innerHTML: '',
|
||||
className: '', style: {}, appendChild: () => {}, setAttribute: () => {},
|
||||
addEventListener: () => {}, querySelectorAll: () => [], querySelector: () => null,
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
||||
}),
|
||||
head: { appendChild: () => {} },
|
||||
getElementById: () => null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
body: { appendChild: () => {} },
|
||||
},
|
||||
console,
|
||||
Date,
|
||||
Infinity,
|
||||
Math,
|
||||
Array,
|
||||
Object,
|
||||
String,
|
||||
Number,
|
||||
JSON,
|
||||
RegExp,
|
||||
Error,
|
||||
TypeError,
|
||||
RangeError,
|
||||
parseInt,
|
||||
parseFloat,
|
||||
isNaN,
|
||||
isFinite,
|
||||
encodeURIComponent,
|
||||
decodeURIComponent,
|
||||
setTimeout: () => {},
|
||||
clearTimeout: () => {},
|
||||
setInterval: () => {},
|
||||
clearInterval: () => {},
|
||||
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
|
||||
performance: { now: () => Date.now() },
|
||||
localStorage: (() => {
|
||||
const store = {};
|
||||
return {
|
||||
getItem: k => store[k] || null,
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
};
|
||||
})(),
|
||||
location: { hash: '' },
|
||||
history: { replaceState: () => {} },
|
||||
CustomEvent: class CustomEvent {},
|
||||
Map,
|
||||
Set,
|
||||
Promise,
|
||||
URLSearchParams,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
||||
_registeredPages: registeredPages,
|
||||
// Stub global functions packets.js depends on
|
||||
registerPage: (name, handler) => { registeredPages[name] = handler; },
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function loadInCtx(ctx, file) {
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx, { filename: file });
|
||||
for (const k of Object.keys(ctx.window)) {
|
||||
ctx[k] = ctx.window[k];
|
||||
}
|
||||
}
|
||||
|
||||
function loadPacketsSandbox() {
|
||||
const ctx = makeSandbox();
|
||||
// Load dependencies first
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
// HopDisplay stub (simpler than loading real file which may have DOM deps)
|
||||
vm.runInContext(`
|
||||
window.HopDisplay = {
|
||||
renderHop: function(h, entry, opts) {
|
||||
if (entry && entry.name) return '<span class="hop-named">' + entry.name + '</span>';
|
||||
return '<span class="hop-hex">' + h + '</span>';
|
||||
},
|
||||
_showFromBtn: function() {}
|
||||
};
|
||||
`, ctx);
|
||||
loadInCtx(ctx, 'public/packets.js');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ===== TESTS =====
|
||||
|
||||
console.log('\n=== packets.js: typeName ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('typeName returns known type', () => {
|
||||
assert.strictEqual(api.typeName(0), 'Request');
|
||||
assert.strictEqual(api.typeName(4), 'Advert');
|
||||
assert.strictEqual(api.typeName(5), 'Channel Msg');
|
||||
});
|
||||
|
||||
test('typeName returns fallback for unknown', () => {
|
||||
assert.strictEqual(api.typeName(99), 'Type 99');
|
||||
assert.strictEqual(api.typeName(undefined), 'Type undefined');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: obsName ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('obsName returns dash for falsy id', () => {
|
||||
assert.strictEqual(api.obsName(null), '—');
|
||||
assert.strictEqual(api.obsName(''), '—');
|
||||
assert.strictEqual(api.obsName(undefined), '—');
|
||||
});
|
||||
|
||||
test('obsName returns id when not in observerMap', () => {
|
||||
assert.strictEqual(api.obsName('unknown-id'), 'unknown-id');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: kv ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('kv produces correct HTML', () => {
|
||||
const result = api.kv('Route', 'Direct');
|
||||
assert(result.includes('byop-key'));
|
||||
assert(result.includes('Route'));
|
||||
assert(result.includes('Direct'));
|
||||
assert(result.includes('byop-val'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: sectionRow / fieldRow ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('sectionRow produces section HTML', () => {
|
||||
const result = api.sectionRow('Header');
|
||||
assert(result.includes('section-row'));
|
||||
assert(result.includes('Header'));
|
||||
assert(result.includes('colspan="4"'));
|
||||
});
|
||||
|
||||
test('fieldRow produces field HTML', () => {
|
||||
const result = api.fieldRow(0, 'Header Byte', '0xFF', 'some desc');
|
||||
assert(result.includes('0'));
|
||||
assert(result.includes('Header Byte'));
|
||||
assert(result.includes('0xFF'));
|
||||
assert(result.includes('some desc'));
|
||||
assert(result.includes('mono'));
|
||||
});
|
||||
|
||||
test('fieldRow handles empty description', () => {
|
||||
const result = api.fieldRow(5, 'Test', 'val', '');
|
||||
assert(result.includes('text-muted'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: getDetailPreview ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('getDetailPreview returns empty for null/undefined', () => {
|
||||
assert.strictEqual(api.getDetailPreview(null), '');
|
||||
assert.strictEqual(api.getDetailPreview(undefined), '');
|
||||
});
|
||||
|
||||
test('getDetailPreview handles CHAN type', () => {
|
||||
const result = api.getDetailPreview({ type: 'CHAN', text: 'hello world', channel: 'general' });
|
||||
assert(result.includes('💬'));
|
||||
assert(result.includes('hello world'));
|
||||
assert(result.includes('chan-tag'));
|
||||
assert(result.includes('general'));
|
||||
});
|
||||
|
||||
test('getDetailPreview truncates long CHAN text', () => {
|
||||
const longText = 'x'.repeat(100);
|
||||
const result = api.getDetailPreview({ type: 'CHAN', text: longText });
|
||||
assert(result.includes('…'));
|
||||
assert(!result.includes('x'.repeat(100)));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT type', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'TestNode', pubKey: 'abc123',
|
||||
flags: { repeater: true }
|
||||
});
|
||||
assert(result.includes('📡'));
|
||||
assert(result.includes('TestNode'));
|
||||
assert(result.includes('hop-link'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT room', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'RoomNode', pubKey: 'abc',
|
||||
flags: { room: true }
|
||||
});
|
||||
assert(result.includes('🏠'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT sensor', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'Sensor1', pubKey: 'abc',
|
||||
flags: { sensor: true }
|
||||
});
|
||||
assert(result.includes('🌡'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ADVERT companion (default)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ADVERT', name: 'Comp', pubKey: 'abc',
|
||||
flags: {}
|
||||
});
|
||||
assert(result.includes('📻'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_TXT with channelHash (no_key)', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_TXT', channelHash: 0xAB, decryptionStatus: 'no_key'
|
||||
});
|
||||
assert(result.includes('🔒'));
|
||||
assert(result.includes('0xAB'));
|
||||
assert(result.includes('no key'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_TXT decryption_failed', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_TXT', channelHash: 5, decryptionStatus: 'decryption_failed'
|
||||
});
|
||||
assert(result.includes('decryption failed'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles GRP_TXT with channelHashHex', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'GRP_TXT', channelHash: 0xFF, channelHashHex: 'FF'
|
||||
});
|
||||
assert(result.includes('0xFF'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles TXT_MSG', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'TXT_MSG', srcHash: 'abcdef01', destHash: '12345678'
|
||||
});
|
||||
assert(result.includes('✉️'));
|
||||
assert(result.includes('abcdef01'));
|
||||
assert(result.includes('12345678'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles PATH', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'PATH', srcHash: 'aabb', destHash: 'ccdd'
|
||||
});
|
||||
assert(result.includes('🔀'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles REQ', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'REQ', srcHash: 'aa', destHash: 'bb'
|
||||
});
|
||||
assert(result.includes('🔒'));
|
||||
assert(result.includes('aa'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles RESPONSE', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'RESPONSE', srcHash: 'aa', destHash: 'bb'
|
||||
});
|
||||
assert(result.includes('🔒'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles ANON_REQ', () => {
|
||||
const result = api.getDetailPreview({
|
||||
type: 'ANON_REQ', destHash: 'dd'
|
||||
});
|
||||
assert(result.includes('anon'));
|
||||
assert(result.includes('dd'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles text fallback', () => {
|
||||
const result = api.getDetailPreview({ text: 'some message' });
|
||||
assert(result.includes('some message'));
|
||||
});
|
||||
|
||||
test('getDetailPreview truncates long text fallback', () => {
|
||||
const result = api.getDetailPreview({ text: 'z'.repeat(100) });
|
||||
assert(result.includes('…'));
|
||||
});
|
||||
|
||||
test('getDetailPreview handles public_key fallback', () => {
|
||||
const result = api.getDetailPreview({ public_key: 'abcdef1234567890abcdef' });
|
||||
assert(result.includes('📡'));
|
||||
assert(result.includes('abcdef1234567890'));
|
||||
});
|
||||
|
||||
test('getDetailPreview returns empty for empty decoded', () => {
|
||||
assert.strictEqual(api.getDetailPreview({}), '');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: getPathHopCount ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('getPathHopCount with valid path', () => {
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: '["a","b","c"]' }), 3);
|
||||
});
|
||||
|
||||
test('getPathHopCount with empty path', () => {
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: '[]' }), 0);
|
||||
});
|
||||
|
||||
test('getPathHopCount with null/missing', () => {
|
||||
assert.strictEqual(api.getPathHopCount({}), 0);
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: null }), 0);
|
||||
});
|
||||
|
||||
test('getPathHopCount with invalid JSON', () => {
|
||||
assert.strictEqual(api.getPathHopCount({ path_json: 'not json' }), 0);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: sortGroupChildren ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('sortGroupChildren handles null/empty gracefully', () => {
|
||||
api.sortGroupChildren(null);
|
||||
api.sortGroupChildren({});
|
||||
api.sortGroupChildren({ _children: [] });
|
||||
// No throw
|
||||
});
|
||||
|
||||
test('sortGroupChildren default sort groups by observer earliest-first', () => {
|
||||
// Need to set obsSortMode — it reads from closure. Default is 'observer'.
|
||||
const group = {
|
||||
_children: [
|
||||
{ observer_name: 'B', timestamp: '2024-01-01T02:00:00Z' },
|
||||
{ observer_name: 'A', timestamp: '2024-01-01T01:00:00Z' },
|
||||
{ observer_name: 'B', timestamp: '2024-01-01T01:30:00Z' },
|
||||
]
|
||||
};
|
||||
api.sortGroupChildren(group);
|
||||
// A has earliest timestamp, should be first
|
||||
assert.strictEqual(group._children[0].observer_name, 'A');
|
||||
// Then B entries
|
||||
assert.strictEqual(group._children[1].observer_name, 'B');
|
||||
assert.strictEqual(group._children[2].observer_name, 'B');
|
||||
// B entries should be time-ascending within group
|
||||
assert(group._children[1].timestamp < group._children[2].timestamp);
|
||||
});
|
||||
|
||||
test('sortGroupChildren updates header from first child', () => {
|
||||
const group = {
|
||||
observer_id: 'old',
|
||||
_children: [
|
||||
{ observer_name: 'A', observer_id: 'new-id', timestamp: '2024-01-01T01:00:00Z', snr: 10, rssi: -50, path_json: '["x"]', direction: 'rx' },
|
||||
]
|
||||
};
|
||||
api.sortGroupChildren(group);
|
||||
assert.strictEqual(group.observer_id, 'new-id');
|
||||
assert.strictEqual(group.snr, 10);
|
||||
assert.strictEqual(group.rssi, -50);
|
||||
assert.strictEqual(group.path_json, '["x"]');
|
||||
assert.strictEqual(group.direction, 'rx');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: renderTimestampCell ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('renderTimestampCell produces HTML with timestamp-text', () => {
|
||||
const result = api.renderTimestampCell('2024-01-15T10:30:00Z');
|
||||
assert(result.includes('timestamp-text'));
|
||||
});
|
||||
|
||||
test('renderTimestampCell handles null gracefully', () => {
|
||||
const result = api.renderTimestampCell(null);
|
||||
// Should not throw, produces some output
|
||||
assert(typeof result === 'string');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: renderPath ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('renderPath returns dash for empty/null', () => {
|
||||
assert.strictEqual(api.renderPath(null, null), '—');
|
||||
assert.strictEqual(api.renderPath([], null), '—');
|
||||
});
|
||||
|
||||
test('renderPath renders hops with arrows', () => {
|
||||
const result = api.renderPath(['aa', 'bb'], null);
|
||||
assert(result.includes('arrow'));
|
||||
assert(result.includes('aa'));
|
||||
assert(result.includes('bb'));
|
||||
});
|
||||
|
||||
test('renderPath renders single hop without arrow', () => {
|
||||
const result = api.renderPath(['cc'], null);
|
||||
assert(result.includes('cc'));
|
||||
assert(!result.includes('arrow'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: renderDecodedPacket ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('renderDecodedPacket produces header section', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 4, payloadVersion: 1 },
|
||||
payload: { name: 'TestNode' },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aabbccdd';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('byop-decoded'));
|
||||
assert(result.includes('Header'));
|
||||
assert(result.includes('4 bytes'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders path hops', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 4 },
|
||||
payload: {},
|
||||
path: { hops: ['aa', 'bb'] }
|
||||
};
|
||||
const hex = 'aabbccdd';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('Path (2 hops)'));
|
||||
assert(result.includes('aa'));
|
||||
assert(result.includes('bb'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders payload fields', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 5 },
|
||||
payload: { channel: 'general', text: 'hello' },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aabb';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('channel'));
|
||||
assert(result.includes('general'));
|
||||
assert(result.includes('hello'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders nested objects as JSON', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 0 },
|
||||
payload: { flags: { repeater: true } },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aa';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('byop-pre'));
|
||||
assert(result.includes('repeater'));
|
||||
});
|
||||
|
||||
test('renderDecodedPacket skips null payload values', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 0 },
|
||||
payload: { a: null, b: undefined, c: 'visible' },
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aa';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('visible'));
|
||||
// null/undefined values should be skipped
|
||||
const kvCount = (result.match(/byop-row/g) || []).length;
|
||||
// Only 'c' should appear in payload (a and b are null/undefined), plus header fields
|
||||
assert(kvCount >= 1);
|
||||
});
|
||||
|
||||
test('renderDecodedPacket renders raw hex', () => {
|
||||
const decoded = {
|
||||
header: { routeType: 0, payloadType: 0 },
|
||||
payload: {},
|
||||
path: { hops: [] }
|
||||
};
|
||||
const hex = 'aabbcc';
|
||||
const result = api.renderDecodedPacket(decoded, hex);
|
||||
assert(result.includes('AA BB CC'));
|
||||
assert(result.includes('byop-hex'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildFieldTable ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('buildFieldTable produces table HTML', () => {
|
||||
const pkt = { raw_hex: 'c0400102', route_type: 1, payload_type: 4 };
|
||||
const decoded = { type: 'ADVERT', name: 'Node', pubKey: 'abc', flags: { type: 2, hasLocation: false, hasName: true, raw: 0x22 } };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('field-table'));
|
||||
assert(result.includes('Header'));
|
||||
assert(result.includes('Header Byte'));
|
||||
assert(result.includes('Path Length'));
|
||||
});
|
||||
|
||||
test('buildFieldTable handles transport codes (route_type 0)', () => {
|
||||
const pkt = { raw_hex: 'c0400102030405060708', route_type: 0, payload_type: 0 };
|
||||
const decoded = { destHash: 'aa', srcHash: 'bb', mac: 'cc', encryptedData: 'dd' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Transport Codes'));
|
||||
assert(result.includes('Next Hop'));
|
||||
assert(result.includes('Last Hop'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders path hops', () => {
|
||||
const pkt = { raw_hex: 'c042aabb', route_type: 1, payload_type: 0 };
|
||||
const decoded = { destHash: 'xx' };
|
||||
const result = api.buildFieldTable(pkt, decoded, ['aa', 'bb'], []);
|
||||
assert(result.includes('Path (2 hops)'));
|
||||
assert(result.includes('Hop 0'));
|
||||
assert(result.includes('Hop 1'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders ADVERT payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 4 };
|
||||
const decoded = {
|
||||
type: 'ADVERT', pubKey: 'abc123', timestamp: 1234567890,
|
||||
timestampISO: '2009-02-13T23:31:30Z', signature: 'sig',
|
||||
name: 'TestNode',
|
||||
flags: { type: 1, hasLocation: true, hasName: true, raw: 0x55 }
|
||||
};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Public Key'));
|
||||
assert(result.includes('Timestamp'));
|
||||
assert(result.includes('Signature'));
|
||||
assert(result.includes('App Flags'));
|
||||
assert(result.includes('Companion'));
|
||||
assert(result.includes('Latitude'));
|
||||
assert(result.includes('Node Name'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders GRP_TXT payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 5 };
|
||||
const decoded = { type: 'GRP_TXT', channelHash: 0xAB, mac: 'AABB', encryptedData: 'data', decryptionStatus: 'no_key' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Channel Hash'));
|
||||
assert(result.includes('MAC'));
|
||||
assert(result.includes('Encrypted Data'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders CHAN payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 5 };
|
||||
const decoded = { type: 'CHAN', channel: 'general', sender: 'Alice', sender_timestamp: '12:00' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Channel'));
|
||||
assert(result.includes('general'));
|
||||
assert(result.includes('Sender'));
|
||||
assert(result.includes('Sender Time'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders ACK payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 3 };
|
||||
const decoded = { type: 'ACK', ackChecksum: 'DEADBEEF' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Checksum'));
|
||||
assert(result.includes('DEADBEEF'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders destHash-based payload', () => {
|
||||
const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 2 };
|
||||
const decoded = { destHash: 'DD', srcHash: 'SS', mac: 'MM', encryptedData: 'EE' };
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Dest Hash'));
|
||||
assert(result.includes('Src Hash'));
|
||||
});
|
||||
|
||||
test('buildFieldTable renders raw fallback for unknown payload', () => {
|
||||
const pkt = { raw_hex: 'c040aabbccdd', route_type: 1, payload_type: 99 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('Raw'));
|
||||
});
|
||||
|
||||
test('buildFieldTable hash_size calculation', () => {
|
||||
// Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4
|
||||
const pkt = { raw_hex: '00C0', route_type: 1, payload_type: 0 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('hash_size=4'));
|
||||
});
|
||||
|
||||
test('buildFieldTable handles empty raw_hex', () => {
|
||||
const pkt = { raw_hex: '', route_type: 1, payload_type: 0 };
|
||||
const decoded = {};
|
||||
const result = api.buildFieldTable(pkt, decoded, [], []);
|
||||
assert(result.includes('field-table'));
|
||||
assert(result.includes('0B') || result.includes('0 bytes') || result.includes('??'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: _getRowCount ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('_getRowCount returns 1 for ungrouped', () => {
|
||||
// _displayGrouped is internal, but when not grouped, should return 1
|
||||
// Since we can't easily control _displayGrouped, test the function behavior
|
||||
const result = api._getRowCount({ hash: 'abc', _children: [{ observer_id: '1' }] });
|
||||
// Default _displayGrouped depends on initialization, but the function should not throw
|
||||
assert(typeof result === 'number');
|
||||
assert(result >= 1);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildFlatRowHtml ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('buildFlatRowHtml produces table row', () => {
|
||||
const p = {
|
||||
id: 1, hash: 'abc123', timestamp: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aabb', payload_type: 4,
|
||||
route_type: 1, decoded_json: '{}', path_json: '[]'
|
||||
};
|
||||
const result = api.buildFlatRowHtml(p);
|
||||
assert(result.includes('<tr'));
|
||||
assert(result.includes('data-id="1"'));
|
||||
assert(result.includes('data-hash="abc123"'));
|
||||
});
|
||||
|
||||
test('buildFlatRowHtml calculates size from hex', () => {
|
||||
const p = {
|
||||
id: 2, hash: 'x', timestamp: '', observer_id: null,
|
||||
raw_hex: 'aabbccdd', payload_type: 0, route_type: 0,
|
||||
decoded_json: '{}', path_json: '[]'
|
||||
};
|
||||
const result = api.buildFlatRowHtml(p);
|
||||
assert(result.includes('4B')); // 8 hex chars = 4 bytes
|
||||
});
|
||||
|
||||
test('buildFlatRowHtml handles missing raw_hex', () => {
|
||||
const p = {
|
||||
id: 3, hash: 'y', timestamp: '', observer_id: null,
|
||||
raw_hex: null, payload_type: 0, route_type: 0,
|
||||
decoded_json: '{}', path_json: '[]'
|
||||
};
|
||||
const result = api.buildFlatRowHtml(p);
|
||||
assert(result.includes('0B'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: buildGroupRowHtml ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
const api = ctx._packetsTestAPI;
|
||||
|
||||
test('buildGroupRowHtml renders single-count group', () => {
|
||||
const p = {
|
||||
hash: 'abc', count: 1, latest: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aabb', payload_type: 4,
|
||||
route_type: 1, decoded_json: '{}', path_json: '[]',
|
||||
observation_count: 1, observer_count: 1
|
||||
};
|
||||
const result = api.buildGroupRowHtml(p);
|
||||
assert(result.includes('<tr'));
|
||||
assert(result.includes('data-hash="abc"'));
|
||||
// Single count: no expand arrow, no group-header class
|
||||
assert(!result.includes('group-header'));
|
||||
});
|
||||
|
||||
test('buildGroupRowHtml renders multi-count group with expand arrow', () => {
|
||||
const p = {
|
||||
hash: 'xyz', count: 3, latest: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aabbcc', payload_type: 0,
|
||||
route_type: 0, decoded_json: '{}', path_json: '[]',
|
||||
observation_count: 3, observer_count: 2
|
||||
};
|
||||
const result = api.buildGroupRowHtml(p);
|
||||
assert(result.includes('group-header'));
|
||||
assert(result.includes('▶')); // collapsed arrow
|
||||
});
|
||||
|
||||
test('buildGroupRowHtml shows observation count badge', () => {
|
||||
const p = {
|
||||
hash: 'obs', count: 1, latest: '2024-01-01T00:00:00Z',
|
||||
observer_id: null, raw_hex: 'aa', payload_type: 0,
|
||||
route_type: 0, decoded_json: '{}', path_json: '[]',
|
||||
observation_count: 5, observer_count: 1
|
||||
};
|
||||
const result = api.buildGroupRowHtml(p);
|
||||
assert(result.includes('badge-obs'));
|
||||
assert(result.includes('👁'));
|
||||
assert(result.includes('5'));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== packets.js: page registration ===');
|
||||
{
|
||||
const ctx = loadPacketsSandbox();
|
||||
// registerPage is defined in app.js and stores in its own `pages` closure.
|
||||
// We verify via the navigateTo mechanism or by checking the pages object isn't empty.
|
||||
// Since we can't easily access the closure, just verify the test API is exposed.
|
||||
test('_packetsTestAPI is exposed on window', () => {
|
||||
assert(ctx._packetsTestAPI);
|
||||
assert(typeof ctx._packetsTestAPI.typeName === 'function');
|
||||
assert(typeof ctx._packetsTestAPI.getDetailPreview === 'function');
|
||||
assert(typeof ctx._packetsTestAPI.sortGroupChildren === 'function');
|
||||
assert(typeof ctx._packetsTestAPI.buildFieldTable === 'function');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SUMMARY =====
|
||||
console.log(`\n${'='.repeat(40)}`);
|
||||
console.log(`packets.js tests: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user