diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 229aa62b7..cf2730e3b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -518,6 +518,7 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1279-legend-p2-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1799-label-vocab-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-home-coverage-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 node test-issue-304-my-mesh-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 node test-issue-2027-my-mesh-node-page-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-path-inspector-coverage-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/cmd/server/chunked_load.go b/cmd/server/chunked_load.go index c0d5bd557..c34455a1e 100644 --- a/cmd/server/chunked_load.go +++ b/cmd/server/chunked_load.go @@ -245,10 +245,16 @@ func (s *PacketStore) RunStartupLoad(chunkSize int) error { s.mu.RLock() loadedCount := int64(len(s.packets)) s.mu.RUnlock() - var totalInDB int64 - if err := s.db.conn.QueryRow(`SELECT COUNT(*) FROM transmissions`).Scan(&totalInDB); err != nil { - totalInDB = -1 - } + // #351 F1: measure coverage against the rows LoadChunked actually + // targeted (retention/hot-start-filtered), NOT a raw COUNT(*) over + // the whole table. With the shipped default (retentionHours=168, + // hotStartupHours=0) and a DB holding more than 168h of history, a + // whole-table count makes this ratio structurally < 1 forever, so + // the 24h activity coverage gate never proves completeness even + // though every retained row is in memory. docs/api-spec.md defines + // coverage against the RETAINED transmissions, and the bg-loader + // path already measures it that way. + totalInDB := s.loadWindowTotalInDB var ratio float64 switch { case totalInDB <= 0: @@ -366,6 +372,11 @@ func (s *PacketStore) LoadChunked(chunkSize int) error { if err := s.db.conn.QueryRow(countSQL).Scan(&totalInDB); err != nil { totalInDB = -1 } + // #351 F1: publish the retention/hot-start-filtered total so + // RunStartupLoad's hotStartupHours==0 branch can compute a coverage + // ratio against the rows it actually targeted instead of the whole + // table. Same goroutine, read-after-return — see field godoc. + s.loadWindowTotalInDB = int64(totalInDB) // Memory cap honoured by clamping the maximum cursor walk. var maxPackets int64 diff --git a/cmd/server/count_sort_tie_order_321_test.go b/cmd/server/count_sort_tie_order_321_test.go index d91cc2549..9e11280ba 100644 --- a/cmd/server/count_sort_tie_order_321_test.go +++ b/cmd/server/count_sort_tie_order_321_test.go @@ -148,7 +148,7 @@ func TestNodeHealthObserverRowsTieOrder_321(t *testing.T) { s := newHealthTieStore(t, pubkey, counts) assertSameEveryRun(t, "GetNodeHealth observers observer_id", want, func() interface{} { - res, err := s.GetNodeHealth(pubkey) + res, err := s.GetNodeHealth(pubkey, time.Now().UTC()) if err != nil { t.Fatalf("GetNodeHealth: %v", err) } diff --git a/cmd/server/node_activity_24h.go b/cmd/server/node_activity_24h.go new file mode 100644 index 000000000..23234c222 --- /dev/null +++ b/cmd/server/node_activity_24h.go @@ -0,0 +1,125 @@ +package main + +import "time" + +// NodeActivityHour is one half-open hour of transmission activity. +type NodeActivityHour struct { + Start string `json:"start"` + End string `json:"end"` + Count int `json:"count"` +} + +// NodeActivity24h describes the last 24 rolling hours. Complete is false +// unless the in-memory index is known to cover the whole window. +type NodeActivity24h struct { + WindowStart string `json:"windowStart"` + WindowEnd string `json:"windowEnd"` + Buckets [24]NodeActivityHour `json:"buckets"` + CoverageStart *string `json:"coverageStart"` + Complete bool `json:"complete"` + start time.Time + end time.Time + // startSecond is start truncated to whole seconds in the fixed-width + // "2006-01-02T15:04:05" form; add() uses it as a string pre-filter. + startSecond string +} + +// nodeActivitySecondLayout is fixed width: no fraction, no zone suffix. +const nodeActivitySecondLayout = "2006-01-02T15:04:05" + +func newNodeActivity24h(now, coverageStart time.Time) NodeActivity24h { + end := now.UTC() + start := end.Add(-24 * time.Hour) + a := NodeActivity24h{ + WindowStart: start.Format(time.RFC3339Nano), + WindowEnd: end.Format(time.RFC3339Nano), + start: start, + end: end, + startSecond: start.Format(nodeActivitySecondLayout), + } + for i := range a.Buckets { + bucketStart := start.Add(time.Duration(i) * time.Hour) + a.Buckets[i] = NodeActivityHour{ + Start: bucketStart.Format(time.RFC3339Nano), + End: bucketStart.Add(time.Hour).Format(time.RFC3339Nano), + } + } + if !coverageStart.IsZero() { + coverageStart = coverageStart.UTC() + iso := coverageStart.Format(time.RFC3339Nano) + a.CoverageStart = &iso + a.Complete = !coverageStart.After(start) + } + return a +} + +func (a *NodeActivity24h) add(tx *StoreTx) { + if tx == nil { + return + } + // #351 F6/R3: cheap pre-filter before the RFC3339Nano parse, for the + // common case of a node whose in-memory history spans well past 24h. + // It compares against startSecond, the window start truncated to whole + // seconds, NOT WindowStart: RFC3339Nano trims trailing zeros, so the + // fraction widths of WindowStart and a stamp differ ("…:00Z" vs + // "…:00.100Z") and a full-string compare can sort an in-window stamp + // below the bound. The second prefix is fixed width, so a UTC stamp at + // or after the window start always sorts >= startSecond whatever its + // fraction. A stamp in the start's own second survives and the exact + // bounds check below decides it. The upper bound is deliberately NOT + // string-filtered, for the same fraction-width reason. + if tx.FirstSeen < a.startSecond { + return + } + when, err := time.Parse(time.RFC3339Nano, tx.FirstSeen) + if err != nil || when.Before(a.start) || !when.Before(a.end) { + return + } + idx := int(when.Sub(a.start) / time.Hour) + a.Buckets[idx].Count++ +} + +// nodeActivityCoverageStartLocked returns zero unless load and retention +// provide positive evidence of continuous in-memory coverage. A memory cap +// can evict the oldest rows after oldestLoaded was set, so use the current +// first row as the stricter lower bound when it is enabled. +// +// #351 F4: loadCoverage is read by the caller (GetNodeHealth) BEFORE it +// takes s.mu and passed in here, so this function never acquires bgErrMu +// while holding s.mu. bgErrMu exists precisely so its readers need not +// synchronise on s.mu (store.go:530); nesting s.mu → bgErrMu would be the +// first such site in the codebase and would quietly undermine that contract. +func (s *PacketStore) nodeActivityCoverageStartLocked(now time.Time, loadCoverage float64) time.Time { + if !s.loaded || !s.backgroundLoadDone.Load() || s.backgroundLoadFailed.Load() || + (s.retentionHours > 0 && s.retentionHours < 24) || len(s.packets) == 0 { + return time.Time{} + } + // The startup "done" gate accepts 90% of DB rows. That is not enough + // to call every empty hour a confirmed zero; require the exact count. + // Unlimited retention with a hot-start window has no background fill and + // leaves loadCoverageRatio at zero. The window itself can still prove 24h + // coverage when it is at least 24h wide; oldestLoaded is checked below. + if loadCoverage < 1 && !(s.retentionHours <= 0 && s.hotStartupHours >= 24) { + return time.Time{} + } + coverage, err := time.Parse(time.RFC3339Nano, s.oldestLoaded) + if err != nil { + return time.Time{} + } + if s.maxMemoryMB > 0 { + first, err := time.Parse(time.RFC3339Nano, s.packets[0].FirstSeen) + if err != nil { + return time.Time{} + } + if first.After(coverage) { + coverage = first + } + } + if s.retentionHours > 0 { + floor := now.UTC().Add(-time.Duration(s.retentionHours * float64(time.Hour))) + if floor.After(coverage) { + coverage = floor + } + } + return coverage +} diff --git a/cmd/server/node_activity_304_test.go b/cmd/server/node_activity_304_test.go new file mode 100644 index 000000000..8820698b5 --- /dev/null +++ b/cmd/server/node_activity_304_test.go @@ -0,0 +1,349 @@ +package main + +import ( + "encoding/json" + "path/filepath" + "testing" + "time" +) + +func TestNodeActivity24hRollingBoundariesAndUniqueTransmissions(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + packets := make([]*StoreTx, 0, 30) + for i := 0; i < 25; i++ { + packets = append(packets, &StoreTx{ID: i + 1, FirstSeen: start.Add(time.Duration(i) * time.Hour).Format(time.RFC3339), ObservationCount: 3}) + } + packets = append(packets, + &StoreTx{ID: 26, FirstSeen: start.Add(-time.Second).Format(time.RFC3339)}, + &StoreTx{ID: 27, FirstSeen: now.Format(time.RFC3339)}, + &StoreTx{ID: 28, FirstSeen: "bad timestamp"}, + ) + got := newNodeActivity24h(now, start.Add(-time.Hour)) + for _, tx := range packets { + got.add(tx) + } + if len(got.Buckets) != 24 { + t.Fatalf("buckets=%d, want 24", len(got.Buckets)) + } + if got.WindowStart != start.Format(time.RFC3339) || got.WindowEnd != now.Format(time.RFC3339) { + t.Fatalf("window=%s..%s", got.WindowStart, got.WindowEnd) + } + for i, b := range got.Buckets { + if b.Count != 1 { + t.Errorf("bucket %d count=%d, want one transmission despite three observations", i, b.Count) + } + } +} + +func TestNodeActivity24hIncompleteWhenCoverageUnknown(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + got := newNodeActivity24h(now, time.Time{}) + if got.Complete || got.CoverageStart != nil { + t.Fatalf("empty history must not claim complete coverage: %+v", got) + } + for _, b := range got.Buckets { + if b.Count != 0 { + t.Fatalf("empty bucket count=%d", b.Count) + } + } +} + +func TestNodeHealthActivity24hUsesFullByNodeIndex(t *testing.T) { + srv, _ := setupTestServer(t) + const pubkey = "aabbccdd11223344" + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + packets := make([]*StoreTx, 0, 27) + for i := 0; i < 26; i++ { + packets = append(packets, &StoreTx{ID: i + 1, FirstSeen: start.Add(time.Duration(i%24) * time.Hour).Format(time.RFC3339), ObservationCount: 4}) + } + packets = append(packets, &StoreTx{ID: 27, FirstSeen: "malformed"}) + srv.store.mu.Lock() + srv.store.byNode[pubkey] = packets + srv.store.loaded = true + srv.store.loadCoverageRatio = 1 + srv.store.retentionHours = 168 + srv.store.oldestLoaded = start.Add(-time.Hour).Format(time.RFC3339) + srv.store.packets = []*StoreTx{{ID: 100, FirstSeen: srv.store.oldestLoaded}} + srv.store.backgroundLoadDone.Store(true) + srv.store.mu.Unlock() + health, err := srv.store.GetNodeHealth(pubkey, now) + if err != nil { + t.Fatal(err) + } + activity, ok := health["activity24h"].(NodeActivity24h) + if !ok || !activity.Complete || activity.CoverageStart == nil { + t.Fatalf("activity coverage: %#v", health["activity24h"]) + } + if activity.Buckets[0].Count != 2 || activity.Buckets[23].Count != 1 { + t.Fatalf("buckets[0]=%d [23]=%d", activity.Buckets[0].Count, activity.Buckets[23].Count) + } + if len(health["recentPackets"].([]map[string]interface{})) != 20 { + t.Fatal("recentPackets compatibility limit changed") + } + encoded, err := json.Marshal(health) + if err != nil { + t.Fatal(err) + } + var body struct { + Activity24h struct { + Buckets []NodeActivityHour `json:"buckets"` + Complete bool `json:"complete"` + } `json:"activity24h"` + } + if err := json.Unmarshal(encoded, &body); err != nil || !body.Activity24h.Complete || len(body.Activity24h.Buckets) != 24 { + t.Fatalf("JSON contract invalid: %v %s", err, encoded) + } +} + +func TestNodeActivity24hCoverageRequiresLoadedHistory(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + s := &PacketStore{loaded: true, loadCoverageRatio: 1, oldestLoaded: start.Add(-time.Hour).Format(time.RFC3339), retentionHours: 168, + packets: []*StoreTx{{FirstSeen: start.Add(-time.Hour).Format(time.RFC3339)}}} + s.backgroundLoadDone.Store(true) + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); got.After(start) || got.IsZero() { + t.Fatalf("complete load coverage=%v", got) + } + s.loadCoverageRatio = 0.90 + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { + t.Fatalf("90%% startup gate is not full coverage, got %v", got) + } + s.retentionHours = 0 + s.hotStartupHours = 25 + s.loadCoverageRatio = 0 + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); got.After(start) || got.IsZero() { + t.Fatalf("unlimited retention with a fully loaded 25h hot window must be complete, got %v", got) + } + s.hotStartupHours = 12 + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { + t.Fatalf("a 12h hot window cannot prove 24h coverage, got %v", got) + } + s.hotStartupHours = 0 + s.loadCoverageRatio = 1 // RunStartupLoad sets this after full synchronous loading. + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); got.After(start) || got.IsZero() { + t.Fatalf("unlimited retention with no hot window loads full DB, got %v", got) + } + s.retentionHours = 168 + s.maxMemoryMB = 100 + s.packets[0].FirstSeen = start.Add(time.Hour).Format(time.RFC3339) + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.After(start) { + t.Fatalf("memory cap evicted first hour, coverage=%v", got) + } + s.backgroundLoadFailed.Store(true) + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { + t.Fatalf("failed background load must be unknown, coverage=%v", got) + } + s.backgroundLoadFailed.Store(false) + s.retentionHours = 12 + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { + t.Fatalf("12h retention cannot cover 24h, coverage=%v", got) + } + s.retentionHours = 168 + s.packets = nil + if got := s.nodeActivityCoverageStartLocked(now, s.loadCoverageRatio); !got.IsZero() { + t.Fatalf("empty index cannot prove coverage, coverage=%v", got) + } +} + +// TestNodeActivity24hKnownButShorterCoverageIsIncomplete covers #351 F3 / +// #304 ("distinguish known zero activity from missing/incomplete coverage"). +// When coverage is KNOWN but reaches back less than the full 24h window, the +// window must report incomplete — a zero bucket inside the uncovered span is +// "unknown", not a confirmed quiet hour. +// +// Kills mutant M3: replacing `a.Complete = !coverageStart.After(start)` with +// an unconditional `a.Complete = true` whenever coverage is known. That +// mutant survived the entire cmd/server suite before this test (review +// finding 3). +func TestNodeActivity24hKnownButShorterCoverageIsIncomplete(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + + // Coverage known, but only 8h deep — short of the 24h window. + short := newNodeActivity24h(now, now.Add(-8*time.Hour)) + if short.CoverageStart == nil { + t.Fatalf("coverage start must be reported when it is known: %+v", short) + } + if short.Complete { + t.Fatalf("coverage reaching back only 8h must not mark a 24h window complete: %+v", short) + } + + // Boundary the mutant would also mask: coverage exactly at the window + // start IS complete, so the assertion above is testing the comparison, + // not merely that Complete is ever false. + if full := newNodeActivity24h(now, start); !full.Complete || full.CoverageStart == nil { + t.Fatalf("coverage back to the window start must be complete: %+v", full) + } + + // And one nanosecond past the start is still incomplete (half the point + // of the strict comparison). + if justShort := newNodeActivity24h(now, start.Add(time.Nanosecond)); justShort.Complete { + t.Fatalf("coverage starting after the window start must be incomplete: %+v", justShort) + } +} + +// TestNodeActivity24hAddPreFilterKeepsBoundaryPackets covers #351 F6: add()'s +// cheap string pre-filter trims ONLY the lower bound, so it must never drop an +// in-window packet. With a sub-second `now`, WindowEnd carries a fraction, so a +// whole-second stamp inside the final second sorts lexicographically AFTER +// WindowEnd — a string upper-bound filter would wrongly drop it; the exact time +// comparison must keep it. A stamp exactly at the (fractional) start is kept too. +func TestNodeActivity24hAddPreFilterKeepsBoundaryPackets(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 500_000_000, time.UTC) // .5s precision + start := now.Add(-24 * time.Hour) + + final := newNodeActivity24h(now, start) + // 100ms before now → final (23rd) bucket, whole-second precision. + final.add(&StoreTx{FirstSeen: now.Add(-100 * time.Millisecond).Truncate(time.Second).Format(time.RFC3339)}) + if final.Buckets[23].Count != 1 { + t.Fatalf("final-second whole-second packet must land in bucket 23, got %d", final.Buckets[23].Count) + } + + atStart := newNodeActivity24h(now, start) + atStart.add(&StoreTx{FirstSeen: start.Format(time.RFC3339Nano)}) + if atStart.Buckets[0].Count != 1 { + t.Fatalf("packet at the window start must land in bucket 0, got %d", atStart.Buckets[0].Count) + } + + // A stamp one second before the window start is older than the window and + // must be dropped (exercises the pre-filter's skip path). + before := newNodeActivity24h(now, start) + before.add(&StoreTx{FirstSeen: start.Add(-time.Second).Format(time.RFC3339Nano)}) + var total int + for _, b := range before.Buckets { + total += b.Count + } + if total != 0 { + t.Fatalf("pre-window packet must not be counted, got total %d", total) + } +} + +// TestNodeActivity24hPreFilterIgnoresFractionWidth covers #351 R3: +// RFC3339Nano trims trailing zeros, so WindowStart can carry fewer fraction +// digits than a stamp ("…:00Z" vs the ingestor's "…:00.100Z"), and a plain +// string compare then sorts an in-window stamp below the bound. The +// pre-filter must keep every in-window packet whatever the digit counts. +func TestNodeActivity24hPreFilterIgnoresFractionWidth(t *testing.T) { + cases := []struct { + name string + nanos int + offset time.Duration + layout string + }{ + {"whole-second now, ms stamp", 0, 100 * time.Millisecond, "2006-01-02T15:04:05.000Z07:00"}, + {"whole-second now, nano stamp", 0, time.Nanosecond, time.RFC3339Nano}, + {"trimmed .5 now, ms stamp", 500_000_000, time.Millisecond, "2006-01-02T15:04:05.000Z07:00"}, + {"trimmed .5 now, us stamp", 500_000_000, time.Microsecond, "2006-01-02T15:04:05.000000Z07:00"}, + {"exact start, ms stamp", 500_000_000, 0, "2006-01-02T15:04:05.000Z07:00"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, tc.nanos, time.UTC) + start := now.Add(-24 * time.Hour) + a := newNodeActivity24h(now, start) + a.add(&StoreTx{FirstSeen: start.Add(tc.offset).Format(tc.layout)}) + if a.Buckets[0].Count != 1 { + t.Fatalf("WindowStart=%s, stamp=%s: in-window packet dropped (bucket 0 = %d)", + a.WindowStart, start.Add(tc.offset).Format(tc.layout), a.Buckets[0].Count) + } + // One millisecond before the window must still be dropped. + b := newNodeActivity24h(now, start) + b.add(&StoreTx{FirstSeen: start.Add(-time.Millisecond).Format("2006-01-02T15:04:05.000Z07:00")}) + for i, bk := range b.Buckets { + if bk.Count != 0 { + t.Fatalf("pre-window packet counted in bucket %d", i) + } + } + }) + } +} + +// Test351F1StartupCoverageHonoursRetentionWindow covers #351 F1: the startup +// coverage ratio must be measured against the rows LoadChunked actually +// targeted (the retained window), not a raw COUNT(*) over the whole table. +// With the shipped default (retentionHours>0, hotStartupHours=0) and a DB +// holding history older than the retention window, the old whole-table +// denominator held loadCoverageRatio structurally below 1 forever, so the +// 24h activity gate could never prove completeness and the chart showed +// "Activity unavailable" permanently. Both startup paths are exercised. +func Test351F1StartupCoverageHonoursRetentionWindow(t *testing.T) { + now := time.Now().UTC() + windowStart := now.Add(-24 * time.Hour) + + // 30 daily rows: id 1 = today ... id 30 = 29 days ago. Only the first + // ~8 fall inside a 168h (7-day) retention window; the whole table is 30, + // so a whole-table ratio would be ~0.27. + seed := func(dbPath string) { + seedTestDBRows(t, dbPath, 30, 1, func(i int) (string, int64) { + ts := now.Add(-time.Duration(i-1) * 24 * time.Hour) + return ts.Format(time.RFC3339), ts.Unix() + }) + } + + assertComplete := func(t *testing.T, store *PacketStore) { + t.Helper() + if store.loadCoverageRatio < 1 { + t.Fatalf("loadCoverageRatio=%.4f < 1: coverage measured against the whole table "+ + "instead of the retained window (finding 1)", store.loadCoverageRatio) + } + store.mu.RLock() + cov := store.nodeActivityCoverageStartLocked(now, store.loadCoverageRatio) + store.mu.RUnlock() + if cov.IsZero() || cov.After(windowStart) { + t.Fatalf("24h coverage gate must prove completeness when 7+ days of retained "+ + "history are in memory; got coverageStart=%v (windowStart=%v)", cov, windowStart) + } + } + + t.Run("hotStartupHours=0 synchronous full load", func(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + seed(dbPath) + db, err := OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer db.conn.Close() + store := NewPacketStore(db, &PacketStoreConfig{RetentionHours: 168, HotStartupHours: 0}) + if err := store.RunStartupLoad(500); err != nil { + t.Fatalf("RunStartupLoad: %v", err) + } + assertComplete(t, store) + }) + + t.Run("hotStartupHours=24 hot window plus background fill", func(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + seed(dbPath) + db, err := OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer db.conn.Close() + store := NewPacketStore(db, &PacketStoreConfig{RetentionHours: 168, HotStartupHours: 24}) + if err := store.RunStartupLoad(500); err != nil { + t.Fatalf("RunStartupLoad: %v", err) + } + assertComplete(t, store) + }) +} + +func BenchmarkNodeActivity24h30K(b *testing.B) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + packets := make([]*StoreTx, 30000) + for i := range packets { + packets[i] = &StoreTx{ID: i + 1, FirstSeen: now.Add(-time.Duration(i%1440) * time.Minute).Format(time.RFC3339)} + } + b.ResetTimer() + for n := 0; n < b.N; n++ { + a := newNodeActivity24h(now, now.Add(-25*time.Hour)) + for _, tx := range packets { + a.add(tx) + } + if a.Buckets[23].Count == 0 { + b.Fatal("unexpected empty final hour") + } + } +} diff --git a/cmd/server/node_liveness_regression_test.go b/cmd/server/node_liveness_regression_test.go index d5291f3eb..d9d847643 100644 --- a/cmd/server/node_liveness_regression_test.go +++ b/cmd/server/node_liveness_regression_test.go @@ -32,7 +32,7 @@ func TestNodeHealth_LastAdvertIsOwnAdvertNotTraffic(t *testing.T) { packets = append([]*StoreTx{advert}, packets...) } store.byNode[key] = packets - health, err := store.GetNodeHealth(key) + health, err := store.GetNodeHealth(key, time.Now().UTC()) if err != nil { t.Fatal(err) } @@ -217,7 +217,7 @@ func snapshotNodeActivity(t *testing.T, store *PacketStore, key string) activity bulkRelay = RepeaterRelayInfo{WindowHours: 24} } snap.bulkRelay = bulkRelay - health, err := store.GetNodeHealth(key) + health, err := store.GetNodeHealth(key, time.Now().UTC()) if err != nil || health == nil { t.Fatalf("health for %s: %v %v", key, health, err) } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 111439f94..093fef64f 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2102,7 +2102,7 @@ func (s *Server) handleNodeHealth(w http.ResponseWriter, r *http.Request) { return } if s.store != nil { - result, err := s.store.GetNodeHealth(pubkey) + result, err := s.store.GetNodeHealth(pubkey, time.Now().UTC()) if err != nil || result == nil { writeError(w, 404, "Not found") return diff --git a/cmd/server/store.go b/cmd/server/store.go index 7ad0237ad..42c21c659 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -543,6 +543,19 @@ type PacketStore struct { // perf payload for prod observability. loadCoverageRatio float64 + // loadWindowTotalInDB: row count LoadChunked actually targeted — i.e. + // COUNT(*) honouring the SAME retention/hot-start filter the chunk loop + // applied, not the whole table. #351 F1: RunStartupLoad's + // hotStartupHours==0 branch divides loadedCount by THIS (not the + // unfiltered table count) so loadCoverageRatio means "fraction of the + // RETAINED rows that are in memory", matching docs/api-spec.md and the + // bg-loader path. -1 when the count could not be taken. + // CONCURRENCY: written once by LoadChunked and read once by + // RunStartupLoad on the SAME goroutine (RunStartupLoad calls LoadChunked + // synchronously, then reads this after it returns), so no lock is + // needed; no other goroutine touches it. + loadWindowTotalInDB int64 + // Async hash migration state: set after migrateContentHashesAsync completes. hashMigrationComplete atomic.Bool @@ -10449,7 +10462,10 @@ func (s *PacketStore) GetBulkHealth(limit int, region, area string) []map[string // --- Subpaths Analytics --- // GetNodeHealth returns health info for a single node using in-memory data. -func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, error) { +// #351 F5: now is injected so tests can pin the rolling 24h activity window +// without a second untyped-map-returning helper (AGENTS.md: the untyped-payload +// count must not grow). Production callers pass time.Now().UTC(); see routes.go. +func (s *PacketStore) GetNodeHealth(pubkey string, now time.Time) (map[string]interface{}, error) { // Fetch node info from DB (fast single-row lookup) node, err := s.db.GetNodeByPubkey(pubkey) if err != nil { @@ -10472,12 +10488,20 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro } } + // #351 F4: snapshot the bgErrMu-guarded coverage ratio BEFORE taking + // s.mu so this reader honours bgErrMu's "no s.mu needed" contract + // (store.go:530) instead of being the first site to nest s.mu → bgErrMu. + s.bgErrMu.RLock() + loadCoverage := s.loadCoverageRatio + s.bgErrMu.RUnlock() + s.mu.RLock() defer s.mu.RUnlock() packets := s.byNode[pubkey] activityKey := strings.ToLower(pubkey) - todayStart := time.Now().UTC().Truncate(24 * time.Hour).Format(time.RFC3339) + todayStart := now.UTC().Truncate(24 * time.Hour).Format(time.RFC3339) + activity := newNodeActivity24h(now, s.nodeActivityCoverageStartLocked(now, loadCoverage)) var packetsToday int var snrSum float64 @@ -10494,6 +10518,7 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro }{} for _, pkt := range packets { + activity.add(pkt) totalObservations += pkt.ObservationCount if pkt.FirstSeen > todayStart { packetsToday++ @@ -10628,6 +10653,7 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro AvgHops: &avgHops, LastHeard: timestampPointer(lastHeard), LastAdvert: timestampPointer(lastAdvert), }, "recentPackets": recentPackets, + "activity24h": activity, }, nil } diff --git a/docs/api-spec.md b/docs/api-spec.md index 56e9d5d6e..56f8adbb8 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -786,10 +786,33 @@ Detailed health information for a single node. // Packet fields (see Packet Object) minus `observations` "observation_count": number // added for display } - ] + ], + "activity24h": { + "windowStart": string (ISO UTC), // inclusive: request time minus 24 hours + "windowEnd": string (ISO UTC), // exclusive: request time + "buckets": [ // always 24 rolling one-hour buckets, oldest first + { "start": string (ISO UTC), "end": string (ISO UTC), "count": number } + ], + "coverageStart": string (ISO UTC) | null, + "complete": boolean + } } ``` +`activity24h.count` counts unique transmissions associated with this node in +the in-memory node index, using each transmission's `first_seen` time. Multiple +observations of one transmission count once. Association can come from node +identity or a resolved path; it does not prove that a repeater forwarded the +packet. Bucket boundaries are half-open `[start, end)` and move with request +time rather than aligning to clock hours. A malformed `first_seen` is ignored. +`complete` is true only when successful loading reports 100% of the retained +transmissions in memory and the retention/index bounds cover the whole 24-hour +window. The server's general background-load “done” threshold of 90% is not +enough for this field. If `complete` is false, zero-count +buckets before `coverageStart` represent unknown history rather than confirmed +silence. A null `coverageStart` means coverage cannot be established (for +example during startup or after a failed load). + ### Response `404` ```json diff --git a/public/home.css b/public/home.css index 8b01c2c31..86b95d7c5 100644 --- a/public/home.css +++ b/public/home.css @@ -471,9 +471,24 @@ .mnc-val { font: 700 1rem var(--font); color: var(--text); } .mnc-lbl { font-size: .65rem; color: var(--text-muted); margin-top: 2px; } -.mnc-observers { font-size: .75rem; color: var(--text-muted); margin-bottom: 8px; line-height: 1.4; } +.mnc-observers { font-size: .75rem; color: var(--text-muted); margin-bottom: 8px; line-height: 1.4; min-height: 22px; } +.mnc-observer-name { display: inline-block; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; } +.mnc-view-all { margin-left: 4px; } +.mnc-observers-dialog { + width: min(440px, calc(100vw - 24px)); max-height: min(70vh, 600px); + position: fixed; inset: 0; margin: auto; + padding: 20px; border: 1px solid var(--border); border-radius: 12px; + background: var(--surface-1); color: var(--text); +} +.mnc-observers-dialog::backdrop { background: var(--surface-0); opacity: .8; } +.mnc-dialog-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.mnc-dialog-header h2 { margin: 0 0 12px; font-size: 1rem; overflow-wrap: anywhere; } +.mnc-dialog-close { border: 1px solid var(--border); border-radius: 6px; padding: 7px 10px; background: var(--surface-2); color: var(--text); cursor: pointer; } +.mnc-dialog-list { max-height: 50vh; overflow: auto; margin: 0; padding-left: 24px; } +.mnc-dialog-list li { overflow-wrap: anywhere; margin-bottom: 5px; } .mnc-spark { margin-bottom: 8px; } +.home-spark-unavailable { font-size: .7rem; color: var(--text-muted); } .spark-label { font-size: .65rem; color: var(--text-muted); margin-bottom: 4px; } .spark-bars { display: flex; align-items: flex-end; gap: 2px; height: 28px; } .spark-bar { flex: 1; background: var(--accent); border-radius: 1px; min-width: 0; } diff --git a/public/home.js b/public/home.js index 7c456d937..3c6269f3b 100644 --- a/public/home.js +++ b/public/home.js @@ -115,6 +115,10 @@ ${hasNodes ? '
Loading your nodes…
' : '
'} + +

+ +
${!hasNodes ? `
@@ -263,6 +267,8 @@ clearTimeout(searchTimeout); if (searchAbort) { searchAbort.abort(); searchAbort = null; } if (miniMap) { miniMap.remove(); miniMap = null; } + const observersDialog = document.getElementById('homeObserversDialog'); + if (observersDialog?.open) observersDialog.close(); if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } @@ -288,12 +294,15 @@ return; } + const observerLists = new Map(); + const cards = await Promise.all(myNodes.map(async (mn) => { try { const h = await api('/nodes/' + encodeURIComponent(mn.pubkey) + '/health', { ttl: CLIENT_TTL.nodeHealth }); const node = h.node || {}; const stats = h.stats || {}; const obs = h.observers || []; + observerLists.set(mn.pubkey, obs); const age = stats.lastHeard ? Date.now() - new Date(stats.lastHeard).getTime() : null; const status = age === null ? 'silent' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent'; @@ -307,8 +316,19 @@ const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : null; const snrColor = snrVal != null ? (snrVal > 10 ? 'var(--status-green)' : snrVal > 0 ? 'var(--accent)' : snrVal > -5 ? 'var(--status-yellow)' : 'var(--status-red)') : '#6b7280'; - // Build sparkline from recent packets (packet timestamps → hourly buckets) - const sparkHtml = buildSparkline(h.recentPackets || []); + const sparkHtml = buildSparkline(h.activity24h); + const isRepeater = String(node.role || '').toLowerCase() === 'repeater'; + const preview = isRepeater ? obs.slice(0, 3) : obs; + // #351 F2/R1: a name wider than the CSS max-width: 14ch clamp is + // clipped with an ellipsis. The full name must stay recoverable on + // EVERY card. Each span carries a title (pointer + assistive tech), + // and the accessible dialog (keyboard + touch) is offered when the + // preview hides names. Otherwise the button starts hidden and + // revealClippedObserverButtons() shows it after layout if a name is + // actually clipped — glyph width, not character count, decides that. + const obsFullName = o => o.observer_name || o.observer_id || 'Unknown'; + const hiddenObservers = obs.length - preview.length; + const observerBtnText = hiddenObservers > 0 ? `View all ${obs.length} →` : 'Full names →'; return `
@@ -336,8 +356,8 @@
Avg hops
- ${obs.length ? `
Heard by: ${obs.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ')}
` : ''} - ${sparkHtml ? `
${sparkHtml}
` : ''} + ${obs.length ? `
Heard by: ${preview.map(o => `${escapeHtml(obsFullName(o))}`).join(', ')}
` : ''} +
${sparkHtml}
@@ -365,6 +385,7 @@ })); grid.innerHTML = cards.join(''); + revealClippedObserverButtons(grid); // Wire up remove buttons grid.querySelectorAll('.mnc-remove').forEach(btn => { @@ -385,6 +406,7 @@ if (btn.dataset.action === 'node') window.location.hash = '#/nodes/' + encodeURIComponent(btn.dataset.key); if (btn.dataset.action === 'health') loadHealth(btn.dataset.key); if (btn.dataset.action === 'packets') window.location.hash = '#/packets/' + btn.dataset.key; + if (btn.dataset.action === 'observers') showObservers(btn, observerLists.get(btn.dataset.key) || []); }); }); @@ -407,23 +429,52 @@ }); } - function buildSparkline(packets) { - if (!packets.length) return ''; - // Group into hourly buckets over last 24h - const now = Date.now(); - const buckets = new Array(24).fill(0); - packets.forEach(p => { - const t = new Date(p.timestamp || p.created_at).getTime(); - const hoursAgo = Math.floor((now - t) / 3600000); - if (hoursAgo >= 0 && hoursAgo < 24) buckets[23 - hoursAgo]++; + // #351 R1: measure after layout. scrollWidth > clientWidth is exactly when + // the ellipsis is drawn. All reads happen before any write, so the whole + // grid costs one layout pass. + function revealClippedObserverButtons(grid) { + const clipped = [...grid.querySelectorAll('.mnc-view-all[hidden]')].filter(btn => + [...btn.parentElement.querySelectorAll('.mnc-observer-name')].some(el => el.scrollWidth > el.clientWidth)); + clipped.forEach(btn => { btn.hidden = false; }); + } + + function showObservers(trigger, observers) { + const dialog = document.getElementById('homeObserversDialog'); + if (!dialog) return; + const cardName = trigger.closest('.my-node-card')?.querySelector('.mnc-name')?.textContent || 'Node'; + dialog.querySelector('#homeObserversTitle').textContent = `${cardName} · ${observers.length} observers`; + const list = dialog.querySelector('.mnc-dialog-list'); + const fragment = document.createDocumentFragment(); + observers.forEach(observer => { + const item = document.createElement('li'); + item.textContent = observer.observer_name || observer.observer_id || 'Unknown'; + fragment.appendChild(item); }); - const max = Math.max(...buckets, 1); - const bars = buckets.map(v => { - const h = Math.max(2, Math.round((v / max) * 24)); - const opacity = v > 0 ? 0.4 + (v / max) * 0.6 : 0.1; - return `
`; + list.replaceChildren(fragment); + dialog.querySelector('.mnc-dialog-close').onclick = () => dialog.close(); + dialog.onclick = (event) => { + if (event.target !== dialog) return; + const rect = dialog.getBoundingClientRect(); + if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) dialog.close(); + }; + dialog.onclose = () => { if (trigger.isConnected) trigger.focus(); }; + dialog.showModal(); + } + + function buildSparkline(activity) { + const label = '
Node-associated transmissions · last 24h
'; + const buckets = activity?.buckets; + const valid = activity?.complete === true && Array.isArray(buckets) && buckets.length === 24 && + buckets.every(b => Number.isInteger(b.count) && b.count >= 0 && Number.isFinite(Date.parse(b.start)) && Number.isFinite(Date.parse(b.end))); + if (!valid) return label + '
Activity unavailable: 24h history incomplete
'; + const max = Math.max(1, ...buckets.map(b => b.count)); + const bars = buckets.map(b => { + const height = Math.max(2, Math.round((b.count / max) * 24)); + const opacity = b.count > 0 ? 0.4 + (b.count / max) * 0.6 : 0.1; + const range = `${new Date(b.start).toLocaleString()} to ${new Date(b.end).toLocaleString()}`; + return ``; }).join(''); - return `
24h activity
${bars}
`; + return `${label}
${bars}
`; } // ==================== STATS ==================== diff --git a/test-issue-304-my-mesh-e2e.js b/test-issue-304-my-mesh-e2e.js new file mode 100644 index 000000000..cccca3f78 --- /dev/null +++ b/test-issue-304-my-mesh-e2e.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +const ROOT = path.join(__dirname, 'public'); +const ORIGIN = 'http://127.0.0.1:18740'; +const KEYS = ['a', 'b', 'c', 'd', 'e', 'f', '1', '2'].map(c => c.repeat(64)); +// #351 F2: a name wider than the 14ch clamp. Matches the review's repro name. +const CLIP = 'Jackrabbit Mountain Relay North'; +// #351 R1: short (≤14 chars) but WIDE names. The clamp is CSS max-width: 14ch +// (fourteen "0" glyphs), so uppercase/wide glyphs clip well before 14 chars. +// WIDE clips in any sans font; the others are font-dependent and are checked +// by the measured invariant below rather than asserted to clip. +const WIDE = 'WWWWWWWWWWWWWW'; +const SHORT_WIDE = ['MOUNT WOLFHAWK', 'MMMMMMMMMMMMM', 'ØSTERBRO MOLE']; +// #351 R2: a quote breaks out of an attribute that is not escapeAttr'd. +const QUOTE = 'x" onmouseover="alert(1)'; +const now = Date.now(); +const end = new Date(now).toISOString(); +const start = new Date(now - 24 * 3600000).toISOString(); +const activity = (complete = true) => ({ + windowStart: start, windowEnd: end, coverageStart: complete ? start : new Date(now - 8 * 3600000).toISOString(), complete, + buckets: Array.from({ length: 24 }, (_, i) => ({ + start: new Date(now - (24 - i) * 3600000).toISOString(), + end: new Date(now - (23 - i) * 3600000).toISOString(), + count: i === 3 ? 35 : 0, + })), +}); + +async function render(page, responses, width, height) { + await page.setViewportSize({ width, height }); + await page.route('**/*', route => { + const u = new URL(route.request().url()); + if (u.origin !== ORIGIN) return route.abort(); + if (u.pathname === '/icons/phosphor-sprite.svg') return route.fulfill({ contentType: 'image/svg+xml', body: fs.readFileSync(path.join(ROOT, 'icons/phosphor-sprite.svg')) }); + return route.fulfill({ contentType: 'text/html', body: '
' }); + }); + await page.goto(ORIGIN + '/'); + await page.addStyleTag({ path: path.join(ROOT, 'style.css') }); + await page.addStyleTag({ path: path.join(ROOT, 'home.css') }); + for (const file of ['app.js', 'roles.js']) await page.addScriptTag({ path: path.join(ROOT, file) }); + await page.evaluate((data) => { + window.removeEventListener('hashchange', navigate); + window.fixturePages = {}; + registerPage = (name, mod) => { window.fixturePages[name] = mod; }; + api = async (route) => { + if (route === '/stats') return { totalNodes: 3 }; + if (route in data) return data[route]; + throw Error('Unexpected API: ' + route); + }; + localStorage.setItem('meshcore-user-level', 'experienced'); + localStorage.setItem('meshcore-my-nodes', JSON.stringify(Object.keys(data).map(route => ({ pubkey: route.split('/')[2], name: 'Node' })))); + }, responses); + await page.addScriptTag({ path: path.join(ROOT, 'home.js') }); + await page.evaluate(() => window.fixturePages.home.init(document.getElementById('fixture'))); + await page.waitForFunction((n) => document.querySelectorAll('.my-node-card').length === n, Object.keys(responses).length); +} + +(async () => { + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); + try { + for (const [width, height] of [[1280, 900], [375, 812]]) { + const context = await browser.newContext({ viewport: { width, height }, hasTouch: width < 500, isMobile: width < 500 }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + const names = Array.from({ length: 51 }, (_, i) => i === 50 ? '' : `Observer ${i}`); + names[0] = QUOTE; + names[1] = 'Very-long-observer-name-'.repeat(25); + names[2] = ''; + // Card roles are mixed (#304): a 51-observer repeater, a 1-observer + // repeater, an observer-less repeater, a ≤3-observer repeater with a + // clipped name, and a NON-repeater with a clipped name. + const specs = [ + { role: 'repeater', observers: names.map(observer_name => ({ observer_name })), activity: activity() }, + { role: 'repeater', observers: [{ observer_name: 'One' }], activity: activity(false) }, + { role: 'repeater', observers: [], activity: undefined }, + { role: 'repeater', observers: [{ observer_name: CLIP }], activity: activity(false) }, + { role: 'client', observers: [{ observer_name: CLIP }, { observer_name: 'Shorty' }], activity: activity(false) }, + { role: 'repeater', observers: [{ observer_name: WIDE }], activity: activity(false) }, + { role: 'repeater', observers: SHORT_WIDE.map(observer_name => ({ observer_name })), activity: activity(false) }, + { role: 'client', observers: [{ observer_name: 'Shorty' }, ...SHORT_WIDE.map(observer_name => ({ observer_name }))], activity: activity(false) }, + ]; + const data = Object.fromEntries(specs.map((spec, i) => [`/nodes/${KEYS[i]}/health`, { + node: { name: `Node ${i}`, role: spec.role }, stats: { lastHeard: end, packetsToday: 2 }, + observers: spec.observers, recentPackets: [], activity24h: spec.activity, + }])); + await render(page, data, width, height); + if (process.env.SCREENSHOT_PATH && width === 1280) { + await page.screenshot({ path: process.env.SCREENSHOT_PATH, fullPage: true }); + } + const cards = page.locator('.my-node-card'); + const large = cards.nth(0); + assert.equal(await large.locator('.mnc-observers .mnc-observer-name').count(), 3); + assert.equal(await large.locator('.mnc-observers .mnc-observer-name').nth(1).evaluate(el => el.scrollWidth > el.clientWidth), true); + assert.equal(await large.locator('.mnc-observers .mnc-observer-name').nth(1).getAttribute('title'), names[1]); + assert.equal(await large.locator('.mnc-observers svg').count(), 0); + assert.equal(await cards.nth(1).locator('.mnc-view-all:visible').count(), 0); + assert.equal(await cards.nth(2).locator('.mnc-observers').count(), 0); + assert.equal(await large.locator('.home-spark-bar').count(), 24); + assert.match(await large.locator('.mnc-spark').innerText(), /Node-associated transmissions.*last 24h/); + assert.match(await large.locator('.home-spark-bar').nth(3).getAttribute('aria-label'), /35 node-associated transmissions/); + assert.match(await large.locator('.home-spark-bar').nth(3).getAttribute('title'), /35 node-associated transmissions/); + assert.match(await large.locator('.home-spark-bar').first().getAttribute('aria-label'), /0 node-associated transmissions/); + assert.equal(await cards.nth(1).locator('.home-spark-bar').count(), 0); + assert.match(await cards.nth(1).locator('.mnc-spark').innerText(), /unavailable/i); + assert.equal(await cards.nth(2).locator('.home-spark-bar').count(), 0); + assert.match(await cards.nth(2).locator('.mnc-spark').innerText(), /unavailable/i); + const heights = await cards.evaluateAll(els => els.map(el => el.getBoundingClientRect().height)); + assert(heights[0] < 350, `repeater card too tall: ${heights[0]} at ${width}px`); + const open = large.locator('.mnc-view-all'); + await open.focus(); + await page.keyboard.press('Enter'); + const dialog = page.locator('#homeObserversDialog'); + await dialog.waitFor({ state: 'visible' }); + assert.equal(await dialog.locator('li').count(), 51); + assert.equal(await dialog.locator('img').count(), 0); + assert.equal(await page.locator('#homeHealth.visible').count(), 0); + await page.keyboard.press('Escape'); + assert.equal(await dialog.isVisible(), false); + assert.equal(await open.evaluate(el => el === document.activeElement), true); + await open.click(); + await dialog.waitFor({ state: 'visible' }); + const dialogBox = await dialog.boundingBox(); + assert(dialogBox && dialogBox.width <= width - 16, 'dialog must fit viewport'); + await page.mouse.click(2, 2); + assert.equal(await dialog.isVisible(), false); + if (width < 500) { + await open.tap(); + await dialog.waitFor({ state: 'visible' }); + await dialog.locator('.mnc-dialog-close').tap(); + } + + // #351 F2: clipped names stay recoverable on EVERY card — a ≤3-observer + // repeater (card 3) and a NON-repeater (card 4), not only repeaters + // with >3 observers. Title tooltip (pointer/AT) + accessible dialog + // (keyboard/touch), and the cards stay compact. + for (const idx of [3, 4]) { + const card = cards.nth(idx); + const span = card.locator('.mnc-observer-name').first(); + assert.equal(await span.evaluate(el => el.scrollWidth > el.clientWidth), true, `card ${idx} name must clip at ${width}px`); + assert.equal(await span.getAttribute('title'), CLIP, `card ${idx} span must carry the full name in title`); + assert.equal(await card.locator('.mnc-view-all:visible').count(), 1, `card ${idx} must offer the observer dialog`); + } + assert.equal(await cards.nth(4).locator('.mnc-observer-name').count(), 2); + const clipBtn = cards.nth(4).locator('.mnc-view-all'); + await clipBtn.scrollIntoViewIfNeeded(); + await clipBtn.click(); + await dialog.waitFor({ state: 'visible' }); + assert((await dialog.locator('li').allInnerTexts()).includes(CLIP), 'dialog must list the full clipped name'); + await page.keyboard.press('Escape'); + assert.equal(await dialog.isVisible(), false); + const h = await cards.evaluateAll(els => els.map(el => el.getBoundingClientRect().height)); + assert(h[3] < 350 && h[4] < 350, `clipped cards too tall: ${h[3]}, ${h[4]} at ${width}px`); + + // #351 R1: "clipped" is what the browser rendered, not a character + // count. On EVERY card the dialog button is visible exactly when the + // preview hides observers or some shown name is visually clipped + // (scrollWidth > clientWidth: the case where the ellipsis is drawn). + const disclosure = await cards.evaluateAll(els => els.map(card => { + const btn = card.querySelector('.mnc-view-all'); + return { + clipped: [...card.querySelectorAll('.mnc-observer-name')].filter(s => s.scrollWidth > s.clientWidth).map(s => s.textContent), + visible: !!btn && btn.checkVisibility(), + }; + })); + disclosure.forEach((d, idx) => { + const hides = specs[idx].role === 'repeater' && specs[idx].observers.length > 3; + assert.equal(d.visible, hides || d.clipped.length > 0, + `card ${idx} at ${width}px: clipped=${JSON.stringify(d.clipped)} hides=${hides} but button visible=${d.visible}`); + }); + assert.deepEqual(disclosure[5].clipped, [WIDE], `a 14-char wide name must clip at ${width}px`); + const wideBtn = cards.nth(5).locator('.mnc-view-all'); + await wideBtn.scrollIntoViewIfNeeded(); + if (width < 500) await wideBtn.tap(); else await wideBtn.click(); + await dialog.waitFor({ state: 'visible' }); + assert.deepEqual(await dialog.locator('li').allInnerTexts(), [WIDE], 'dialog must list the full short-but-wide name'); + await page.keyboard.press('Escape'); + assert.equal(await dialog.isVisible(), false); + + // #351 R2: a name with a double quote must stay inside the title + // attribute — no injected attributes on any observer span. + const attrs = await page.locator('.mnc-observer-name').evaluateAll(els => els.map(el => ({ names: el.getAttributeNames().sort(), title: el.title, text: el.textContent }))); + attrs.forEach(a => { + assert.deepEqual(a.names, ['class', 'title'], `observer span gained attributes: ${a.names}`); + assert.equal(a.title, a.text, 'title must carry the full, unaltered name'); + }); + assert.equal(attrs.filter(a => a.title === QUOTE).length, 1, 'quoted name must render verbatim'); + assert.equal(await page.locator('[onmouseover]').count(), 0); + + assert.deepEqual(errors, []); + await context.close(); + } + console.log('My Mesh #304: desktop/mobile card, sparkline, coverage and observer dialog passed'); + } finally { await browser.close(); } +})().catch(e => { console.error(e); process.exitCode = 1; });