Compare commits

...

10 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
Kpa-clawbot 016b87b33c test: add 64 unit tests for packets.js (Part of #344) (#488)
## Summary

Adds 64 unit tests for `packets.js` — the largest untested frontend file
(2000+ lines) covering filter engine integration, time window logic,
groupByHash rendering, and packet detail display.

Part of #344 — packets.js coverage.

## Approach

Follows the existing `test-frontend-helpers.js` pattern: loads real
source files into a `vm.createContext` sandbox and tests actual code (no
copies).

Added a `window._packetsTestAPI` export at the end of the packets.js
IIFE to expose pure functions for testing without changing any runtime
behavior.

## What's Tested

| Function | Tests | What it covers |
|----------|-------|----------------|
| `typeName` | 2 | Type code → name mapping, unknown fallback |
| `obsName` | 2 | Observer name lookup, falsy/missing handling |
| `kv` | 1 | Key-value HTML helper |
| `sectionRow` / `fieldRow` | 3 | Table section/field HTML builders |
| `getDetailPreview` | 17 | All packet types: CHAN, ADVERT
(repeater/room/sensor/companion), GRP_TXT
(no_key/decryption_failed/channelHashHex), TXT_MSG, PATH, REQ, RESPONSE,
ANON_REQ, text fallback, public_key fallback, empty |
| `getPathHopCount` | 4 | Valid path, empty, null, invalid JSON |
| `sortGroupChildren` | 3 | Default observer sort, header update, null
safety |
| `renderTimestampCell` | 2 | Timestamp HTML output, null handling |
| `renderPath` | 3 | Empty/null, multi-hop with arrows, single hop |
| `renderDecodedPacket` | 6 | Header/path/payload/nested objects/null
skip/raw hex |
| `buildFieldTable` | 11 | All payload types (ADVERT with
flags/location/name, GRP_TXT, CHAN, ACK, destHash, raw fallback),
transport codes, path hops, hash_size calculation, empty hex |
| `_getRowCount` | 1 | Virtual scroll row counting |
| `buildFlatRowHtml` | 3 | Row rendering, size calculation, missing hex
|
| `buildGroupRowHtml` | 3 | Single/multi group, observation badge |
| Test API exposure | 1 | Verifies window._packetsTestAPI |

## Constraints Met

- No new test dependencies
- Tests real code via `vm.createContext`, not copies
- No build step — vanilla JS
- All existing tests still pass (254 frontend-helpers, 62 packet-filter,
29 aging)

Co-authored-by: you <you@example.com>
2026-04-02 16:42:25 -07:00
Kpa-clawbot 889107a5e1 fix: address PR #487 review feedback (#501)
## Summary

Addresses review feedback from PR #487 (nodes.js coverage).

### Changes

1. **Replace fragile `exportInternals` regex source patching with stable
test hooks** — `getStatusInfo` and `getStatusTooltip` are now exposed
via `window._nodesGetStatusInfo` and `window._nodesGetStatusTooltip`,
matching the existing pattern used by all other test-accessible
functions. The brittle regex `.replace()` approach that modified source
code at runtime has been removed entirely.

2. **Strengthen weak null assertion** — The `renderNodeTimestampHtml
handles null` test previously asserted `html.includes('—') ||
html.length > 0`, which is a near-tautology (any non-empty string
passes). Now strictly asserts `html.includes('—')`.

### Files changed
- `public/nodes.js` — 2 new test hook lines
- `test-frontend-helpers.js` — removed 21-line `exportInternals` branch,
updated tests to use hooks

### Testing
- All 309 frontend helper tests pass
- All 62 packet filter tests pass
- All 29 aging tests pass

Closes review items from #487.

Co-authored-by: you <you@example.com>
2026-04-02 16:40:11 -07:00
Kpa-clawbot 50f94603c1 test: P0 coverage for nodes.js — sort, status, timestamps, sync (#487)
## Summary

Add 67 new unit tests for `nodes.js`, raising frontend helper test count
from 233 to 300.

Part of #344 — nodes.js coverage.

## What's Tested

### Sort System (`toggleSort`, `sortNodes`, `sortArrow`)
- Direction toggling on same column (asc↔desc)
- Default sort directions per column type (name→asc, last_seen→desc,
advert_count→desc)
- localStorage persistence of sort state
- All 5 sort columns: `name`, `public_key`, `role`, `last_seen`,
`advert_count`
- Both ascending and descending for each column
- Case-insensitive name sorting
- Unnamed nodes sort last
- Timestamp fallback chain: `last_heard` → `last_seen` → 0
- Missing timestamp handling
- Empty array edge case
- Unknown column graceful handling
- `sortArrow` rendering for active (▲/▼) and inactive columns

### Status Calculation (`getStatusInfo`, `getStatusTooltip`)
- `_lastHeard` takes priority over `last_heard`
- `last_seen` used as fallback when `last_heard` missing
- No-timestamp nodes return stale with `lastHeardMs: 0`
- Infrastructure threshold (72h) for rooms
- Standard threshold (24h) for sensors and companions
- Explanation text varies by role and status
- Unknown role defaults to gray color `#6b7280`
- All role/status tooltip combinations

### Timestamp Rendering (`renderNodeTimestampHtml`,
`renderNodeTimestampText`)
- HTML output includes tooltip and `timestamp-text` class
- Future timestamps show ⚠️ warning icon
- Null input produces dash
- Text output is plain (no HTML tags)

### Favorites Sync (`syncClaimedToFavorites`)
- Claimed pubkeys added to favorites
- No-op when all already synced
- Empty my-nodes handled
- Missing localStorage keys don't crash

## Implementation

- Added test hooks on `window` for closure-scoped functions
(non-invasive, follows existing pattern)
- Tests use `vm.createContext` to load real `nodes.js` code — no copies
- No new dependencies

## Test Results

```
Frontend helpers: 300 passed, 0 failed
```

---------

Co-authored-by: you <you@example.com>
2026-04-02 23:32:41 +00:00
efiten b799f54700 perf: bound memory growth and reduce render CPU on packets page (#421)
## Problem

On a long-running session the packets page consumed 8 GB of browser
memory and 20%+ CPU on an 8-core machine. Root causes:

1. **Unbounded `packets` array growth via WebSocket** —
`packets.unshift()` was called for every new unique hash, but nothing
ever trimmed the array. After hours of live traffic the array grew well
past the initial 50 k load limit.
2. **Unbounded `pauseBuffer`** — all WS messages queued while paused, no
cap.
3. **Unbounded `_children` growth** — expanded groups received a
`unshift(p)` on every matching WS message with no size limit.
4. **O(n) `observers.find()` inside the O(n) render loop** — with 50 k
rows, each render triggered up to 50 k linear scans through the
observers list.
5. **Full DOM rebuild on every WS message** — `renderTableRows()` was
called synchronously on every WebSocket batch, reconstructing the entire
table on each incoming packet.

## Changes

- `packets[]` is now trimmed to `PACKET_LIMIT` after each WS batch;
evicted entries are also removed from `hashIndex` to prevent stale
references.
- `pauseBuffer` capped at 2 000 entries (oldest dropped).
- `_children` capped at 200 entries on WS prepend.
- `renderTableRows()` on the WS path is debounced to 200 ms, batching
rapid updates into a single redraw.
- `observersById = new Map()` pre-built from the observers array; all
`observers.find()` calls in the render loop and WS filter replaced with
O(1) `Map.get()`.

## Test plan

- [x] Load the packets page and leave it running for several minutes
with live WebSocket traffic — memory in DevTools should remain stable
rather than growing continuously
- [x] Pause live updates, wait for several messages, then resume —
buffer replays correctly and display updates
- [x] Expand a packet group and leave it open during live traffic —
children update but don't grow past 200
- [x] Region filter still works correctly (relies on the observer Map
lookup)
- [x] Observer name / IATA badge renders correctly in grouped and flat
mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-04-02 16:04:01 -07:00
Kpa-clawbot d5b300a8ba fix: derive version from git tags instead of package.json (#486)
## Summary

Fixes #485 — the app version was derived from `package.json` via
Node.js, which is a meaningless artifact for this Go project. This
caused version mismatches (e.g., v3.3.0 release showing "3.2.0") when
someone forgot to bump `package.json`.

## Changes

### `manage.sh`
- **Line 43**: Replace `node -p "require('./package.json').version"`
with `git describe --tags --match "v*"` — version is now derived
automatically from git tags
- **Line 515**: Add `--force` to `git fetch origin --tags` in setup
command
- **Line 1320**: Add `--force` to `git fetch origin --tags` in update
command — prevents "would clobber existing tag" errors when tags are
moved

### `package.json`
- Version field set to `0.0.0-use-git-tags` to make it clear this is not
the source of truth. File kept because npm scripts and devDependencies
are still used for testing.

## How it works

`git describe --tags --match "v*"` produces:
- `v3.3.0` — when on an exact tag
- `v3.3.0-3-gabcdef1` — when 3 commits after a tag (useful for
debugging)
- Falls back to `unknown` if no tags exist

## Testing

- All Go tests pass (`cmd/server`, `cmd/ingestor`)
- All frontend unit tests pass (254/254)
- No changes to application logic — only build-time version derivation

Co-authored-by: you <you@example.com>
2026-04-02 00:53:38 -07:00
13 changed files with 1872 additions and 42 deletions
+157 -28
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,
@@ -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)
+321
View File
@@ -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
+2 -1
View 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
View File
@@ -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"`
}
+3 -3
View File
@@ -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
View File
@@ -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": {
+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 ── */
+10
View File
@@ -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
View File
@@ -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;
+16
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,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 {
+45
View File
@@ -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
+505
View File
@@ -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
View File
@@ -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);