Merge areas-meshguide-sync into master: ping-bot reply + view path

Adds a CoreScope-only "ping" bot to the Channels view: a channel message
matching a trigger word ("ping" or "/ping") gets a synthesized "pong"
reply showing hop count, relay path (repeater names, resolved once per
page for every ping on it), region scope, area, SNR, and how many
distinct observers heard it (reporting the deepest/farthest-along
observation across all of them, not just the first). Deliberately never
transmitted back onto the mesh -- CoreScope has no publish path to a
MeshCore broker/radio -- and clearly marked "Not sent to the mesh" so
it's never mistaken for a real bot reply.

A new general-purpose GET /api/packets/{hash}/path endpoint resolves any
packet's relay path geographically (node lat/lon + observer position),
backing a "View path" map link on the reply.

Also includes: MeshCore ADVERT Feat1/Feat2 capability-byte persistence
was added and then reverted after confirming live traffic never sets
those flags (columns remain in the schema, unused).
This commit is contained in:
dborup
2026-07-23 16:02:52 +02:00
11 changed files with 1334 additions and 24 deletions
+356 -18
View File
@@ -1517,6 +1517,170 @@ func (db *DB) GetTraces(hash string) ([]map[string]interface{}, error) {
return traces, nil
}
// PacketPathPoint is one hop's position along a packet's resolved relay
// path, for map visualization (public/packet-path-map.js). Lat/Lon are
// nil when that node has never advertised a GPS position -- the caller
// draws a gap rather than guessing.
type PacketPathPoint struct {
PublicKey string `json:"publicKey"`
Name string `json:"name"`
Role string `json:"role,omitempty"`
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
}
// PacketPathObserver is the station that produced the deepest observation
// of a packet path (see GetPacketPath), positioned from its configured
// IATA code the same way the Wardriving tab positions observers -- not a
// stored per-observer lat/lon column.
type PacketPathObserver struct {
Name string `json:"name"`
IATA string `json:"iata,omitempty"`
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
}
// PacketPathResponse is the geographic relay path for one packet hash,
// used to draw it on a map (the ping-bot reply's "View path" link).
type PacketPathResponse struct {
Hash string `json:"hash"`
Hops int `json:"hops"`
Points []PacketPathPoint `json:"points"`
Observer *PacketPathObserver `json:"observer,omitempty"`
}
// GetPacketPath resolves a packet's DEEPEST observation (the one with the
// most hops -- same "farthest leg" reasoning as the ping-bot reply, see
// pingBotReply's doc comment) to a geographic point sequence: each
// relay's name/role/lat/lon in path order, plus the hearing observer's
// position. A packet can have several observations (heard by more than
// one station, possibly at different hop depths); this always picks the
// one that traveled farthest, since that's the more informative path to
// show on a map.
func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) {
if !db.hasResolvedPath {
return nil, fmt.Errorf("resolved_path not available on this server")
}
var querySQL string
if db.isV3 {
querySQL = `SELECT obs.name, obs.iata, o.resolved_path
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''`
} else {
querySQL = `SELECT o.observer_name, NULL, o.resolved_path
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
WHERE t.hash = ? AND o.resolved_path IS NOT NULL AND o.resolved_path != ''`
}
rows, err := db.conn.Query(querySQL, strings.ToLower(hash))
if err != nil {
return nil, fmt.Errorf("packet path query: %w", err)
}
defer rows.Close()
var bestPath []*string
var bestObserverName, bestObserverIATA sql.NullString
for rows.Next() {
var obsName, obsIATA, rpJSON sql.NullString
if err := rows.Scan(&obsName, &obsIATA, &rpJSON); err != nil {
continue
}
if !rpJSON.Valid {
continue
}
rp := unmarshalResolvedPath(rpJSON.String)
if len(rp) > len(bestPath) {
bestPath = rp
bestObserverName, bestObserverIATA = obsName, obsIATA
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("packet path iteration: %w", err)
}
resp := &PacketPathResponse{Hash: hash, Hops: len(bestPath), Points: []PacketPathPoint{}}
if len(bestPath) == 0 {
return resp, nil
}
pubkeys := make([]string, 0, len(bestPath))
for _, pk := range bestPath {
if pk != nil && *pk != "" {
pubkeys = append(pubkeys, *pk)
}
}
type nodeInfo struct {
name string
role string
lat *float64
lon *float64
}
nodeByPK := make(map[string]nodeInfo, len(pubkeys))
if len(pubkeys) > 0 {
placeholders := make([]byte, 0, len(pubkeys)*2)
args := make([]interface{}, len(pubkeys))
for i, pk := range pubkeys {
if i > 0 {
placeholders = append(placeholders, ',')
}
placeholders = append(placeholders, '?')
args[i] = pk
}
nodeRows, err := db.conn.Query(
"SELECT public_key, name, role, lat, lon FROM nodes WHERE public_key IN ("+string(placeholders)+")", args...)
if err == nil {
for nodeRows.Next() {
var pk string
var name, role sql.NullString
var lat, lon sql.NullFloat64
if nodeRows.Scan(&pk, &name, &role, &lat, &lon) == nil {
ni := nodeInfo{name: name.String, role: role.String}
if lat.Valid {
v := lat.Float64
ni.lat = &v
}
if lon.Valid {
v := lon.Float64
ni.lon = &v
}
nodeByPK[pk] = ni
}
}
nodeRows.Close()
}
}
for _, pk := range bestPath {
if pk == nil || *pk == "" {
continue
}
ni := nodeByPK[*pk]
name := ni.name
if name == "" {
name = *pk
}
resp.Points = append(resp.Points, PacketPathPoint{
PublicKey: *pk, Name: name, Role: ni.role, Lat: ni.lat, Lon: ni.lon,
})
}
if bestObserverName.Valid && bestObserverName.String != "" {
obs := &PacketPathObserver{Name: bestObserverName.String}
if bestObserverIATA.Valid {
obs.IATA = strings.ToUpper(strings.TrimSpace(bestObserverIATA.String))
if coord, ok := iataCoords[obs.IATA]; ok {
lat, lon := coord.Lat, coord.Lon
obs.Lat, obs.Lon = &lat, &lon
}
}
resp.Observer = obs
}
return resp, nil
}
// GetChannels returns channel list from GRP_TXT packets.
// Queries transmissions directly (not a VIEW) to avoid observation-level
// duplicates that could cause stale lastMessage when an older message has
@@ -1749,6 +1913,72 @@ func (db *DB) GetEncryptedChannels(region ...string) ([]map[string]interface{},
// This avoids loading every observation row for a channel into Go memory
// before paginating (issue #1225: 5703 tx × ~50 obs ≈ 275K rows → ~30s
// for limit=50).
// channelMentionPrefixRe strips a leading "@target " reply-address the
// same way the frontend does (public/channels.js replyMatch) before
// matching the ping trigger, so "@CoreScopeBot ping" triggers the same as
// a bare "ping".
var channelMentionPrefixRe = regexp.MustCompile(`^@[A-Za-z0-9_-]{1,32}\s+`)
// pingTriggerWords are the exact (case-insensitive) message bodies that
// trigger a pong reply. Mirrored by pingTriggerWords in
// public/channels.js -- keep both lists in sync by hand.
var pingTriggerWords = map[string]bool{
"ping": true,
"/ping": true,
}
// isPingTrigger reports whether displayText, after stripping a leading
// "@target " mention the same way the frontend does (public/channels.js
// replyMatch), exactly matches one of pingTriggerWords.
func isPingTrigger(displayText string) bool {
trigger := strings.TrimSpace(displayText)
trigger = channelMentionPrefixRe.ReplaceAllString(trigger, "")
return pingTriggerWords[strings.ToLower(strings.TrimSpace(trigger))]
}
// pingBotReply synthesizes a "pong" reply for a channel message whose
// text matched isPingTrigger — CoreScope-side only, never transmitted
// back onto the mesh (CoreScope has no publish path to a MeshCore
// broker/radio). Purely a read-time annotation over data this message's
// own row already carries (hop count + relay path, SNR, hearing
// observer, region scope), not a persisted message.
//
// repeaterNames is the resolved relay path in hop order (element i is
// hop i's node name, falling back to its pubkey/hash-prefix when a name
// couldn't be resolved); nil/empty when hops == 0 or resolution wasn't
// available -- the hop count itself is unaffected either way.
func pingBotReply(hops int, snr sql.NullFloat64, observer, scope string, repeaterNames []string) map[string]interface{} {
parts := make([]string, 0, 4)
if hops > 0 {
s := "s"
if hops == 1 {
s = ""
}
hopDesc := fmt.Sprintf("%d hop%s", hops, s)
if len(repeaterNames) > 0 {
hopDesc += " (via " + strings.Join(repeaterNames, " → ") + ")"
}
parts = append(parts, hopDesc)
} else {
parts = append(parts, "0 hops (direct)")
}
if snr.Valid {
parts = append(parts, fmt.Sprintf("SNR %.1fdB", snr.Float64))
}
if observer != "" {
parts = append(parts, "heard by "+observer)
}
if scope != "" {
parts = append(parts, "scope "+scope)
}
return map[string]interface{}{
"sender": "CoreScopeBot",
"text": "🏓 pong! " + strings.Join(parts, " · "),
"hops": hops,
"snr": nullFloat(snr),
}
}
func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region ...string) ([]map[string]interface{}, int, error) {
if limit <= 0 {
limit = 100
@@ -1870,10 +2100,17 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
if db.hasScopeName {
scopeCol = ", t.scope_name"
}
// resolvedPathCol feeds the ping-bot reply's "via RepeaterA → RepeaterB"
// hop names (see the bulk-resolve pass below) -- optional like
// scopeCol since not every DB/test fixture has this column.
resolvedPathCol := ""
if db.hasResolvedPath {
resolvedPathCol = ", o.resolved_path"
}
var obsSQL string
if db.isV3 {
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
obs.id, obs.name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + `
obs.id, obs.name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + resolvedPathCol + `
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
@@ -1881,7 +2118,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
ORDER BY o.id ASC`
} else {
obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen,
o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + `
o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp, t.route_type` + scopeCol + resolvedPathCol + `
FROM observations o
JOIN transmissions t ON t.id = o.transmission_id
WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `)
@@ -1901,9 +2138,26 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
}
msgMap := make(map[int]*msg, len(pageIDs))
// pendingPing collects a ping-triggering message's REACH across every
// observation of it, not just the first: hops/snr/resolvedPath track
// the DEEPEST (max-hop) observation seen so far -- how far the packet
// had propagated before the farthest-along observer heard it -- and
// observers is every distinct observer that heard it at all (breadth).
// A single arbitrary "first observation wins" data point understates
// both: two observers can hear the same flood at very different hop
// depths depending on which relay leg reached them.
type pendingPing struct {
hops int
snr sql.NullFloat64
resolvedPath []*string
observers map[string]bool
scope string
}
pendingPings := make(map[int]*pendingPing)
for rows.Next() {
var pktID, txID int
var pktHash, dj, fs, obsID, obsName, pathJSON sql.NullString
var pktHash, dj, fs, obsID, obsName, pathJSON, resolvedPathJSON sql.NullString
var snr sql.NullFloat64
var obsTs sql.NullInt64
var routeType sql.NullInt64
@@ -1912,17 +2166,54 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
if db.hasScopeName {
scanArgs = append(scanArgs, &scopeName)
}
if db.hasResolvedPath {
scanArgs = append(scanArgs, &resolvedPathJSON)
}
if err := rows.Scan(scanArgs...); err != nil {
return nil, 0, err
}
if !dj.Valid {
continue
}
// Hop count, relay path, and hearing station for THIS observation
// row -- computed for every row (not just the first) so a ping's
// reach can be tracked across every station that heard it.
var hops int
var entryPrefix string
if pathJSON.Valid {
var h []string
if json.Unmarshal([]byte(pathJSON.String), &h) == nil {
hops = len(h)
if len(h) > 0 {
entryPrefix = h[0]
}
}
}
var resolvedPath []*string
if resolvedPathJSON.Valid {
resolvedPath = unmarshalResolvedPath(resolvedPathJSON.String)
}
observerName := ""
if obsName.Valid {
observerName = obsName.String
} else if obsID.Valid {
observerName = obsID.String
}
if existing, ok := msgMap[txID]; ok {
existing.Repeats++
if obsTs.Valid && obsTs.Int64 > existing.LatestEpoch {
existing.LatestEpoch = obsTs.Int64
}
if agg, ok := pendingPings[txID]; ok {
if observerName != "" {
agg.observers[observerName] = true
}
if hops > agg.hops {
agg.hops, agg.snr, agg.resolvedPath = hops, snr, resolvedPath
}
}
continue
}
var decoded map[string]interface{}
@@ -1944,17 +2235,6 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
displayText = text[idx+2:]
}
}
var hops int
var entryPrefix string
if pathJSON.Valid {
var h []string
if json.Unmarshal([]byte(pathJSON.String), &h) == nil {
hops = len(h)
if len(h) > 0 {
entryPrefix = h[0]
}
}
}
senderTs := decoded["sender_timestamp"]
m := &msg{
Data: map[string]interface{}{
@@ -1978,14 +2258,72 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region .
if obsTs.Valid {
m.LatestEpoch = obsTs.Int64
}
if obsName.Valid {
m.Data["observers"] = []string{obsName.String}
} else if obsID.Valid {
m.Data["observers"] = []string{obsID.String}
if observerName != "" {
m.Data["observers"] = []string{observerName}
}
if isPingTrigger(displayText) {
agg := &pendingPing{hops: hops, snr: snr, resolvedPath: resolvedPath, scope: scopeName.String, observers: map[string]bool{}}
if observerName != "" {
agg.observers[observerName] = true
}
pendingPings[txID] = agg
}
msgMap[txID] = m
}
// Bulk-resolve every pubkey referenced by any ping's DEEPEST relay path
// in ONE query, then build each pending reply's "via RepeaterA →
// RepeaterB" text plus its observer-breadth label. Names default to
// the raw pubkey/prefix when unresolved rather than being dropped, so
// the hop count and reply still make sense.
if len(pendingPings) > 0 {
pubkeySet := map[string]bool{}
for _, p := range pendingPings {
for _, pk := range p.resolvedPath {
if pk != nil && *pk != "" {
pubkeySet[*pk] = true
}
}
}
pubkeys := make([]string, 0, len(pubkeySet))
for pk := range pubkeySet {
pubkeys = append(pubkeys, pk)
}
names, _ := db.namesAndRolesForPubkeys(pubkeys)
for txID, p := range pendingPings {
var repeaterNames []string
for _, pk := range p.resolvedPath {
if pk == nil || *pk == "" {
continue
}
if name := names[*pk]; name != "" {
repeaterNames = append(repeaterNames, name)
} else {
repeaterNames = append(repeaterNames, *pk)
}
}
// Breadth: name the single observer when there's only one (as
// specific as before), otherwise report the count -- "heard by
// 4 observers" says more about actual reach than an arbitrarily
// picked single name once more than one observer heard it.
observerLabel := ""
switch len(p.observers) {
case 0:
// leave empty
case 1:
for name := range p.observers {
observerLabel = name
}
default:
observerLabel = fmt.Sprintf("%d observers", len(p.observers))
}
if m, ok := msgMap[txID]; ok {
m.Data["botReply"] = pingBotReply(p.hops, p.snr, observerLabel, p.scope, repeaterNames)
}
}
}
// Issue #1366 follow-up: emit batch sorted by LatestSeen ascending
// (newest LAST) — matches the in-memory path's tail-of-msgOrder
// convention and the frontend's scrollToBottom() behavior. pageIDs
+276
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -620,6 +621,89 @@ func TestGetTraces(t *testing.T) {
}
}
// TestGetPacketPath covers the "View path" map data source: given a
// packet hash, resolve its DEEPEST observation's relay path to
// name/role/lat/lon per hop, plus the hearing observer's IATA-derived
// position. Deliberately independent of seedTestData's fixtures.
func TestGetPacketPath(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkAlpha', 'RepeaterAlpha', 'repeater', 56.1, 10.2)`)
// pkBravo deliberately has NO nodes row -- exercises the raw-pubkey/no-position fallback.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000001', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
// Shallow observation (obs1): 1 hop.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlpha"]', 1736935200)`)
// Deeper observation (obs2): 2 hops -- must win even though it's not first.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 2, 4.0, -95, '["aa","bb"]', '["pkAlpha","pkBravo"]', 1736935260)`)
resp, err := db.GetPacketPath("pathtest00000001")
if err != nil {
t.Fatal(err)
}
if resp.Hops != 2 {
t.Fatalf("Hops = %d, want 2 (the deeper observation)", resp.Hops)
}
if len(resp.Points) != 2 {
t.Fatalf("Points = %+v, want 2 entries", resp.Points)
}
if resp.Points[0].Name != "RepeaterAlpha" || resp.Points[0].Lat == nil || *resp.Points[0].Lat != 56.1 {
t.Errorf("Points[0] = %+v, want RepeaterAlpha at lat 56.1", resp.Points[0])
}
if resp.Points[1].PublicKey != "pkBravo" || resp.Points[1].Name != "pkBravo" || resp.Points[1].Lat != nil {
t.Errorf("Points[1] = %+v, want raw pubkey fallback with nil lat (no nodes row)", resp.Points[1])
}
if resp.Observer == nil || resp.Observer.Name != "Observer Two" {
t.Fatalf("Observer = %+v, want Observer Two (heard the deeper observation)", resp.Observer)
}
if resp.Observer.Lat == nil || *resp.Observer.Lat != 37.6213 {
t.Errorf("Observer.Lat = %v, want the SFO IATA coordinate (37.6213)", resp.Observer.Lat)
}
}
func TestGetPacketPath_NoResolvedPath(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'pathtest00000002', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '["aa"]', 1736935200)`)
resp, err := db.GetPacketPath("pathtest00000002")
if err != nil {
t.Fatal(err)
}
if len(resp.Points) != 0 {
t.Errorf("Points = %+v, want empty when no observation has a resolved_path", resp.Points)
}
if resp.Observer != nil {
t.Errorf("Observer = %+v, want nil when there's no resolved path", resp.Observer)
}
}
func TestGetPacketPath_UnknownHash(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
resp, err := db.GetPacketPath("doesnotexist0000")
if err != nil {
t.Fatal(err)
}
if resp.Hops != 0 || len(resp.Points) != 0 {
t.Errorf("expected an empty response for an unknown hash, got %+v", resp)
}
}
func TestGetChannels(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
@@ -1400,6 +1484,198 @@ func TestGetChannelMessagesNoSender(t *testing.T) {
}
}
// TestGetChannelMessages_PingBotReply covers the CoreScope-only "ping"
// bot: a channel message whose text is exactly "ping" gets a synthetic
// botReply attached (never transmitted back onto the mesh -- see
// pingBotReply's doc comment), while ordinary messages don't.
func TestGetChannelMessages_PingBotReply(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkAlphaRepeater', 'RepeaterAlpha', 'repeater')`)
// pkBravoRepeater deliberately has NO nodes row -- exercises the
// unresolved-pubkey fallback (raw pubkey shown instead of a name).
// tx1: a plain chat message -- must NOT get a botReply.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AA', 'chanmsg00000001', '2026-01-15T10:00:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"just chatting","sender":"Alice"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 9.0, -88, '["aa","bb"]', 1736935200)`)
// tx2: bare "ping" -- must get a botReply with hops=2, snr=8.2, observer,
// and the relay path resolved to "RepeaterAlpha → pkBravoRepeater"
// (second hop has no nodes row, so its raw pubkey is shown instead).
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('BB', 'chanmsg00000002', '2026-01-15T10:01:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Bob"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (2, 1, 8.2, -90, '["aa","bb"]', '["pkAlphaRepeater","pkBravoRepeater"]', 1736935260)`)
// tx3: "@CoreScopeBot ping" -- the mention-prefix must be stripped before matching.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('CC', 'chanmsg00000003', '2026-01-15T10:02:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"@CoreScopeBot ping","sender":"Carol"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (3, 1, 5.0, -95, '[]', 1736935320)`)
// tx4: "pinging" -- must NOT match (not an exact "ping").
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('DD', 'chanmsg00000004', '2026-01-15T10:03:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"pinging around","sender":"Dave"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (4, 1, 3.0, -99, '[]', 1736935380)`)
// tx5: "/ping" -- the slash-command form must trigger too.
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('EE', 'chanmsg00000005', '2026-01-15T10:04:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"/ping","sender":"Frank"}', '#ping')`)
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (5, 1, 6.0, -91, '["aa"]', 1736935440)`)
messages, total, err := db.GetChannelMessages("#ping", 100, 0)
if err != nil {
t.Fatal(err)
}
if total != 5 {
t.Fatalf("expected 5 messages, got %d", total)
}
byText := map[string]map[string]interface{}{}
for _, m := range messages {
byText[m["text"].(string)] = m
}
if r := byText["just chatting"]["botReply"]; r != nil {
t.Errorf("plain chat message should not get a botReply, got %+v", r)
}
if r := byText["pinging around"]["botReply"]; r != nil {
t.Errorf("\"pinging\" should not match the exact \"ping\" trigger, got %+v", r)
}
pingReply, _ := byText["ping"]["botReply"].(map[string]interface{})
if pingReply == nil {
t.Fatal("bare \"ping\" message should get a botReply")
}
if pingReply["sender"] != "CoreScopeBot" {
t.Errorf("botReply sender = %v, want CoreScopeBot", pingReply["sender"])
}
if pingReply["hops"] != 2 {
t.Errorf("botReply hops = %v, want 2", pingReply["hops"])
}
replyText, _ := pingReply["text"].(string)
if !strings.Contains(replyText, "2 hops") || !strings.Contains(replyText, "8.2dB") || !strings.Contains(replyText, "Observer One") {
t.Errorf("botReply text = %q, want hops/SNR/observer mentioned", replyText)
}
if !strings.Contains(replyText, "via RepeaterAlpha → pkBravoRepeater") {
t.Errorf("botReply text = %q, want the resolved relay path (RepeaterAlpha for the known node, raw pubkey fallback for the unresolved one)", replyText)
}
mentionReply, _ := byText["@CoreScopeBot ping"]["botReply"].(map[string]interface{})
if mentionReply == nil {
t.Fatal("\"@CoreScopeBot ping\" should get a botReply (mention prefix stripped before matching)")
}
if mentionReply["hops"] != 0 {
t.Errorf("mention-prefixed ping botReply hops = %v, want 0 (empty path)", mentionReply["hops"])
}
slashReply, _ := byText["/ping"]["botReply"].(map[string]interface{})
if slashReply == nil {
t.Fatal("\"/ping\" should get a botReply -- it's in pingTriggerWords alongside bare \"ping\"")
}
if slashReply["sender"] != "CoreScopeBot" {
t.Errorf("\"/ping\" botReply sender = %v, want CoreScopeBot", slashReply["sender"])
}
}
// TestGetChannelMessages_PingBotReply_MultiObservation covers a single
// ping transmission heard by TWO different observers at
// DIFFERENT hop depths (normal in a mesh: one station may hear an early
// relay leg, another a later one). The botReply must report the DEEPEST
// (max-hop) observation's path/SNR -- not whichever observation happened
// to be scanned first -- and the breadth ("N observers") once more than
// one distinct station heard it, per pingBotReply's doc comment.
func TestGetChannelMessages_PingBotReply_MultiObservation(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'SJC')`)
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs2', 'Observer Two', 'SFO')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkAlphaRepeater', 'RepeaterAlpha', 'repeater')`)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('pkCharlieRepeater', 'RepeaterCharlie', 'repeater')`)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('EE', 'chanmsg00000005', '2026-01-15T10:04:00Z', 1, 5,
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
// obs1 (scanned first, o.id=1): shallow leg, 1 hop. transmission_id=1
// since this is the first (only) transmission inserted in this fresh DB.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 1, 9.0, -88, '["aa"]', '["pkAlphaRepeater"]', 1736935440)`)
// obs2 (scanned second, o.id=2): deeper leg, 3 hops -- must win despite
// being neither first nor having the highest SNR.
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, resolved_path, timestamp)
VALUES (1, 2, 4.5, -99, '["aa","bb","cc"]', '["pkAlphaRepeater","pkBravoRepeater","pkCharlieRepeater"]', 1736935445)`)
messages, _, err := db.GetChannelMessages("#ping", 100, 0)
if err != nil {
t.Fatal(err)
}
var reply map[string]interface{}
for _, m := range messages {
if m["text"] == "ping" {
reply, _ = m["botReply"].(map[string]interface{})
}
}
if reply == nil {
t.Fatal("expected a botReply on the ping message")
}
if reply["hops"] != 3 {
t.Errorf("botReply hops = %v, want 3 (the deeper of the two observations)", reply["hops"])
}
text, _ := reply["text"].(string)
if !strings.Contains(text, "SNR 4.5dB") {
t.Errorf("botReply text = %q, want the SNR paired with the deeper (3-hop) observation, not the shallower one's 9.0dB", text)
}
if !strings.Contains(text, "via RepeaterAlpha → pkBravoRepeater → RepeaterCharlie") {
t.Errorf("botReply text = %q, want the deeper observation's resolved relay path", text)
}
if !strings.Contains(text, "heard by 2 observers") {
t.Errorf("botReply text = %q, want breadth reported as \"2 observers\" now that more than one observer heard it", text)
}
}
// TestAppendAreaToBotReply covers appendAreaToBotReply (routes.go): the
// handler-level pass that folds a ping message's resolved "area" (set by
// annotateMessageAreas, which needs server config unavailable to db.go)
// into its already-built botReply text.
func TestAppendAreaToBotReply(t *testing.T) {
withArea := map[string]interface{}{
"area": "Aarhus",
"botReply": map[string]interface{}{"sender": "CoreScopeBot", "text": "🏓 pong! 2 hops"},
}
noArea := map[string]interface{}{
"botReply": map[string]interface{}{"sender": "CoreScopeBot", "text": "🏓 pong! 0 hops (direct)"},
}
noBotReply := map[string]interface{}{"area": "Aarhus", "text": "just chatting"}
appendAreaToBotReply([]map[string]interface{}{withArea, noArea, noBotReply})
gotText := withArea["botReply"].(map[string]interface{})["text"].(string)
if !strings.Contains(gotText, "area Aarhus") {
t.Errorf("botReply text = %q, want area appended", gotText)
}
gotNoAreaText := noArea["botReply"].(map[string]interface{})["text"].(string)
if strings.Contains(gotNoAreaText, "area") {
t.Errorf("botReply text = %q, want unchanged when message has no area", gotNoAreaText)
}
if _, ok := noBotReply["botReply"]; ok {
t.Error("a message with no botReply must not gain one")
}
}
func TestGetNetworkStatusDateFormats(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
+34 -2
View File
@@ -139,8 +139,10 @@ func routeDescriptions() map[string]routeMeta {
"GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"},
// Misc
"GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}},
"GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"},
"GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}},
"GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"},
"GET /api/packets/{hash}/path": {Summary: "Get a packet's geographic relay path", Description: "Resolves a packet's DEEPEST observation (the one with the most hops -- same reasoning as the ping-bot reply, issue tracker: when the same flood is heard by more than one station, the farthest-along leg is the more informative one to show) to a point sequence: each relay's name/role/lat/lon in path order, plus the hearing observer's position (from its configured IATA code, like the Wardriving tab). Lat/lon are null for any hop that has never advertised a GPS position -- callers should draw a gap, not guess. Backs the Channels tab's ping-bot \"View path\" map link.", Tag: "packets",
Response: schemaRef("PacketPathResponse")},
"GET /api/iata-coords": {Summary: "Get IATA airport coordinates", Description: "Returns lat/lon for known airport codes (used for observer positioning).", Tag: "config"},
"GET /api/audio-lab/buckets": {Summary: "Audio lab frequency buckets", Description: "Returns frequency bucket data for audio analysis.", Tag: "analytics"},
}
@@ -331,6 +333,36 @@ func componentSchemas() map[string]interface{} {
"timeSeries": map[string]interface{}{"type": "array", "items": schemaRef("HopDepthTimePoint"), "description": "Scoped/unscoped median hop depth over time within the window — is containment trending better or worse."},
},
},
"PacketPathPoint": map[string]interface{}{
"type": "object",
"description": "One hop's position along a packet's resolved relay path.",
"properties": map[string]interface{}{
"publicKey": str("Node public key (hex)."),
"name": str("Node display name, or its public key if unnamed."),
"role": str("Node role (e.g. repeater, room), when known."),
"lat": map[string]interface{}{"type": "number", "nullable": true, "description": "Null when this node has never advertised a GPS position."},
"lon": map[string]interface{}{"type": "number", "nullable": true},
},
},
"PacketPathObserver": map[string]interface{}{
"type": "object",
"description": "The station that produced the deepest observation of a packet path, positioned from its configured IATA code.",
"properties": map[string]interface{}{
"name": str("Observer display name."),
"iata": str("Observer's configured IATA airport code, when set."),
"lat": map[string]interface{}{"type": "number", "nullable": true},
"lon": map[string]interface{}{"type": "number", "nullable": true},
},
},
"PacketPathResponse": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"hash": str("The packet hash this path was resolved for."),
"hops": map[string]interface{}{"type": "integer", "description": "Length of the deepest observed relay path."},
"points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The relay path in hop order."},
"observer": schemaRef("PacketPathObserver"),
},
},
}
}
+37
View File
@@ -346,6 +346,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/observers/{id}", s.handleObserverDetail).Methods("GET")
r.HandleFunc("/api/observers", s.handleObservers).Methods("GET")
r.HandleFunc("/api/traces/{hash}", s.handleTraces).Methods("GET")
r.HandleFunc("/api/packets/{hash}/path", s.handlePacketPath).Methods("GET")
r.HandleFunc("/api/paths/inspect", s.handlePathInspect).Methods("POST")
r.HandleFunc("/api/iata-coords", s.handleIATACoords).Methods("GET")
r.HandleFunc("/api/audio-lab/buckets", s.handleAudioLabBuckets).Methods("GET")
@@ -2889,6 +2890,26 @@ func (s *Server) annotateMessageAreas(messages []map[string]interface{}) {
}
}
// appendAreaToBotReply folds a ping message's own resolved area (set by
// annotateMessageAreas just above, which MUST run first) into its
// botReply text. Area resolution needs server-level config (s.cfg.Areas)
// that db.go's GetChannelMessages/pingBotReply don't have access to, so
// this runs as a handler-level second pass instead.
func appendAreaToBotReply(messages []map[string]interface{}) {
for _, m := range messages {
area, _ := m["area"].(string)
if area == "" {
continue
}
reply, ok := m["botReply"].(map[string]interface{})
if !ok {
continue
}
text, _ := reply["text"].(string)
reply["text"] = text + " · area " + area
}
}
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
region := r.URL.Query().Get("region")
includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true"
@@ -2934,12 +2955,14 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
return
}
s.annotateMessageAreas(messages)
appendAreaToBotReply(messages)
writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total})
return
}
if s.store != nil {
messages, total := s.store.GetChannelMessages(hash, limit, offset, region)
s.annotateMessageAreas(messages)
appendAreaToBotReply(messages)
writeJSON(w, ChannelMessagesResponse{Messages: messages, Total: total})
return
}
@@ -3152,6 +3175,20 @@ func (s *Server) handleTraces(w http.ResponseWriter, r *http.Request) {
writeJSON(w, TraceResponse{Traces: traces})
}
func (s *Server) handlePacketPath(w http.ResponseWriter, r *http.Request) {
hash := mux.Vars(r)["hash"]
if s.db == nil {
writeJSON(w, PacketPathResponse{Hash: hash, Points: []PacketPathPoint{}})
return
}
resp, err := s.db.GetPacketPath(hash)
if err != nil {
writeError(w, 500, err.Error())
return
}
writeJSON(w, resp)
}
var iataCoords = map[string]IataCoord{
"SJC": {Lat: 37.3626, Lon: -121.929},
"SFO": {Lat: 37.6213, Lon: -122.379},
+65 -4
View File
@@ -323,6 +323,31 @@
return typeof hash === 'number' ? '0x' + hash.toString(16).toUpperCase().padStart(2, '0') : hash;
}
function getChannelColor(hash) { return CHANNEL_COLORS[hashCode(String(hash)) % CHANNEL_COLORS.length]; }
// Mirrors pingBotReply in cmd/server/db.go -- kept in sync by hand since
// this is the client-side equivalent for messages that arrive live over
// the WebSocket (handleWSMessage below), which never round-trips through
// GetChannelMessages and so never gets the server-computed botReply.
// Same trigger rule, same reply format. CoreScope-only: see the doc
// comment on botReplyHtml in renderMessages for why this never reaches
// the real mesh.
//
// Unlike the server version, this one can't show the resolved relay
// path (repeater names) -- the live WS broadcast doesn't carry a
// per-packet resolved_path, only REST-loaded history does (via
// GetChannelMessages). scope/area ARE available live and are included.
// pingTriggerWords mirrors pingTriggerWords in cmd/server/db.go -- keep
// both lists in sync by hand.
var pingTriggerWords = { 'ping': true, '/ping': true };
function pingBotReply(text, hops, snr, observer, scope, area) {
var trigger = String(text || '').trim().replace(/^@[A-Za-z0-9_-]{1,32}\s+/, '').trim();
if (!pingTriggerWords[trigger.toLowerCase()]) return null;
var parts = [hops > 0 ? (hops + ' hop' + (hops === 1 ? '' : 's')) : '0 hops (direct)'];
if (snr !== null && snr !== undefined) parts.push('SNR ' + Number(snr).toFixed(1) + 'dB');
if (observer) parts.push('heard by ' + observer);
if (scope) parts.push('scope ' + scope);
if (area) parts.push('area ' + area);
return { sender: 'CoreScopeBot', text: '🏓 pong! ' + parts.join(' · '), hops: hops, snr: snr };
}
function getSenderColor(name) {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
@@ -656,6 +681,7 @@
if (ci > 0 && ci < 50 && text.substring(0, ci) === sender) {
text = text.substring(ci + 2);
}
var alreadyDecObserver = c.packet.observer_name || null;
decrypted.push({
sender: sender, text: text,
timestamp: c.packet.first_seen || c.packet.timestamp,
@@ -665,7 +691,8 @@
observers: c.packet.observer_name ? [c.packet.observer_name] : [],
scope: c.packet.scope_name || null,
routeType: c.packet.route_type ?? null,
repeats: 1
repeats: 1,
botReply: pingBotReply(text, d.path_len || 0, c.packet.snr || null, alreadyDecObserver, c.packet.scope_name || null)
});
continue;
}
@@ -674,6 +701,7 @@
var result = await ChannelDecrypt.decryptPacket(keyBytes, c.decoded.mac, c.decoded.encryptedData);
if (result) {
macFailCount = 0;
var decObserver = c.packet.observer_name || null;
decrypted.push({
sender: result.sender, text: result.message,
timestamp: c.packet.first_seen || c.packet.timestamp,
@@ -683,7 +711,8 @@
observers: c.packet.observer_name ? [c.packet.observer_name] : [],
scope: c.packet.scope_name || null,
routeType: c.packet.route_type ?? null,
repeats: 1
repeats: 1,
botReply: pingBotReply(result.message, 0, c.packet.snr || null, decObserver, c.packet.scope_name || null)
});
} else {
macFailCount++;
@@ -1323,6 +1352,10 @@
});
msgEl.addEventListener('click', handleNodeTap);
msgEl.addEventListener('click', function (e) {
const el = e.target.closest('[data-view-path]');
if (el && window.PacketPathMap) window.PacketPathMap.open(el.dataset.viewPath);
});
// touchend fires more reliably on mobile for non-button elements
let touchMoved = false;
msgEl.addEventListener('touchstart', () => { touchMoved = false; }, { passive: true });
@@ -1467,6 +1500,7 @@
existing._fromWS = true;
existing._wsAt = Date.now();
} else {
var wsHops = payload.path_len || 0;
messages.push({
sender: sender,
text: displayText,
@@ -1476,11 +1510,12 @@
packetHash: pktHash,
repeats: 1,
observers: observer ? [observer] : [],
hops: payload.path_len || 0,
hops: wsHops,
snr: snr,
scope: scope,
routeType: routeType,
area: area,
botReply: pingBotReply(displayText, wsHops, snr, observer, scope, area),
// #1498: mark as WS-pushed so a later REST replacement
// (selectChannel / refreshMessages) can merge instead of
// stomp. Without this flag the REST response wipes any
@@ -2290,6 +2325,30 @@
if (msg.area) meta.push(`area: ${escapeHtml(msg.area)}`);
const safeId = btoa(encodeURIComponent(sender));
// Ping-bot reply (server-synthesized in GetChannelMessages when this
// message's text matches a trigger word (pingTriggerWords) -- see
// pingBotReply in db.go).
// CoreScope-only: never transmitted back onto the mesh, since
// CoreScope has no publish path to a MeshCore broker/radio. The
// "Not sent to the mesh" caveat is load-bearing, not decoration --
// without it this could be misread as a real bot reply the sender's
// own radio received.
// "View path" only makes sense when there's an actual multi-hop
// route to draw (hops > 0) and we have a packet hash to look it up
// by -- a direct (0-hop) reply has no relay path to show on a map.
const viewPathHtml = (msg.botReply && msg.botReply.hops > 0 && msg.packetHash)
? ` · <button type="button" class="ch-analyze-link" data-view-path="${escapeHtml(msg.packetHash)}" style="background:none;border:none;padding:0;cursor:pointer;font:inherit">View path →</button>`
: '';
const botReplyHtml = msg.botReply ? `<div class="ch-msg ch-message ch-bot-message">
<div class="ch-avatar" aria-hidden="true" style="background:var(--text-muted)">🤖</div>
<div class="ch-msg-content ch-message-content">
<div class="ch-msg-sender ch-message-sender" style="color:var(--text-muted)">${escapeHtml(msg.botReply.sender || 'CoreScopeBot')}</div>
<div class="ch-msg-bubble ch-message-bubble">${escapeHtml(msg.botReply.text || '')}</div>
<div class="ch-msg-meta ch-message-meta">Not sent to the mesh — CoreScope-only reply${viewPathHtml}</div>
</div>
</div>` : '';
// #1367: emit BOTH the new chat-app class names (.ch-message /
// .ch-message-bubble / .ch-message-meta) and the legacy .ch-msg*
// names so existing tests/themes don't regress.
@@ -2300,7 +2359,7 @@
<div class="ch-msg-bubble ch-message-bubble">${displayText}</div>
<div class="ch-msg-meta ch-message-meta">${meta.join(' · ')}${msg.packetHash ? ` · <a href="#/packets/${msg.packetHash}" class="ch-analyze-link">View packet →</a>` : ''}</div>
</div>
</div>`;
</div>${botReplyHtml}`;
}).join('');
}
@@ -2309,6 +2368,8 @@
if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); }
}
window._channelsRenderMessagesForTest = renderMessages;
window._channelsPingBotReplyForTest = pingBotReply;
window._channelsSetStateForTest = function (state) {
if (!state) return;
if (Array.isArray(state.channels)) channels = state.channels;
+1
View File
@@ -220,6 +220,7 @@
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-reach-map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="packet-path-map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-reach-coverage.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-reach.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="rx-coverage.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
+131
View File
@@ -0,0 +1,131 @@
/* window.PacketPathMap.open(hash) — on-demand modal showing a packet's
resolved relay path (see GET /api/packets/{hash}/path, cmd/server/db.go
GetPacketPath) as a sequential Leaflet map: each hop plotted in path
order and connected by a line, ending at the observer that produced
the deepest observation. Reuses node-reach-map.js's Leaflet setup
conventions (tile helper, circleMarker points, theme-aware colors) but
draws an ORDERED CHAIN instead of a star, since a relay path is a
sequence, not a hub-and-spoke.
Entry point today: the ping-bot reply's "View path" link
(public/channels.js botReplyHtml) -- kept general (keyed by packet
hash, not ping-specific) since any packet with a resolved path could
use the same view later. */
(function () {
'use strict';
function cssVar(name) {
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || '#888';
}
var activeMap = null;
function onKeydown(e) {
if (e.key === 'Escape') close();
}
function close() {
var overlay = document.getElementById('packetPathModal');
if (overlay) overlay.remove();
if (activeMap) {
try { activeMap.remove(); } catch (e) { /* already gone */ }
activeMap = null;
}
document.removeEventListener('keydown', onKeydown);
}
async function open(hash) {
close(); // in case one's already open
var overlay = document.createElement('div');
overlay.id = 'packetPathModal';
overlay.className = 'modal-overlay';
overlay.innerHTML =
'<div class="modal" style="max-width:min(92vw,700px);padding:16px">' +
'<button type="button" id="packetPathClose" aria-label="Close" ' +
'style="position:absolute;top:8px;right:8px;background:none;border:none;cursor:pointer;font-size:22px;line-height:1;color:var(--text-muted)">&times;</button>' +
'<h3 style="margin:0 0 4px;padding-right:24px">Relay Path</h3>' +
'<p class="text-muted" style="margin:0 0 10px;font-size:12px">How far this packet traveled before reaching the farthest-along observer. Hops without a known GPS position are omitted from the line.</p>' +
'<div id="packetPathMapContainer" style="height:360px;border-radius:8px;overflow:hidden;background:var(--surface-1)"></div>' +
'<div id="packetPathStatus" style="margin-top:8px;font-size:12px;color:var(--text-muted)">Loading…</div>' +
'</div>';
document.body.appendChild(overlay);
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
var closeBtn = document.getElementById('packetPathClose');
if (closeBtn) closeBtn.addEventListener('click', close);
document.addEventListener('keydown', onKeydown);
var statusEl = document.getElementById('packetPathStatus');
var data;
try {
data = await api('/packets/' + encodeURIComponent(hash) + '/path');
} catch (e) {
if (statusEl) statusEl.textContent = 'Failed to load path: ' + e.message;
return;
}
var allHops = data.points || [];
var located = allHops.filter(function (p) { return p.lat != null && p.lon != null; });
var missing = allHops.length - located.length;
var hasObserver = !!(data.observer && data.observer.lat != null && data.observer.lon != null);
if (located.length === 0 && !hasObserver) {
if (statusEl) {
statusEl.textContent = data.hops > 0
? 'None of the ' + data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' in this path have a known position yet.'
: 'This packet has no resolved relay path yet.';
}
return;
}
if (typeof L === 'undefined') {
if (statusEl) statusEl.textContent = 'Map library unavailable.';
return;
}
var chain = located.map(function (p, i) {
return { lat: p.lat, lon: p.lon, name: p.name, label: 'hop ' + (i + 1) + ' of ' + data.hops };
});
if (hasObserver) {
chain.push({ lat: data.observer.lat, lon: data.observer.lon, name: data.observer.name, label: 'observer', isObserver: true });
}
var center = chain[Math.floor(chain.length / 2)];
var map = L.map('packetPathMapContainer', { zoomControl: true, attributionControl: false })
.setView([center.lat, center.lon], 10);
if (typeof window._applyTilesToNodeMap === 'function') {
window._applyTilesToNodeMap(map);
} else {
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
}
var outline = cssVar('--surface-0');
var accent = cssVar('--accent');
var observerColor = cssVar('--status-yellow');
var bounds = [];
var line = [];
chain.forEach(function (p) {
bounds.push([p.lat, p.lon]);
line.push([p.lat, p.lon]);
var color = p.isObserver ? observerColor : accent;
L.circleMarker([p.lat, p.lon], { radius: p.isObserver ? 7 : 6, color: outline, weight: 2, fillColor: color, fillOpacity: 1 })
.addTo(map)
.bindTooltip(escapeHtml(p.name) + ' (' + p.label + ')');
});
if (line.length > 1) {
L.polyline(line, { color: accent, weight: 2.5, opacity: 0.85 }).addTo(map);
}
try { map.fitBounds(bounds, { padding: [30, 30] }); } catch (e) { /* single point */ }
setTimeout(function () { map.invalidateSize(); }, 120);
activeMap = map;
var statusParts = [data.hops + ' hop' + (data.hops === 1 ? '' : 's') + ' total'];
if (missing > 0) statusParts.push(missing + ' without a known position (not shown)');
if (statusEl) statusEl.textContent = statusParts.join(' · ');
}
window.PacketPathMap = { open: open, close: close };
})();
+5
View File
@@ -1902,6 +1902,11 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
border: 1px solid var(--border);
}
.ch-mention { color: var(--link-color); font-weight: 600; }
/* Ping-bot reply (public/channels.js botReplyHtml) -- dashed border marks
* it as a synthesized, CoreScope-only reply, never actually transmitted
* onto the mesh, distinct from a real observed message. */
.ch-bot-message { margin-top: -8px; }
.ch-bot-message .ch-msg-bubble { border-style: dashed; font-style: italic; }
.ch-encrypted-text { font-size: 11px; color: var(--text-muted); }
.ch-msg-meta { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.ch-analyze-link { color: var(--link-color); text-decoration: none; margin-left: 8px; }
+2
View File
@@ -72,6 +72,8 @@ node test-issue-1770-mobile-row-clamp.js
node test-issue-1849-trace-hashbytes.js
node test-node-analytics-hop-chart.js
node test-analytics-hop-depth-ui.js
node test-channels-ping-bot-reply.js
node test-packet-path-map.js
echo ""
echo "═══════════════════════════════════════"
+248
View File
@@ -0,0 +1,248 @@
/**
* Unit tests for the CoreScope-only "ping" bot reply bubble
* (public/channels.js renderMessages' botReplyHtml block).
*
* The backend (cmd/server/db.go pingBotReply) attaches a synthetic
* `botReply` field to a channel message whose text is exactly "ping" --
* this file covers that the frontend renders it distinctly, includes the
* "not sent to the mesh" caveat, and escapes attacker-controlled fields
* (observer names flow into botReply.text server-side, so it must not be
* trusted blindly).
*
* Sandbox pattern borrowed from test-channels-merge-1498-unit.js: load
* channels.js in a tolerant vm context, grab the test-only export.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function makeSandbox() {
const noop = () => {};
const fakeEl = () => ({
addEventListener: noop, querySelector: () => null, querySelectorAll: () => [],
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
appendChild: noop, removeChild: noop, setAttribute: noop, getAttribute: () => null,
textContent: '', innerHTML: '', style: {}, dataset: {}, scrollTop: 0, scrollHeight: 0,
});
const chMessagesEl = fakeEl();
const doc = {
readyState: 'complete', createElement: fakeEl, head: fakeEl(), body: fakeEl(),
documentElement: fakeEl(),
getElementById: (id) => (id === 'chMessages' ? chMessagesEl : null),
querySelector: () => null, querySelectorAll: () => [],
addEventListener: noop,
};
const win = { addEventListener: noop, matchMedia: () => ({ matches: false, addListener: noop, addEventListener: noop }) };
const ctx = {
window: win, document: doc, console, Date, Math, JSON, Set, Map, Array, Object, Promise, Response: function () {}, Error,
setTimeout, clearTimeout, setInterval, clearInterval,
history: { replaceState: noop, pushState: noop },
location: { hash: '', href: '', pathname: '/' },
navigator: { userAgent: 'node' },
RegionFilter: { getRegionParam: () => '' },
api: () => Promise.resolve({ messages: [] }),
CLIENT_TTL: {},
ChannelDecrypt: undefined,
truncate: (s) => s,
formatHashHex: (h) => String(h),
channelDisplayName: (c) => c && c.name,
escapeHtml,
getSenderColor: () => '#123456',
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
};
vm.createContext(ctx);
try {
vm.runInContext(fs.readFileSync('public/channels.js', 'utf8'), ctx);
} catch (e) {
// Tolerant: only the render path under test needs to have been
// exported before any unrelated init code throws.
}
return { ctx, chMessagesEl };
}
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✅ ' + name); }
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
}
console.log('\n=== channels.js: pingBotReply (shared trigger/format logic) ===');
// This is the client-side twin of pingBotReply in cmd/server/db.go, used
// by the WebSocket live-push path and the client-side PSK-channel decrypt
// path (neither of which round-trips through GetChannelMessages, so
// neither gets the server-computed botReply without this).
test('exact "ping" (any case) triggers a reply', () => {
const { ctx } = makeSandbox();
const fn = ctx.window._channelsPingBotReplyForTest;
assert.ok(fn('ping', 1, 5, 'Obs') !== null);
assert.ok(fn('PING', 1, 5, 'Obs') !== null);
assert.ok(fn(' ping ', 1, 5, 'Obs') !== null, 'surrounding whitespace should be trimmed');
});
test('"/ping" (the slash-command form) also triggers, alongside bare "ping"', () => {
const { ctx } = makeSandbox();
const fn = ctx.window._channelsPingBotReplyForTest;
assert.ok(fn('/ping', 1, 5, 'Obs') !== null);
assert.ok(fn('/PING', 1, 5, 'Obs') !== null, 'case-insensitive like the bare form');
assert.strictEqual(fn('/pingx', 1, 5, 'Obs'), null, 'still an exact match, not a prefix match');
});
test('a mention prefix like "@CoreScopeBot ping" is stripped before matching', () => {
const { ctx } = makeSandbox();
const fn = ctx.window._channelsPingBotReplyForTest;
assert.ok(fn('@CoreScopeBot ping', 0, null, null) !== null);
});
test('"pinging" or other substrings do not match (exact trigger only)', () => {
const { ctx } = makeSandbox();
const fn = ctx.window._channelsPingBotReplyForTest;
assert.strictEqual(fn('pinging around', 1, 5, 'Obs'), null);
assert.strictEqual(fn('not ping', 1, 5, 'Obs'), null);
assert.strictEqual(fn('', 1, 5, 'Obs'), null);
});
test('reply text includes hops, SNR, and observer when present', () => {
const { ctx } = makeSandbox();
const fn = ctx.window._channelsPingBotReplyForTest;
const r = fn('ping', 3, 8.25, 'Observer One');
assert.strictEqual(r.sender, 'CoreScopeBot');
assert.ok(r.text.includes('3 hops'), r.text);
assert.ok(r.text.includes('SNR 8.3dB') || r.text.includes('SNR 8.2dB'), r.text);
assert.ok(r.text.includes('heard by Observer One'), r.text);
});
test('hops=0 reports "0 hops (direct)"; missing SNR/observer are omitted cleanly', () => {
const { ctx } = makeSandbox();
const fn = ctx.window._channelsPingBotReplyForTest;
const r = fn('ping', 0, null, null);
assert.ok(r.text.includes('0 hops (direct)'), r.text);
assert.ok(!r.text.includes('SNR'), r.text);
assert.ok(!r.text.includes('heard by'), r.text);
});
test('scope and area are included when present, omitted when not', () => {
const { ctx } = makeSandbox();
const fn = ctx.window._channelsPingBotReplyForTest;
const withBoth = fn('ping', 1, 5, 'Obs', '#dk', 'Aarhus');
assert.ok(withBoth.text.includes('scope #dk'), withBoth.text);
assert.ok(withBoth.text.includes('area Aarhus'), withBoth.text);
const withNeither = fn('ping', 1, 5, 'Obs', null, null);
assert.ok(!withNeither.text.includes('scope'), withNeither.text);
assert.ok(!withNeither.text.includes('area'), withNeither.text);
});
console.log('\n=== channels.js: ping-bot reply rendering ===');
test('a message without botReply renders no bot bubble', () => {
const { ctx, chMessagesEl } = makeSandbox();
ctx.window._channelsSetStateForTest({ messages: [
{ sender: 'Alice', text: 'just chatting', timestamp: '2026-01-15T10:00:00Z' },
] });
ctx.window._channelsRenderMessagesForTest();
assert.ok(!chMessagesEl.innerHTML.includes('ch-bot-message'), 'no botReply field should mean no bot bubble');
});
test('a message with botReply renders a distinct bot bubble with the reply text', () => {
const { ctx, chMessagesEl } = makeSandbox();
ctx.window._channelsSetStateForTest({ messages: [
{
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z',
botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops · SNR 8.2dB · heard by Observer One', hops: 2, snr: 8.2 },
},
] });
ctx.window._channelsRenderMessagesForTest();
const html = chMessagesEl.innerHTML;
assert.ok(html.includes('ch-bot-message'), 'should render the distinct bot-message class');
assert.ok(html.includes('CoreScopeBot'), 'should show the bot sender name');
assert.ok(html.includes('2 hops'), 'should include the hop count from the reply text');
assert.ok(html.includes('SNR 8.2dB'), 'should include the SNR from the reply text');
});
test('"View path" link appears when hops > 0 and a packetHash is available', () => {
const { ctx, chMessagesEl } = makeSandbox();
ctx.window._channelsSetStateForTest({ messages: [
{
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', packetHash: 'abc123',
botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops', hops: 2, snr: null },
},
] });
ctx.window._channelsRenderMessagesForTest();
const html = chMessagesEl.innerHTML;
assert.ok(html.includes('View path'), 'should show the View path link');
assert.ok(html.includes('data-view-path="abc123"'), 'should carry the packet hash for the click handler to look up');
});
test('"View path" link is absent for a direct (0-hop) reply -- nothing to draw', () => {
const { ctx, chMessagesEl } = makeSandbox();
ctx.window._channelsSetStateForTest({ messages: [
{
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', packetHash: 'abc123',
botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 0 hops (direct)', hops: 0, snr: null },
},
] });
ctx.window._channelsRenderMessagesForTest();
assert.ok(!chMessagesEl.innerHTML.includes('View path'), 'a direct reply has no relay path to visualize');
});
test('"View path" link is absent when there is no packetHash to look it up by', () => {
const { ctx, chMessagesEl } = makeSandbox();
ctx.window._channelsSetStateForTest({ messages: [
{
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z',
botReply: { sender: 'CoreScopeBot', text: '🏓 pong! 2 hops', hops: 2, snr: null },
},
] });
ctx.window._channelsRenderMessagesForTest();
assert.ok(!chMessagesEl.innerHTML.includes('View path'), 'without a packetHash there is nothing to fetch the path for');
});
test('the "not sent to the mesh" caveat is always present on a bot bubble', () => {
const { ctx, chMessagesEl } = makeSandbox();
ctx.window._channelsSetStateForTest({ messages: [
{ sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'CoreScopeBot', text: 'pong', hops: 0 } },
] });
ctx.window._channelsRenderMessagesForTest();
assert.ok(chMessagesEl.innerHTML.includes('Not sent to the mesh'), 'the caveat must be visible so this is never mistaken for a real mesh reply');
});
test('botReply.text and .sender are HTML-escaped (observer names are operator-controlled)', () => {
const { ctx, chMessagesEl } = makeSandbox();
ctx.window._channelsSetStateForTest({ messages: [
{
sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z',
botReply: { sender: '<img src=x onerror=alert(1)>', text: 'heard by <script>alert(2)</script>', hops: 0 },
},
] });
ctx.window._channelsRenderMessagesForTest();
const html = chMessagesEl.innerHTML;
assert.ok(!html.includes('<img src=x'), 'botReply.sender must be escaped');
assert.ok(!html.includes('<script>alert(2)'), 'botReply.text must be escaped');
});
test('the bot bubble renders immediately after its triggering message, not before other messages', () => {
const { ctx, chMessagesEl } = makeSandbox();
ctx.window._channelsSetStateForTest({ messages: [
{ sender: 'Bob', text: 'ping', timestamp: '2026-01-15T10:01:00Z', botReply: { sender: 'CoreScopeBot', text: 'pong', hops: 0 } },
{ sender: 'Carol', text: 'after', timestamp: '2026-01-15T10:02:00Z' },
] });
ctx.window._channelsRenderMessagesForTest();
const html = chMessagesEl.innerHTML;
const pingIdx = html.indexOf('>ping<');
const botIdx = html.indexOf('ch-bot-message');
const afterIdx = html.indexOf('>after<');
assert.ok(pingIdx > -1 && botIdx > -1 && afterIdx > -1, 'all three pieces should be present');
assert.ok(pingIdx < botIdx && botIdx < afterIdx, 'order should be: ping message, bot reply, next message');
});
console.log('\n════════════════════════════════════════');
console.log(` Channels ping-bot reply: ${passed} passed, ${failed} failed`);
console.log('════════════════════════════════════════');
if (failed > 0) process.exit(1);
+179
View File
@@ -0,0 +1,179 @@
/**
* Tests for public/packet-path-map.js — the on-demand "View path" modal
* that draws a packet's resolved relay path on a Leaflet map (backed by
* GET /api/packets/{hash}/path, cmd/server/db.go GetPacketPath).
*
* Two layers, matching this repo's established pattern for modal/DOM
* code (see test-channel-modal-ux.js): string-contract checks over the
* raw source for structural/safety properties, plus a functional smoke
* test using a minimal-but-real DOM mock (createElement/appendChild/
* getElementById/remove all actually work, unlike the channels.js test
* sandbox's inert stubs) to exercise open()/close() end-to-end on the
* two code paths that don't need Leaflet: a failed fetch, and a
* fetch that resolves with nothing plottable.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
const src = fs.readFileSync('public/packet-path-map.js', 'utf8');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ✅ ' + name); }
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
}
console.log('\n=== packet-path-map.js: string-contract checks ===');
test('exports window.PacketPathMap.{open,close}', () => {
assert.ok(/window\.PacketPathMap\s*=\s*\{\s*open:\s*open,\s*close:\s*close\s*\}/.test(src));
});
test('fetches via the shared api() helper, not a raw fetch (picks up auth/base-URL handling)', () => {
assert.ok(/api\(\s*'\/packets\/'\s*\+\s*encodeURIComponent\(hash\)\s*\+\s*'\/path'\s*\)/.test(src));
});
test('escapes node/observer names before interpolating into tooltip HTML (operator-controlled data)', () => {
assert.ok(/escapeHtml\(p\.name\)/.test(src), 'point tooltips must escape the name');
});
test('handles Escape key and click-outside to close, matching other CoreScope modals', () => {
assert.ok(/e\.key === 'Escape'/.test(src));
assert.ok(/e\.target === overlay/.test(src));
});
test('degrades gracefully when the Leaflet global is unavailable, rather than throwing', () => {
assert.ok(/typeof L === 'undefined'/.test(src));
});
test('close() tears down the Leaflet map instance, not just the DOM overlay (avoids a leaked map on repeat opens)', () => {
assert.ok(/activeMap\.remove\(\)/.test(src));
});
console.log('\n=== packet-path-map.js: functional smoke test (no-Leaflet code paths) ===');
function makeSandbox(apiImpl) {
// A minimal but REAL DOM: elements track their own children/attributes
// so createElement -> appendChild -> getElementById -> remove() all
// actually work, unlike the inert stubs used for pure string-render
// testing elsewhere. Deliberately small: only what open()/close() touch.
function makeElement(tag) {
const el = {
tagName: tag, children: [], attributes: {}, style: {}, dataset: {},
_listeners: {},
get id() { return this.attributes.id || ''; },
set id(v) { this.attributes.id = v; },
// Real innerHTML would parse into a live child tree; this mock only
// needs id-addressable children with a settable textContent (all
// open()/close() read back), so it scans for id="..." occurrences
// and registers one lightweight child per id found.
set innerHTML(html) {
this._innerHTML = html;
this.children = [];
const re = /id="([^"]+)"/g;
let m;
while ((m = re.exec(html))) {
const child = makeElement('div');
child.id = m[1];
this.appendChild(child);
}
},
get innerHTML() { return this._innerHTML || ''; },
set textContent(t) { this._text = t; },
get textContent() { return this._text || ''; },
appendChild(child) { this.children.push(child); child._parent = this; return child; },
remove() { if (this._parent) this._parent.children = this._parent.children.filter(c => c !== this); },
addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); },
removeEventListener(type, fn) { if (this._listeners[type]) this._listeners[type] = this._listeners[type].filter(f => f !== fn); },
querySelector() { return null; },
};
return el;
}
const body = makeElement('body');
const docListeners = {};
const doc = {
createElement: makeElement,
body,
documentElement: { style: {} },
getElementById(id) {
const search = (el) => {
if (el.id === id) return el;
for (const c of el.children) { const found = search(c); if (found) return found; }
return null;
};
return search(body);
},
addEventListener(type, fn) { (docListeners[type] = docListeners[type] || []).push(fn); },
removeEventListener(type, fn) { if (docListeners[type]) docListeners[type] = docListeners[type].filter(f => f !== fn); },
};
const ctx = {
window: {}, document: doc, console, Math, String, JSON, Promise, Error,
setTimeout, clearTimeout,
getComputedStyle: () => ({ getPropertyValue: () => '' }),
escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
api: apiImpl,
L: undefined, // Leaflet deliberately absent -- these tests only cover the no-plot-data / no-Leaflet paths.
};
vm.createContext(ctx);
vm.runInContext(src, ctx);
return ctx;
}
(async () => {
await (async () => {
try {
const ctx = makeSandbox(() => Promise.reject(new Error('network down')));
await ctx.window.PacketPathMap.open('deadbeef');
const overlay = ctx.document.getElementById('packetPathModal');
const status = ctx.document.getElementById('packetPathStatus');
assert.ok(overlay, 'modal overlay should be created');
assert.ok(status.textContent.includes('Failed to load path'), 'should surface the fetch error, got: ' + status.textContent);
passed++;
console.log(' ✅ a failed fetch shows an error status without throwing');
} catch (e) { failed++; console.log(' ❌ a failed fetch shows an error status without throwing: ' + e.message); }
})();
await (async () => {
try {
const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 0, points: [] }));
await ctx.window.PacketPathMap.open('deadbeef');
const status = ctx.document.getElementById('packetPathStatus');
assert.ok(status.textContent.includes('no resolved relay path'), 'should explain there is nothing to show yet, got: ' + status.textContent);
passed++;
console.log(' ✅ an empty path (hops=0, no points) shows a clear "nothing to show" status');
} catch (e) { failed++; console.log(' ❌ an empty path (hops=0, no points) shows a clear "nothing to show" status: ' + e.message); }
})();
await (async () => {
try {
const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', hops: 3, points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: null, lon: null }] }));
await ctx.window.PacketPathMap.open('deadbeef');
const status = ctx.document.getElementById('packetPathStatus');
assert.ok(status.textContent.includes('3 hop'), 'should mention the hop count even when no hop has a known position, got: ' + status.textContent);
passed++;
console.log(' ✅ hops with no known position at all still report the hop count, not a silent blank');
} catch (e) { failed++; console.log(' ❌ hops with no known position at all still report the hop count, not a silent blank: ' + e.message); }
})();
await (async () => {
try {
const ctx = makeSandbox(() => Promise.reject(new Error('boom')));
await ctx.window.PacketPathMap.open('deadbeef');
assert.ok(ctx.document.getElementById('packetPathModal'), 'modal should be open');
ctx.window.PacketPathMap.close();
assert.ok(!ctx.document.getElementById('packetPathModal'), 'modal should be removed after close()');
passed++;
console.log(' ✅ close() removes the modal overlay from the DOM');
} catch (e) { failed++; console.log(' ❌ close() removes the modal overlay from the DOM: ' + e.message); }
})();
console.log('\n════════════════════════════════════════');
console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`);
console.log('════════════════════════════════════════');
if (failed > 0) process.exit(1);
})();