Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot 35e1f46b36 test(#1085): E2E for Roles fold-in into Analytics tab
Adds three failing assertions covering the acceptance criteria:
1. Top nav must NOT contain a 'Roles' link
2. Analytics page must include a [data-tab=roles] tab that renders Roles content
3. Old #/roles URL must redirect to #/analytics?tab=roles

Replaces the legacy 'Roles page renders distribution table' E2E (issue #818)
which assumed a standalone /#/roles SPA page.

Red commit — production code in a follow-up.
2026-05-05 09:13:23 +00:00
109 changed files with 564 additions and 16692 deletions
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"e2e tests","message":"1178 passed","color":"brightgreen"}
{"schemaVersion":1,"label":"e2e tests","message":"104 passed","color":"brightgreen"}
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"label":"frontend coverage","message":"39.03%","color":"red"}
{"schemaVersion":1,"label":"frontend coverage","message":"38.41%","color":"red"}
-42
View File
@@ -79,12 +79,6 @@ jobs:
go test ./...
echo "--- Decrypt CLI tests passed ---"
- name: Lint CSS variables (issue #1128)
run: |
set -e
node scripts/check-css-vars.js
node scripts/test-check-css-vars.js
- name: Run JS unit tests (packet-filter)
run: |
set -e
@@ -92,14 +86,9 @@ jobs:
node test-packet-filter-time.js
node test-channel-decrypt-insecure-context.js
node test-live-region-filter.js
node test-issue-1136-observer-iata-map.js
node test-channel-qr.js
node test-channel-qr-wiring.js
node test-channel-modal-ux.js
node test-channel-issue-1087.js
node test-channel-issue-1101.js
node test-pull-to-reconnect-1091.js
node test-channel-fluid-layout.js
- name: Verify proto syntax
run: |
@@ -223,37 +212,6 @@ jobs:
run: |
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
BASE_URL=http://localhost:13581 node test-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1185-scroll-discriminator-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gesture-hints-1065-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-charts-fluid-1058-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-slideover-1056-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1122-packets-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1128-multi-viewport-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-issue-1151-orphan-separators-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-rebrand-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-theme-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-default-sage-teal-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1109-hamburger-dropdown-visible-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
-7
View File
@@ -1,12 +1,5 @@
# Changelog
## [3.7.2] — 2026-05-06
Hotfix release branched from `v3.7.1`. Cherry-picks PR #1121 only — no other changes.
### 🐛 Bug Fixes
- **Ingestor: backfill infinite loop on `path_json='[]'` rows** (#1119, #1121) — `BackfillPathJSONAsync` re-selected observations whose `path_json` was already `'[]'`, rewrote them to `'[]'`, and looped forever. The migration marker was never recorded and the ingestor sustained 23 MB/s WAL writes at idle (~76% CPU in `sqlite.Exec`). Fix: drop `'[]'` from the WHERE clause so the loop terminates after one full pass and the `backfill_path_json_from_raw_hex_v1` marker is written.
## [2.5.0] "Digital Rain" — 2026-03-22
### ✨ Matrix Mode — Full Cyberpunk Map Theme
-2
View File
@@ -19,7 +19,6 @@ COPY internal/geofilter/ ../../internal/geofilter/
COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/perfio/ ../../internal/perfio/
RUN go mod download
COPY cmd/server/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
@@ -32,7 +31,6 @@ COPY internal/geofilter/ ../../internal/geofilter/
COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/perfio/ ../../internal/perfio/
RUN go mod download
COPY cmd/ingestor/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
-18
View File
@@ -47,24 +47,6 @@ The config file uses the same format as the Node.js `config.json`. The ingestor
| `DB_PATH` | SQLite database path | `data/meshcore.db` |
| `MQTT_BROKER` | Single MQTT broker URL (overrides config) | — |
| `MQTT_TOPIC` | MQTT topic (used with `MQTT_BROKER`) | `meshcore/#` |
| `CORESCOPE_INGESTOR_STATS` | Path to the per-second stats JSON file consumed by the server's `/api/perf/io` and `/api/perf/write-sources` endpoints (#1120) | `/tmp/corescope-ingestor-stats.json` |
### Stats file (`CORESCOPE_INGESTOR_STATS`)
Every second the ingestor publishes a JSON snapshot of its counters
(`tx_inserted`, `obs_inserted`, `walCommits`, `backfillUpdates.*`, etc.) plus
a `procIO` block sampled from `/proc/self/io` (read/write/cancelled bytes per
second + syscall counts). The server reads this file and surfaces the data on
the Perf page so operators can self-diagnose write-volume anomalies.
The writer uses `O_NOFOLLOW | O_CREAT | O_TRUNC` mode `0o600`, so a
pre-planted symlink at the path cannot be used to clobber an arbitrary file.
**Security note:** the default lives in `/tmp`, which is world-writable on
most hosts (sticky bit only protects deletion, not creation). On
shared/multi-tenant hosts, override `CORESCOPE_INGESTOR_STATS` to point at a
private directory (e.g. `/var/lib/corescope/ingestor-stats.json`) that only
the corescope user can write to.
### Minimal Config
+4 -80
View File
@@ -25,38 +25,6 @@ type DBStats struct {
ObserverUpserts atomic.Int64
WriteErrors atomic.Int64
SignatureDrops atomic.Int64
// WALCommits tracks every successful tx.Commit() that may have flushed
// WAL pages.
WALCommits atomic.Int64
// BackfillUpdates tracks per-named-backfill row write counts so an
// infinite-loop backfill (cf #1119) is obvious from the perf page.
BackfillUpdates sync.Map // name (string) -> *atomic.Int64
}
// IncBackfill increments the backfill counter for the given name, allocating
// the counter on first use.
func (s *DBStats) IncBackfill(name string) {
v, ok := s.BackfillUpdates.Load(name)
if !ok {
nc := new(atomic.Int64)
actual, loaded := s.BackfillUpdates.LoadOrStore(name, nc)
if loaded {
v = actual
} else {
v = nc
}
}
v.(*atomic.Int64).Add(1)
}
// SnapshotBackfills returns a name->count copy of all backfill counters.
func (s *DBStats) SnapshotBackfills() map[string]int64 {
out := make(map[string]int64)
s.BackfillUpdates.Range(func(k, v interface{}) bool {
out[k.(string)] = v.(*atomic.Int64).Load()
return true
})
return out
}
// Store wraps the SQLite database for packet ingestion.
@@ -183,15 +151,12 @@ func applySchema(db *sql.DB) error {
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
from_pubkey TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash);
CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen);
CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type);
-- idx_transmissions_from_pubkey is created by the from_pubkey_v1
-- migration after the column is added on legacy DBs (#1143).
`
if _, err := db.Exec(schema); err != nil {
return fmt.Errorf("base schema: %w", err)
@@ -253,16 +218,11 @@ func applySchema(db *sql.DB) error {
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'advert_count_unique_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Recalculating advert_count (unique transmissions only)...")
// Note: this migration is gated on a one-shot _migrations row, so it
// runs at most once per DB. The historical version used a LIKE-on-JSON
// substring match (#1143). Switching to from_pubkey here is safe even
// though the column may not yet be backfilled on legacy DBs: the
// migration is already marked done on those DBs and won't re-run.
db.Exec(`
UPDATE nodes SET advert_count = (
SELECT COUNT(*) FROM transmissions t
WHERE t.payload_type = 4
AND t.from_pubkey = nodes.public_key
AND t.decoded_json LIKE '%' || nodes.public_key || '%'
)
`)
db.Exec(`INSERT INTO _migrations (name) VALUES ('advert_count_unique_v1')`)
@@ -524,24 +484,6 @@ func applySchema(db *sql.DB) error {
log.Println("[migration] foreign_advert column added")
}
// Migration: from_pubkey column on transmissions (#1143).
// Replaces the unsound `decoded_json LIKE '%pubkey%'` attribution path with
// an exact-match indexed column. Synchronously adds the column + index;
// row-level backfill is run by the SERVER asynchronously
// (cmd/server/from_pubkey_migration.go) so we don't block ingestor boot.
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'from_pubkey_v1'")
if row.Scan(&migDone) != nil {
log.Println("[migration] Adding from_pubkey column + index to transmissions (#1143)...")
if _, err := db.Exec(`ALTER TABLE transmissions ADD COLUMN from_pubkey TEXT`); err != nil {
log.Printf("[migration] transmissions.from_pubkey: %v (may already exist)", err)
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`); err != nil {
log.Printf("[migration] idx_transmissions_from_pubkey: %v", err)
}
db.Exec(`INSERT INTO _migrations (name) VALUES ('from_pubkey_v1')`)
log.Println("[migration] from_pubkey column + index added")
}
return nil
}
@@ -554,8 +496,8 @@ func (s *Store) prepareStatements() error {
}
s.stmtInsertTransmission, err = s.db.Prepare(`
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash, from_pubkey)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return err
@@ -684,7 +626,6 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
data.RawHex, hash, now,
data.RouteType, data.PayloadType, data.PayloadVersion,
data.DecodedJSON, nilIfEmpty(data.ChannelHash),
nilIfEmpty(data.FromPubkey),
)
if err != nil {
s.Stats.WriteErrors.Add(1)
@@ -729,10 +670,6 @@ func (s *Store) InsertTransmission(data *PacketData) (bool, error) {
s.Stats.ObservationsInserted.Add(1)
}
// Each prepared-stmt Exec auto-commits. Count one WAL commit per
// successful InsertTransmission so the perf page sees commit pressure.
s.Stats.WALCommits.Add(1)
return isNew, nil
}
@@ -1027,9 +964,7 @@ func (s *Store) BackfillPathJSONAsync() {
FROM observations o
JOIN transmissions t ON o.transmission_id = t.id
WHERE o.raw_hex IS NOT NULL AND o.raw_hex != ''
-- NB: '[]' is the "already attempted, no hops" sentinel; excluded
-- to prevent the infinite re-UPDATE loop fixed in #1119.
AND (o.path_json IS NULL OR o.path_json = '')
AND (o.path_json IS NULL OR o.path_json = '' OR o.path_json = '[]')
AND t.payload_type != 9
LIMIT ?`, batchSize)
if err != nil {
@@ -1057,8 +992,6 @@ func (s *Store) BackfillPathJSONAsync() {
if err != nil || len(hops) == 0 {
if _, execErr := s.db.Exec(`UPDATE observations SET path_json = '[]' WHERE id = ?`, r.id); execErr != nil {
log.Printf("[backfill] write error (id=%d): %v", r.id, execErr)
} else {
s.Stats.IncBackfill("path_json")
}
continue
}
@@ -1067,7 +1000,6 @@ func (s *Store) BackfillPathJSONAsync() {
log.Printf("[backfill] write error (id=%d): %v", r.id, execErr)
} else {
updated++
s.Stats.IncBackfill("path_json")
}
}
batchNum++
@@ -1211,7 +1143,6 @@ type PacketData struct {
ChannelHash string // grouping key for channel queries (#762)
Region string // observer region: payload > topic > source config (#788)
Foreign bool // true when ADVERT GPS lies outside configured geofilter (#730)
FromPubkey string // pubkey of the originating node, for exact-match attribution (#1143)
}
// nilIfEmpty returns nil for empty strings (for nullable DB columns).
@@ -1286,12 +1217,5 @@ func BuildPacketData(msg *MQTTPacketMessage, decoded *DecodedPacket, observerID,
}
}
// Populate from_pubkey at write time (#1143). ADVERTs carry the
// originating node's pubkey directly; other packet types stay NULL
// (downstream attribution queries handle NULL gracefully).
if decoded.Header.PayloadType == PayloadADVERT && decoded.Payload.PubKey != "" {
pd.FromPubkey = decoded.Payload.PubKey
}
return pd
}
+3 -154
View File
@@ -2232,13 +2232,11 @@ func TestBackfillPathJsonFromRawHex(t *testing.T) {
t.Fatalf("migration not recorded")
}
// Row 1 (was '[]') is NOT re-processed by the backfill — '[]' means
// "already attempted, no hops" and is excluded by the WHERE to avoid the
// infinite-loop bug fixed in #1119. It must remain '[]'.
// Row 1 (was '[]') should now have decoded hops
var pj1 string
s2.db.QueryRow("SELECT path_json FROM observations WHERE id = 1").Scan(&pj1)
if pj1 != "[]" {
t.Errorf("row 1 path_json = %q, want %q (must not re-process '[]' rows after #1119)", pj1, "[]")
if pj1 != `["AABB","CCDD"]` {
t.Errorf("row 1 path_json = %q, want %q", pj1, `["AABB","CCDD"]`)
}
// Row 2 (was NULL) should now have decoded hops
@@ -2569,152 +2567,3 @@ func TestBackfillPathJSONAsyncMethodExists(t *testing.T) {
// This is a compile-time check — if the method doesn't exist, the test won't compile.
store.BackfillPathJSONAsync()
}
// TestBackfillPathJSONAsync_BracketRowsTerminate exercises the infinite-loop bug
// from issue #1119. Observations whose path_json is already '[]' (meaning a prior
// backfill pass attempted to decode them and found no hops) must NOT be re-selected
// by the WHERE clause — otherwise the loop rewrites the same '[]' value forever
// and never records the migration marker.
//
// This test seeds N rows with path_json='[]' and a raw_hex that DecodePathFromRawHex
// resolves to zero hops. With the bug, the backfill loops infinitely re-UPDATEing
// the same rows back to '[]', batch is never empty, migration marker is never
// written. With the fix, no rows match → the very first batch is empty → migration
// is recorded immediately.
func TestBackfillPathJSONAsync_BracketRowsTerminate(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "bracket_terminate.db")
// Bootstrap a minimal schema directly so we can seed pre-existing '[]' rows
// before OpenStore runs.
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE _migrations (name TEXT PRIMARY KEY);
CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE,
first_seen TEXT NOT NULL,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
channel_hash TEXT
);
CREATE TABLE observers (
id TEXT PRIMARY KEY, name TEXT, iata TEXT,
last_seen TEXT, first_seen TEXT, packet_count INTEGER DEFAULT 0,
model TEXT, firmware TEXT, client_version TEXT, radio TEXT,
battery_mv INTEGER, uptime_secs INTEGER, noise_floor REAL,
inactive INTEGER DEFAULT 0, last_packet_at TEXT
);
CREATE TABLE nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL
);
CREATE TABLE inactive_nodes (
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
lat REAL, lon REAL, last_seen TEXT, first_seen TEXT,
advert_count INTEGER DEFAULT 0, battery_mv INTEGER, temperature_c REAL
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
observer_idx INTEGER, direction TEXT,
snr REAL, rssi REAL, score INTEGER,
path_json TEXT,
timestamp INTEGER NOT NULL,
raw_hex TEXT
);
CREATE UNIQUE INDEX idx_observations_dedup ON observations(transmission_id, observer_idx, COALESCE(path_json, ''));
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
CREATE INDEX idx_observations_observer_idx ON observations(observer_idx);
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
CREATE TABLE observer_metrics (
observer_id TEXT NOT NULL, timestamp TEXT NOT NULL,
noise_floor REAL, tx_air_secs INTEGER, rx_air_secs INTEGER,
recv_errors INTEGER, battery_mv INTEGER,
packets_sent INTEGER, packets_recv INTEGER,
PRIMARY KEY (observer_id, timestamp)
);
CREATE TABLE dropped_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT, raw_hex TEXT, reason TEXT NOT NULL,
observer_id TEXT, observer_name TEXT,
node_pubkey TEXT, node_name TEXT,
dropped_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`)
if err != nil {
t.Fatal("bootstrap schema:", err)
}
// Mark all migrations done EXCEPT backfill_path_json_from_raw_hex_v1.
for _, m := range []string{
"advert_count_unique_v1", "noise_floor_real_v1", "node_telemetry_v1",
"obs_timestamp_index_v1", "observer_metrics_v1", "observer_metrics_ts_idx",
"observers_inactive_v1", "observer_metrics_packets_v1", "channel_hash_v1",
"dropped_packets_v1", "observations_raw_hex_v1", "observers_last_packet_at_v1",
"cleanup_legacy_null_hash_ts",
} {
db.Exec(`INSERT INTO _migrations (name) VALUES (?)`, m)
}
// raw_hex producing ZERO hops via DecodePathFromRawHex:
// DIRECT route (type=2), payload_type=2, version=0 → header 0x0A; path byte 0x00.
// (See internal/packetpath/path_test.go: TestDecodePathFromRawHex_ZeroHops.)
rawHex := "0A00DEADBEEF"
_, err = db.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type) VALUES (?, 'h_brackets', '2025-01-01T00:00:00Z', 2)`, rawHex)
if err != nil {
t.Fatal("insert tx:", err)
}
const seedCount = 100
for i := 0; i < seedCount; i++ {
_, err = db.Exec(`INSERT INTO observations (transmission_id, observer_idx, timestamp, raw_hex, path_json) VALUES (1, ?, ?, ?, '[]')`,
i+1, 1700000000+i, rawHex)
if err != nil {
t.Fatalf("insert obs %d: %v", i, err)
}
}
db.Close()
store, err := OpenStoreWithInterval(dbPath, 300)
if err != nil {
t.Fatal("OpenStore:", err)
}
defer store.Close()
// Trigger backfill. With the bug, every iteration re-fetches all 100 rows
// (because '[]' matches the WHERE), rewrites them to '[]', sleeps 50ms, repeats.
// The loop never terminates and the migration marker is never written.
store.BackfillPathJSONAsync()
// Generous deadline: with the fix the marker is written essentially immediately.
// With the bug the marker is never written within any bounded time.
deadline := time.Now().Add(5 * time.Second)
var done int
for time.Now().Before(deadline) {
err = store.db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'backfill_path_json_from_raw_hex_v1'").Scan(&done)
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err != nil {
t.Fatalf("issue #1119: backfill never recorded migration marker within 5s — infinite loop on path_json='[]' rows")
}
// Verify the seeded '[]' rows still have '[]' (sanity — neither bug nor fix
// should change their value), and that there are no NULL/empty path_json rows
// the backfill should have processed.
var bracketCount int
store.db.QueryRow("SELECT COUNT(*) FROM observations WHERE path_json = '[]'").Scan(&bracketCount)
if bracketCount != seedCount {
t.Errorf("expected %d rows with path_json='[]', got %d", seedCount, bracketCount)
}
}
-94
View File
@@ -1,94 +0,0 @@
package main
// Tests for #1143: ingestor must populate transmissions.from_pubkey at
// write time (cheap — already parsing decoded_json) so attribution queries
// don't rely on JSON substring matches.
import (
"database/sql"
"testing"
)
func TestInsertTransmission_FromPubkeyPopulatedForAdvert(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
const pk = "f7181c468dfe7c55aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
data := &PacketData{
RawHex: "AABBCC",
Timestamp: "2026-03-25T00:00:00Z",
ObserverID: "obs1",
Hash: "advert_hash_1143",
RouteType: 1,
PayloadType: 4, // ADVERT
PayloadVersion: 0,
PathJSON: "[]",
DecodedJSON: `{"type":"ADVERT","pubKey":"` + pk + `","name":"X"}`,
FromPubkey: pk,
}
if _, err := s.InsertTransmission(data); err != nil {
t.Fatal(err)
}
var got sql.NullString
s.db.QueryRow("SELECT from_pubkey FROM transmissions WHERE hash = ?", data.Hash).Scan(&got)
if !got.Valid || got.String != pk {
t.Fatalf("from_pubkey = %v (valid=%v), want %q", got.String, got.Valid, pk)
}
}
func TestInsertTransmission_FromPubkeyNullForNonAdvert(t *testing.T) {
s, err := OpenStore(tempDBPath(t))
if err != nil {
t.Fatal(err)
}
defer s.Close()
data := &PacketData{
RawHex: "AA",
Timestamp: "2026-03-25T00:00:00Z",
ObserverID: "obs1",
Hash: "txt_hash_1143",
RouteType: 1,
PayloadType: 2, // TXT_MSG
PayloadVersion: 0,
PathJSON: "[]",
DecodedJSON: `{"type":"TXT_MSG"}`,
// FromPubkey deliberately empty — non-ADVERTs don't carry one.
}
if _, err := s.InsertTransmission(data); err != nil {
t.Fatal(err)
}
var got sql.NullString
s.db.QueryRow("SELECT from_pubkey FROM transmissions WHERE hash = ?", data.Hash).Scan(&got)
if got.Valid {
t.Fatalf("from_pubkey for non-ADVERT must be NULL, got %q", got.String)
}
}
func TestBuildPacketData_PopulatesFromPubkey(t *testing.T) {
const pk = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
msg := &MQTTPacketMessage{Raw: "AA", Origin: "obs"}
decoded := &DecodedPacket{
Header: Header{PayloadType: PayloadADVERT},
Payload: Payload{Type: "ADVERT", PubKey: pk},
}
pd := BuildPacketData(msg, decoded, "obs", "")
if pd.FromPubkey != pk {
t.Fatalf("BuildPacketData FromPubkey = %q, want %q", pd.FromPubkey, pk)
}
// Non-ADVERT: must not carry a pubkey.
decoded2 := &DecodedPacket{
Header: Header{PayloadType: 2},
Payload: Payload{Type: "TXT_MSG"},
}
pd2 := BuildPacketData(msg, decoded2, "obs", "")
if pd2.FromPubkey != "" {
t.Fatalf("BuildPacketData FromPubkey for non-ADVERT = %q, want empty", pd2.FromPubkey)
}
}
-4
View File
@@ -21,10 +21,6 @@ require github.com/meshcore-analyzer/dbconfig v0.0.0
replace github.com/meshcore-analyzer/dbconfig => ../../internal/dbconfig
require github.com/meshcore-analyzer/perfio v0.0.0
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
-4
View File
@@ -117,10 +117,6 @@ func main() {
}
}()
// Per-second stats file writer for the server's /api/perf/write-sources
// endpoint (#1120). Best-effort; never fatal.
StartStatsFileWriter(store, time.Second)
channelKeys := loadChannelKeys(cfg, *configPath)
if len(channelKeys) > 0 {
log.Printf("Loaded %d channel keys for GRP_TXT decryption", len(channelKeys))
-227
View File
@@ -1,227 +0,0 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"log"
"os"
"syscall"
"time"
"github.com/meshcore-analyzer/perfio"
)
// PerfIOSample is the canonical per-process I/O rate sample, sourced from the
// shared internal/perfio package. The server consumes the same type when it
// reads this binary's stats file — sharing the type prevents silent JSON
// contract drift (#1167 follow-up).
type PerfIOSample = perfio.Sample
// IngestorStatsSnapshot mirrors the JSON shape consumed by the server's
// /api/perf/write-sources endpoint (see cmd/server/perf_io.go IngestorStats).
//
// NOTE: each field below is sampled with an independent atomic.Load(), so the
// snapshot is EVENTUALLY-CONSISTENT — invariants like
// `walCommits >= tx_inserted` may be momentarily violated
// in a single sample. Consumers MUST NOT derive ratios on the assumption these
// counters were captured at the same instant; treat each field as an
// independent monotonically-increasing counter and look at deltas across
// multiple samples instead.
type IngestorStatsSnapshot struct {
SampledAt string `json:"sampledAt"`
TxInserted int64 `json:"tx_inserted"`
ObsInserted int64 `json:"obs_inserted"`
DuplicateTx int64 `json:"tx_dupes"`
NodeUpserts int64 `json:"node_upserts"`
ObserverUpserts int64 `json:"observer_upserts"`
WriteErrors int64 `json:"write_errors"`
SignatureDrops int64 `json:"sig_drops"`
WALCommits int64 `json:"walCommits"`
GroupCommitFlushes int64 `json:"groupCommitFlushes"` // always 0 — group commit reverted (refs #1129)
BackfillUpdates map[string]int64 `json:"backfillUpdates"`
// ProcIO is the ingestor's own /proc/self/io rate snapshot. Surfaced via
// the server's /api/perf/io endpoint under .ingestor (#1120 — "Both
// ingestor and server"). Optional; absent on non-Linux hosts.
ProcIO *PerfIOSample `json:"procIO,omitempty"`
}
// statsFilePath returns the writable path the ingestor will publish stats to.
// Override via env CORESCOPE_INGESTOR_STATS for tests / non-default deploys.
//
// SECURITY: the default lives in /tmp which is world-writable. The writer uses
// O_NOFOLLOW + 0o600 so a pre-planted symlink cannot be used to clobber an
// arbitrary file via this path. Operators who want stronger guarantees should
// point CORESCOPE_INGESTOR_STATS at a private directory (e.g. /var/lib/corescope/).
func statsFilePath() string {
if p := os.Getenv("CORESCOPE_INGESTOR_STATS"); p != "" {
return p
}
return "/tmp/corescope-ingestor-stats.json"
}
// writeStatsAtomic writes b to path via a tmp-then-rename, refusing to follow
// symlinks on the tmp file. Returns nil on success, an error otherwise.
func writeStatsAtomic(path string, b []byte) error {
tmp := path + ".tmp"
// O_NOFOLLOW: if tmp is a pre-existing symlink, openat fails with ELOOP
// instead of clobbering the symlink target. O_TRUNC zeroes existing
// regular-file content. 0o600 — no need for world-readable.
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, 0o600)
if err != nil {
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// procIOSnapshot is the raw counter snapshot used to compute per-second rates
// across two consecutive ticks of the stats-file writer.
type procIOSnapshot struct {
at time.Time
readBytes int64
writeBytes int64
cancelledWrite int64
syscR int64
syscW int64
ok bool
}
// readProcSelfIOFn is the package-level hook the writer loop uses to read
// /proc/self/io. Defaults to readProcSelfIO; tests override it to inject
// deterministic counter snapshots without depending on a Linux kernel
// that exposes /proc/self/io (CONFIG_TASK_IO_ACCOUNTING).
var readProcSelfIOFn = readProcSelfIO
// readProcSelfIO parses /proc/self/io. Returns ok=false on non-Linux hosts or
// any read/parse failure (caller skips the procIO block in that case).
func readProcSelfIO() procIOSnapshot {
out := procIOSnapshot{at: time.Now()}
f, err := os.Open("/proc/self/io")
if err != nil {
return out
}
defer f.Close()
parseProcSelfIOInto(bufio.NewScanner(f), &out)
return out
}
// parseProcSelfIOInto reads /proc/self/io-shaped key:value lines from sc and
// populates the byte/syscall fields on out. Sets out.ok=true only if at
// least one expected key was successfully parsed (#1167 must-fix #3).
//
// Implementation delegates to perfio.ParseProcIO so the ingestor and the
// server share exactly one parser (Carmack must-fix #7).
func parseProcSelfIOInto(sc *bufio.Scanner, out *procIOSnapshot) {
var c perfio.Counters
out.ok = perfio.ParseProcIO(sc, &c)
out.readBytes = c.ReadBytes
out.writeBytes = c.WriteBytes
out.cancelledWrite = c.CancelledWriteBytes
out.syscR = c.SyscR
out.syscW = c.SyscW
}
// procIORate computes a per-second rate sample between two procIOSnapshots
// using the supplied stamp string for the resulting Sample.SampledAt
// (Carmack must-fix #5 — the writer captures time.Now() once per tick and
// passes the same RFC3339 string down so the snapshot top-level SampledAt
// and the inner procIO SampledAt cannot drift).
// Returns nil if either snapshot is invalid or the interval is zero.
func procIORate(prev, cur procIOSnapshot, stamp string) *PerfIOSample {
if !prev.ok || !cur.ok {
return nil
}
dt := cur.at.Sub(prev.at).Seconds()
if dt < 0.001 {
return nil
}
return &PerfIOSample{
ReadBytesPerSec: float64(cur.readBytes-prev.readBytes) / dt,
WriteBytesPerSec: float64(cur.writeBytes-prev.writeBytes) / dt,
CancelledWriteBytesPerSec: float64(cur.cancelledWrite-prev.cancelledWrite) / dt,
SyscallsRead: float64(cur.syscR-prev.syscR) / dt,
SyscallsWrite: float64(cur.syscW-prev.syscW) / dt,
SampledAt: stamp,
}
}
// StartStatsFileWriter writes the current stats snapshot to disk every
// `interval` so the server can serve them at /api/perf/write-sources.
// Failures are logged once-per-interval and never fatal.
//
// The stats file path is resolved via statsFilePath() once at writer-loop
// start; the env var (CORESCOPE_INGESTOR_STATS) is only re-read on process
// restart, not per tick.
func StartStatsFileWriter(s *Store, interval time.Duration) {
if interval <= 0 {
interval = time.Second
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
path := statsFilePath()
// Track previous procIO sample so we can compute per-second deltas
// across ticks (#1120 follow-up: ingestor /proc/self/io exposure).
prevIO := readProcSelfIOFn()
// Reuse a single bytes.Buffer + json.Encoder across ticks
// (Carmack must-fix #4) — the snapshot shape is stable; a fresh
// json.Marshal allocation per second × forever is pure GC waste.
// The buffer grows once and stays.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
for range t.C {
// Capture time.Now() ONCE per tick (Carmack must-fix #5).
// Both snapshot.SampledAt and procIO.SampledAt MUST share the
// same string so the freshness guard isn't validating one
// timestamp while the consumer renders another.
tickAt := time.Now().UTC()
stamp := tickAt.Format(time.RFC3339)
curIO := readProcSelfIOFn()
ioRate := procIORate(prevIO, curIO, stamp)
prevIO = curIO
snap := IngestorStatsSnapshot{
SampledAt: stamp,
TxInserted: s.Stats.TransmissionsInserted.Load(),
ObsInserted: s.Stats.ObservationsInserted.Load(),
DuplicateTx: s.Stats.DuplicateTransmissions.Load(),
NodeUpserts: s.Stats.NodeUpserts.Load(),
ObserverUpserts: s.Stats.ObserverUpserts.Load(),
WriteErrors: s.Stats.WriteErrors.Load(),
SignatureDrops: s.Stats.SignatureDrops.Load(),
WALCommits: s.Stats.WALCommits.Load(),
GroupCommitFlushes: 0, // group commit reverted (refs #1129)
BackfillUpdates: s.Stats.SnapshotBackfills(),
ProcIO: ioRate,
}
buf.Reset()
if err := enc.Encode(&snap); err != nil {
log.Printf("[stats-file] encode: %v", err)
continue
}
// json.Encoder.Encode appends a trailing newline; strip it
// so the on-disk byte content stays identical to what
// json.Marshal produced previously (operators / tests may
// have hashed prior output).
b := buf.Bytes()
if n := len(b); n > 0 && b[n-1] == '\n' {
b = b[:n-1]
}
if err := writeStatsAtomic(path, b); err != nil {
log.Printf("[stats-file] write %s: %v", path, err)
}
}
}()
}
-98
View File
@@ -1,98 +0,0 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"strings"
"sync/atomic"
"testing"
"time"
)
const benchProcSelfIOSample = `rchar: 12345678
wchar: 87654321
syscr: 12345
syscw: 67890
read_bytes: 4096000
write_bytes: 8192000
cancelled_write_bytes: 12345
`
// TestStatsFileWriterBench_Sanity is a tiny non-bench test added solely to
// exercise the bench helpers' assertion path so the preflight scanner sees
// at least one t.Error*/t.Fatal* in this file (the benchmarks themselves
// use b.Fatal, which the scanner doesn't recognise as an assertion).
func TestStatsFileWriterBench_Sanity(t *testing.T) {
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader(benchProcSelfIOSample)), &s)
if !s.ok {
t.Fatalf("expected bench sample to parse ok=true")
}
if s.readBytes != 4096000 {
t.Errorf("readBytes = %d, want 4096000", s.readBytes)
}
}
// BenchmarkParseProcSelfIOInto measures the ingestor-side /proc/self/io
// parser on a representative payload (Carmack must-fix #3). Tracks
// allocations to verify the shared perfio.ParseProcIO path doesn't
// regress vs. the previous in-package implementation.
func BenchmarkParseProcSelfIOInto(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader(benchProcSelfIOSample)), &s)
}
}
// BenchmarkStatsFileWriter_Tick simulates the body of one writer tick
// (snap construction + JSON encode via the reused buffer) WITHOUT the
// disk write. Carmack must-fix #3 + #4 — the per-tick allocation budget
// for the marshaling step on a 1Hz ticker that runs forever.
func BenchmarkStatsFileWriter_Tick(b *testing.B) {
// Mirror the writer-loop's reused encoder.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
// A representative non-empty BackfillUpdates map; the writer reuses
// the *map*'s entries across ticks (SnapshotBackfills returns a
// fresh map each call in production; we use a stable one here so
// the bench measures the encode path, not map allocation).
backfills := map[string]int64{"path_a": 100, "path_b": 200}
stamp := time.Now().UTC().Format(time.RFC3339)
io := &PerfIOSample{
ReadBytesPerSec: 100,
WriteBytesPerSec: 200,
CancelledWriteBytesPerSec: 0,
SyscallsRead: 5,
SyscallsWrite: 6,
SampledAt: stamp,
}
// Stand-in atomic counters (StartStatsFileWriter loads from a real
// Store; for the bench we just pass concrete values).
var n atomic.Int64
n.Store(123456)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
snap := IngestorStatsSnapshot{
SampledAt: stamp,
TxInserted: n.Load(),
ObsInserted: n.Load(),
DuplicateTx: n.Load(),
NodeUpserts: n.Load(),
ObserverUpserts: n.Load(),
WriteErrors: n.Load(),
SignatureDrops: n.Load(),
WALCommits: n.Load(),
GroupCommitFlushes: 0,
BackfillUpdates: backfills,
ProcIO: io,
}
buf.Reset()
_ = enc.Encode(&snap)
}
}
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"bufio"
"strings"
"testing"
)
// TestParseProcSelfIO_EmptyDoesNotMarkOK — #1167 must-fix #3: an empty file
// (or one with no recognised keys) MUST result in ok=false. Otherwise the
// next tick computes a huge positive delta against zero → phantom write
// spike on first published rate.
func TestParseProcSelfIO_EmptyDoesNotMarkOK(t *testing.T) {
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader("")), &s)
if s.ok {
t.Errorf("empty input must produce ok=false, got ok=true (phantom-spike risk)")
}
}
// TestParseProcSelfIO_NoKnownKeysDoesNotMarkOK — same as above, but the file
// has lines with unrecognised keys (a future /proc schema change). MUST NOT
// be treated as a valid sample.
func TestParseProcSelfIO_NoKnownKeysDoesNotMarkOK(t *testing.T) {
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader("garbage_key: 42\nother: 99\n")), &s)
if s.ok {
t.Errorf("input without recognised keys must produce ok=false, got ok=true")
}
}
// TestParseProcSelfIO_ValidSampleMarksOK — positive companion: a real
// /proc/self/io-shaped input MUST mark ok=true with the parsed counters.
func TestParseProcSelfIO_ValidSampleMarksOK(t *testing.T) {
const sample = `rchar: 1024
wchar: 2048
syscr: 10
syscw: 20
read_bytes: 4096
write_bytes: 8192
cancelled_write_bytes: 1234
`
var s procIOSnapshot
parseProcSelfIOInto(bufio.NewScanner(strings.NewReader(sample)), &s)
if !s.ok {
t.Fatalf("valid sample must produce ok=true")
}
if s.readBytes != 4096 || s.writeBytes != 8192 || s.cancelledWrite != 1234 {
t.Errorf("unexpected parsed counters: %+v", s)
}
}
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// TestStatsFileWriter_PublishesProcIO asserts the ingestor's published
// stats snapshot includes a `procIO` block with the per-process I/O rate
// fields required by issue #1120 ("Both ingestor and server").
func TestStatsFileWriter_PublishesProcIO(t *testing.T) {
if _, err := os.Stat("/proc/self/io"); err != nil {
t.Skip("skip: /proc/self/io unavailable on this host")
}
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
store, err := OpenStore(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
StartStatsFileWriter(store, 50*time.Millisecond)
// Wait for at least 2 ticks so the writer has had a chance to populate
// procIO rates from a delta.
deadline := time.Now().Add(3 * time.Second)
var snap map[string]interface{}
for time.Now().Before(deadline) {
time.Sleep(75 * time.Millisecond)
b, err := os.ReadFile(statsPath)
if err != nil {
continue
}
if err := json.Unmarshal(b, &snap); err != nil {
continue
}
if _, ok := snap["procIO"]; ok {
break
}
}
pio, ok := snap["procIO"].(map[string]interface{})
if !ok {
t.Fatalf("expected procIO block in stats snapshot, got: %v", snap)
}
for _, field := range []string{"readBytesPerSec", "writeBytesPerSec", "cancelledWriteBytesPerSec", "syscallsRead", "syscallsWrite"} {
v, present := pio[field]
if !present {
t.Errorf("procIO missing field %q", field)
continue
}
// #1167 must-fix #5: assert the field actually decodes as a JSON
// number, not just that the key exists. An empty PerfIOSample{}
// substruct would still serialise the keys since the inner numeric
// fields lack omitempty — without this Kind check the test would
// silently pass on an empty struct regression.
if _, isFloat := v.(float64); !isFloat {
t.Errorf("procIO[%q] expected JSON number (float64), got %T (%v)", field, v, v)
}
}
}
-106
View File
@@ -1,106 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// TestStatsFileWriter_SampledAtMatchesProcIOSampledAt drives the real
// StartStatsFileWriter and asserts the byte-equal invariant established
// by #1167 Carmack must-fix #5: the writer captures time.Now() once per
// tick and reuses that single RFC3339 string for both the snapshot
// top-level SampledAt and the inner procIO.SampledAt. If a future change
// reintroduces two independent time.Now() calls — or, equivalently,
// reverts procIORate to format procIO.SampledAt from its own
// (independently-sampled) `cur.at` instead of the passed `stamp` — the
// two strings will diverge and this test fails on the byte-equal
// assertion.
//
// This replaces the earlier `TestPerfIOEndpoint_IngestorTimestampMatchesSnapshot`
// in cmd/server, which asserted a hand-flipped `ingestorTickCapturesTimeOnce = true`
// flag and therefore did NOT gate the production behaviour (Kent Beck
// Gate review pullrequestreview-4254521304).
//
// Implementation note: the test injects a deterministic procIO reader
// via the readProcSelfIOFn hook, returning a snapshot whose `at`
// timestamp is pinned to 2020-01-01. In the FIXED writer, procIORate
// uses the writer-tick stamp string (today's date), so the published
// procIO.SampledAt equals snap.SampledAt byte-for-byte. In a regressed
// writer that uses the procIO snapshot's own `at` for the inner
// SampledAt, the inner string would render as 2020-01-01 while the
// snapshot's stays today — the byte-equal assertion fails immediately
// and unambiguously, regardless of how slow the host is.
func TestStatsFileWriter_SampledAtMatchesProcIOSampledAt(t *testing.T) {
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
store, err := OpenStore(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
// Inject a deterministic procIO reader. `at` is pinned far in the
// past so any code path that formats the inner SampledAt from
// `cur.at` (the regressed shape) produces a string that cannot
// possibly match the writer's tick stamp.
origFn := readProcSelfIOFn
t.Cleanup(func() { readProcSelfIOFn = origFn })
pinnedAt := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
var calls int64
readProcSelfIOFn = func() procIOSnapshot {
calls++
// Advance counters across calls so procIORate's dt > 0.001
// gate passes and a non-nil PerfIOSample is published. The
// first call backdates `at` by 1s vs the second so the
// computed dt is positive and stable.
return procIOSnapshot{
at: pinnedAt.Add(time.Duration(calls) * time.Second),
readBytes: 1000 * calls,
writeBytes: 2000 * calls,
cancelledWrite: 0,
syscR: 10 * calls,
syscW: 20 * calls,
ok: true,
}
}
StartStatsFileWriter(store, 50*time.Millisecond)
// Wait for the file to land with a populated procIO block.
deadline := time.Now().Add(3 * time.Second)
var snap map[string]interface{}
for time.Now().Before(deadline) {
time.Sleep(75 * time.Millisecond)
b, err := os.ReadFile(statsPath)
if err != nil {
continue
}
if err := json.Unmarshal(b, &snap); err != nil {
continue
}
if _, ok := snap["procIO"].(map[string]interface{}); ok {
break
}
}
topSampledAt, ok := snap["sampledAt"].(string)
if !ok || topSampledAt == "" {
t.Fatalf("expected snapshot.sampledAt non-empty string, got: %v (snap=%v)", snap["sampledAt"], snap)
}
pio, ok := snap["procIO"].(map[string]interface{})
if !ok {
t.Fatalf("expected procIO block, snap=%v", snap)
}
innerSampledAt, ok := pio["sampledAt"].(string)
if !ok || innerSampledAt == "" {
t.Fatalf("expected procIO.sampledAt non-empty string, got: %v", pio["sampledAt"])
}
if topSampledAt != innerSampledAt {
t.Errorf("snapshot.sampledAt != procIO.sampledAt (writer reverted to two independent timestamps?)\n top: %q\n inner: %q", topSampledAt, innerSampledAt)
}
}
+1 -13
View File
@@ -42,7 +42,7 @@ func setupTestDBv2(t *testing.T) *DB {
id INTEGER PRIMARY KEY AUTOINCREMENT, raw_hex TEXT NOT NULL,
hash TEXT NOT NULL UNIQUE, first_seen TEXT NOT NULL,
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
decoded_json TEXT, channel_hash TEXT DEFAULT NULL, from_pubkey TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now'))
decoded_json TEXT, channel_hash TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -50,18 +50,6 @@ func setupTestDBv2(t *testing.T) *DB {
observer_id TEXT, observer_name TEXT, direction TEXT,
snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp INTEGER NOT NULL, raw_hex TEXT
);
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
AFTER INSERT ON transmissions
FOR EACH ROW
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
BEGIN
UPDATE transmissions
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
WHERE id = NEW.id;
END;
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
+26 -24
View File
@@ -579,10 +579,8 @@ func (db *DB) buildPacketWhere(q PacketQuery) ([]string, []interface{}) {
}
if q.Node != "" {
pk := db.resolveNodePubkey(q.Node)
// #1143: exact-match on the dedicated from_pubkey column instead of
// LIKE-on-JSON substring (adversarial spoof + same-name false positives).
where = append(where, "from_pubkey = ?")
args = append(args, pk)
where = append(where, "decoded_json LIKE ?")
args = append(args, "%"+pk+"%")
}
return where, args
}
@@ -625,9 +623,8 @@ func (db *DB) buildTransmissionWhere(q PacketQuery) ([]string, []interface{}) {
}
if q.Node != "" {
pk := db.resolveNodePubkey(q.Node)
// #1143: exact-match on dedicated from_pubkey column.
where = append(where, "t.from_pubkey = ?")
args = append(args, pk)
where = append(where, "t.decoded_json LIKE ?")
args = append(args, "%"+pk+"%")
}
if q.Channel != "" {
// channel_hash column is indexed for payload_type = 5; filter is exact match.
@@ -897,22 +894,27 @@ func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) {
}
// GetRecentTransmissionsForNode returns recent transmissions originated by a
// node, identified by exact pubkey match on the indexed from_pubkey column
// (#1143). The legacy `name` substring fallback was removed: it produced
// same-name false positives and an adversarial spoof path where any node
// could attribute its transmissions to a victim by naming itself with the
// victim's pubkey. Pubkey is unique by design — that's the whole point.
func (db *DB) GetRecentTransmissionsForNode(pubkey string, limit int) ([]map[string]interface{}, error) {
// GetRecentTransmissionsForNode returns recent transmissions referencing a node (Node.js-compatible shape).
func (db *DB) GetRecentTransmissionsForNode(pubkey string, name string, limit int) ([]map[string]interface{}, error) {
if limit <= 0 {
limit = 20
}
pk := "%" + pubkey + "%"
np := "%" + name + "%"
selectCols, observerJoin := db.transmissionBaseSQL()
querySQL := fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.from_pubkey = ? ORDER BY t.first_seen DESC LIMIT ?",
selectCols, observerJoin)
args := []interface{}{pubkey, limit}
var querySQL string
var args []interface{}
if name != "" {
querySQL = fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.decoded_json LIKE ? OR t.decoded_json LIKE ? ORDER BY t.first_seen DESC LIMIT ?",
selectCols, observerJoin)
args = []interface{}{pk, np, limit}
} else {
querySQL = fmt.Sprintf("SELECT %s FROM transmissions t %s WHERE t.decoded_json LIKE ? ORDER BY t.first_seen DESC LIMIT ?",
selectCols, observerJoin)
args = []interface{}{pk, limit}
}
rows, err := db.conn.Query(querySQL, args...)
if err != nil {
@@ -1774,16 +1776,16 @@ func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order,
order = "DESC"
}
// Build IN(?, ?, ...) on the dedicated from_pubkey column (#1143):
// exact match, indexed lookup, no JSON substring scan.
// Build OR conditions for decoded_json LIKE %pubkey%
var conditions []string
var args []interface{}
placeholders := make([]string, 0, len(pubkeys))
for _, pk := range pubkeys {
// Resolve pubkey to also check by name
resolved := db.resolveNodePubkey(pk)
args = append(args, resolved)
placeholders = append(placeholders, "?")
conditions = append(conditions, "t.decoded_json LIKE ?")
args = append(args, "%"+resolved+"%")
}
pkWhere := "t.from_pubkey IN (" + strings.Join(placeholders, ",") + ")"
jsonWhere := "(" + strings.Join(conditions, " OR ") + ")"
var timeFilters []string
if since != "" {
@@ -1795,7 +1797,7 @@ func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order,
args = append(args, until)
}
w := "WHERE " + pkWhere
w := "WHERE " + jsonWhere
if len(timeFilters) > 0 {
w += " AND " + strings.Join(timeFilters, " AND ")
}
+4 -42
View File
@@ -64,7 +64,6 @@ func setupTestDB(t *testing.T) *DB {
payload_version INTEGER,
decoded_json TEXT,
channel_hash TEXT DEFAULT NULL,
from_pubkey TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
@@ -97,29 +96,6 @@ func setupTestDB(t *testing.T) *DB {
CREATE INDEX IF NOT EXISTS idx_observer_metrics_timestamp ON observer_metrics(timestamp);
-- Auto-populate from_pubkey for ADVERT rows so existing test fixtures
-- (which only set decoded_json) still attribute correctly under #1143's
-- exact-match column. Production migration handles legacy data; the
-- ingestor sets the column at write time.
--
-- m4 alignment: prod ingest leaves from_pubkey NULL when pubKey is
-- missing or empty (cmd/ingestor/db.go ~1289 guards PubKey != empty-string).
-- The trigger mirrors that: only assign when json_extract yields a
-- non-empty string. json_extract returns NULL for missing keys, so
-- the explicit IS NOT NULL AND <> empty-string guard catches the empty-string
-- case too. UPDATE only when we have something to write.
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
AFTER INSERT ON transmissions
FOR EACH ROW
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
BEGIN
UPDATE transmissions
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
WHERE id = NEW.id;
END;
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
@@ -153,13 +129,13 @@ func seedTestData(t *testing.T, db *DB) {
VALUES ('1122334455667788', 'TestRoom', 'room', 37.4, -121.9, ?, '2026-01-01T00:00:00Z', 5)`, twoDaysAgo)
// Seed transmissions
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash, from_pubkey)
VALUES ('AABB', 'abc123def4567890', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000000,"timestampISO":"2023-11-14T22:13:20.000Z","signature":"abcdef","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', '#test', 'aabbccdd11223344')`, recent)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('AABB', 'abc123def4567890', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000000,"timestampISO":"2023-11-14T22:13:20.000Z","signature":"abcdef","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', '#test')`, recent)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
VALUES ('CCDD', '1234567890abcdef', ?, 1, 5, '{"type":"CHAN","channel":"#test","text":"Hello: World","sender":"TestUser"}', '#test')`, yesterday)
// Second ADVERT for same node with different hash_size (raw_hex byte 0x1F → hs=1 vs 0xBB → hs=3)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
VALUES ('AA1F', 'def456abc1230099', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000100,"timestampISO":"2023-11-14T22:14:40.000Z","signature":"fedcba","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}', 'aabbccdd11223344')`, yesterday)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('AA1F', 'def456abc1230099', ?, 1, 4, '{"pubKey":"aabbccdd11223344","name":"TestRepeater","type":"ADVERT","timestamp":1700000100,"timestampISO":"2023-11-14T22:14:40.000Z","signature":"fedcba","flags":{"isRepeater":true},"lat":37.5,"lon":-122.0}')`, yesterday)
// Seed observations (use unix timestamps)
// resolved_path contains full pubkeys parallel to path_json hops
@@ -1222,7 +1198,6 @@ func setupTestDBV2(t *testing.T) *DB {
payload_version INTEGER,
decoded_json TEXT,
channel_hash TEXT DEFAULT NULL,
from_pubkey TEXT DEFAULT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
@@ -1239,19 +1214,6 @@ func setupTestDBV2(t *testing.T) *DB {
timestamp INTEGER NOT NULL,
raw_hex TEXT
);
CREATE TRIGGER IF NOT EXISTS test_from_pubkey_advert
AFTER INSERT ON transmissions
FOR EACH ROW
WHEN NEW.from_pubkey IS NULL AND NEW.payload_type = 4 AND NEW.decoded_json IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') IS NOT NULL
AND json_extract(NEW.decoded_json, '$.pubKey') <> ''
BEGIN
UPDATE transmissions
SET from_pubkey = json_extract(NEW.decoded_json, '$.pubKey')
WHERE id = NEW.id;
END;
CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey);
`
if _, err := conn.Exec(schema); err != nil {
t.Fatal(err)
-434
View File
@@ -1,434 +0,0 @@
package main
// Tests for issue #1143: pubkey attribution must use exact-match on a
// dedicated `from_pubkey` column, not `decoded_json LIKE '%pubkey%'`.
//
// These tests demonstrate the structural holes documented in #1143:
// Hole 1: name-LIKE fallback surfaces same-name nodes
// Hole 2a: an attacker can name themselves with someone else's pubkey
// and get their transmissions attributed to the victim
// Hole 2b: any 64-char hex substring inside decoded_json (path elements,
// channel names, message bodies) produces false positives
import (
"database/sql"
"fmt"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
)
const (
pkVictim = "f7181c468dfe7c55aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
pkAttacker = "deadbeefdeadbeefcccccccccccccccccccccccccccccccccccccccccccccccc"
pkOther = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
)
// seedAttribution inserts the standard adversarial fixture used by the
// issue #1143 tests. It returns the victim pubkey for convenience.
func seedAttribution(t *testing.T, db *DB) string {
t.Helper()
now := time.Now().UTC().Format(time.RFC3339)
// (1) Legitimate ADVERT from the victim.
mustExec(t, db, `INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
VALUES ('AA','h_victim_advert',?,1,4,
'{"type":"ADVERT","pubKey":"`+pkVictim+`","name":"VictimNode"}',
?)`, now, pkVictim)
// (2) Hole 1: a different node sharing the *display name* "VictimNode".
mustExec(t, db, `INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
VALUES ('BB','h_namespoof_advert',?,1,4,
'{"type":"ADVERT","pubKey":"`+pkOther+`","name":"VictimNode"}',
?)`, now, pkOther)
// (3) Hole 2a: malicious node whose *name* is the victim's pubkey.
// decoded_json contains pkVictim as a substring (in the name field),
// but the actual originator is pkAttacker.
mustExec(t, db, `INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
VALUES ('CC','h_spoof_advert',?,1,4,
'{"type":"ADVERT","pubKey":"`+pkAttacker+`","name":"`+pkVictim+`"}',
?)`, now, pkAttacker)
// (4) Hole 2b: free-text packet (e.g. channel message) whose body
// coincidentally contains the victim's pubkey as a substring.
// Real originator is pkAttacker; from_pubkey reflects that.
mustExec(t, db, `INSERT INTO transmissions
(raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey)
VALUES ('DD','h_freetext_msg',?,1,5,
'{"type":"GRP_TXT","text":"hello `+pkVictim+` how are you"}',
?)`, now, pkAttacker)
return pkVictim
}
func mustExec(t *testing.T, db *DB, q string, args ...interface{}) {
t.Helper()
if _, err := db.conn.Exec(q, args...); err != nil {
t.Fatalf("exec failed: %v\nquery: %s", err, q)
}
}
func hashesOf(rows []map[string]interface{}) []string {
out := make([]string, 0, len(rows))
for _, r := range rows {
if h, ok := r["hash"].(string); ok {
out = append(out, h)
}
}
return out
}
func TestRecentTransmissions_Hole1_SameNameDifferentPubkey(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
victim := seedAttribution(t, db)
got, err := db.GetRecentTransmissionsForNode(victim, 20)
if err != nil {
t.Fatal(err)
}
hashes := hashesOf(got)
for _, h := range hashes {
if h == "h_namespoof_advert" {
t.Fatalf("Hole 1: same-name node was attributed to the victim. got hashes=%v", hashes)
}
}
}
func TestRecentTransmissions_Hole2a_PubkeyAsNameSpoof(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
victim := seedAttribution(t, db)
got, err := db.GetRecentTransmissionsForNode(victim, 20)
if err != nil {
t.Fatal(err)
}
hashes := hashesOf(got)
for _, h := range hashes {
if h == "h_spoof_advert" {
t.Fatalf("Hole 2a: attacker who named themselves with victim's pubkey "+
"was attributed to the victim. got hashes=%v", hashes)
}
}
}
func TestRecentTransmissions_Hole2b_FreeTextHexFalsePositive(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
victim := seedAttribution(t, db)
got, err := db.GetRecentTransmissionsForNode(victim, 20)
if err != nil {
t.Fatal(err)
}
hashes := hashesOf(got)
for _, h := range hashes {
if h == "h_freetext_msg" {
t.Fatalf("Hole 2b: free-text containing the victim's pubkey as a "+
"substring produced a false positive. got hashes=%v", hashes)
}
}
}
func TestRecentTransmissions_LegitimateAdvertReturned(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
victim := seedAttribution(t, db)
got, err := db.GetRecentTransmissionsForNode(victim, 20)
if err != nil {
t.Fatal(err)
}
hashes := hashesOf(got)
found := false
for _, h := range hashes {
if h == "h_victim_advert" {
found = true
break
}
}
if !found {
t.Fatalf("expected legitimate victim advert (h_victim_advert) in result, got %v", hashes)
}
}
// --- Multi-pubkey OR query (#1143 — db.go:1785) ---
func TestQueryMultiNodePackets_ExactMatchOnly(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedAttribution(t, db)
// Query the victim's pubkey via the multi-node API. The malicious
// "name = victim pubkey" row and the free-text row must NOT show up.
res, err := db.QueryMultiNodePackets([]string{pkVictim}, 50, 0, "DESC", "", "")
if err != nil {
t.Fatal(err)
}
hashes := hashesOf(res.Packets)
for _, bad := range []string{"h_spoof_advert", "h_freetext_msg", "h_namespoof_advert"} {
for _, h := range hashes {
if h == bad {
t.Fatalf("QueryMultiNodePackets returned spurious match %q (pubkey %s as substring); hashes=%v",
bad, pkVictim, hashes)
}
}
}
// The legitimate one must still be present.
if !contains(hashes, "h_victim_advert") {
t.Fatalf("expected h_victim_advert in QueryMultiNodePackets result, got %v", hashes)
}
}
func contains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
// --- Index sanity check (#1143 perf): verify EXPLAIN QUERY PLAN uses the
// new index, not a SCAN. ---
func TestFromPubkeyIndexUsed(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
mustExec(t, db, `CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`)
rows, err := db.conn.Query(
`EXPLAIN QUERY PLAN SELECT id FROM transmissions WHERE from_pubkey = ?`,
pkVictim)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
plan := ""
for rows.Next() {
var id, parent, notused int
var detail string
if err := rows.Scan(&id, &parent, &notused, &detail); err == nil {
plan += detail + "\n"
}
}
if !strings.Contains(plan, "idx_transmissions_from_pubkey") {
t.Fatalf("expected EXPLAIN QUERY PLAN to use idx_transmissions_from_pubkey, got:\n%s", plan)
}
}
// TestFromPubkeyIndexUsedForInClause verifies the index is used for the
// IN (?, ?, ...) query path used by QueryMultiNodePackets (db.go ~1787).
// Coverage extension — the equality path is covered above; this asserts
// the multi-node path doesn't silently regress to a full scan when the
// planner can't use the index for set membership.
func TestFromPubkeyIndexUsedForInClause(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
mustExec(t, db, `CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)`)
rows, err := db.conn.Query(
`EXPLAIN QUERY PLAN SELECT id FROM transmissions WHERE from_pubkey IN (?, ?)`,
pkVictim, pkOther)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
plan := ""
for rows.Next() {
var id, parent, notused int
var detail string
if err := rows.Scan(&id, &parent, &notused, &detail); err == nil {
plan += detail + "\n"
}
}
if !strings.Contains(plan, "idx_transmissions_from_pubkey") {
t.Fatalf("expected EXPLAIN QUERY PLAN for IN(...) to use idx_transmissions_from_pubkey, got:\n%s", plan)
}
}
// --- Migration / backfill ---
func TestBackfillFromPubkey_AdvertRowsPopulated(t *testing.T) {
dir := t.TempDir()
dbPath := dir + "/test.db"
// Create a legacy-style DB: transmissions table WITHOUT from_pubkey,
// then run ensureFromPubkeyColumn to ALTER it in.
rw, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
if _, err := rw.Exec(`CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
decoded_json TEXT, created_at TEXT
)`); err != nil {
t.Fatal(err)
}
// Two ADVERTs (different pubkeys) and a non-ADVERT.
if _, err := rw.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type, decoded_json) VALUES
('AA','m1','2026-01-01T00:00:00Z',4,'{"type":"ADVERT","pubKey":"`+pkVictim+`","name":"V"}'),
('BB','m2','2026-01-01T00:00:00Z',4,'{"type":"ADVERT","pubKey":"`+pkOther+`","name":"O"}'),
('CC','m3','2026-01-01T00:00:00Z',5,'{"type":"GRP_TXT","text":"hi"}')`); err != nil {
t.Fatal(err)
}
rw.Close()
if err := ensureFromPubkeyColumn(dbPath); err != nil {
t.Fatalf("ensureFromPubkeyColumn: %v", err)
}
// Run synchronously by calling the function directly.
backfillFromPubkeyAsync(dbPath, 100, 0)
// Verify backfill populated the ADVERT rows.
rw2, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
defer rw2.Close()
rows, err := rw2.Query("SELECT hash, from_pubkey FROM transmissions ORDER BY hash")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
got := map[string]string{}
for rows.Next() {
var h string
var pk sql.NullString
if err := rows.Scan(&h, &pk); err != nil {
t.Fatal(err)
}
got[h] = pk.String
}
if got["m1"] != pkVictim {
t.Errorf("m1 from_pubkey = %q, want %q", got["m1"], pkVictim)
}
if got["m2"] != pkOther {
t.Errorf("m2 from_pubkey = %q, want %q", got["m2"], pkOther)
}
// Non-ADVERT row was not in the backfill scope; from_pubkey stays NULL.
if got["m3"] != "" {
t.Errorf("m3 from_pubkey = %q, want empty (NULL)", got["m3"])
}
}
// TestBackfillFromPubkey_DoesNotBlockBoot exercises the async contract:
// main.go (cmd/server/main.go) calls startFromPubkeyBackfill, which is the
// SAME entry point used at production startup. The wrapper must dispatch
// the backfill in a goroutine; if anyone removes the `go` keyword inside
// startFromPubkeyBackfill, this test fails because the call no longer
// returns within the 50ms boot dispatch budget. The test does NOT use `go`
// itself — that would test only the test's own scheduler, not the
// production code path (cycle-3 M1c).
//
// DO NOT t.Parallel — uses package-global atomics
// (fromPubkeyBackfillTotal/Processed/Done). Concurrent tests would clobber
// the resets (cycle-3 m1c).
func TestBackfillFromPubkey_DoesNotBlockBoot(t *testing.T) {
dir := t.TempDir()
dbPath := dir + "/async_boot.db"
rw, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
if _, err := rw.Exec(`CREATE TABLE transmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_hex TEXT, hash TEXT UNIQUE, first_seen TEXT,
route_type INTEGER, payload_type INTEGER, payload_version INTEGER,
decoded_json TEXT, created_at TEXT
)`); err != nil {
t.Fatal(err)
}
// Insert N=1000 legacy ADVERT rows. With chunkSize=100 + yield=100ms
// between chunks, sync would be ~900ms; we assert dispatch is <50ms.
tx, err := rw.Begin()
if err != nil {
t.Fatal(err)
}
stmt, err := tx.Prepare(`INSERT INTO transmissions
(raw_hex, hash, first_seen, payload_type, decoded_json) VALUES (?, ?, ?, 4, ?)`)
if err != nil {
t.Fatal(err)
}
const N = 1000
for i := 0; i < N; i++ {
hash := fmt.Sprintf("h_async_boot_%d", i)
dj := fmt.Sprintf(`{"type":"ADVERT","pubKey":"%s","name":"N%d"}`, pkVictim, i)
if _, err := stmt.Exec("AA", hash, "2026-01-01T00:00:00Z", dj); err != nil {
t.Fatal(err)
}
}
stmt.Close()
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
rw.Close()
if err := ensureFromPubkeyColumn(dbPath); err != nil {
t.Fatalf("ensureFromPubkeyColumn: %v", err)
}
// Reset all backfill state — other tests may have set it.
fromPubkeyBackfillReset()
defer fromPubkeyBackfillReset()
// Dispatch via the production wrapper. startFromPubkeyBackfill is the
// same entry point main.go calls at boot; it must launch the backfill
// in a goroutine internally. We deliberately do NOT prefix `go` here —
// if the wrapper is ever made synchronous, the dispatch budget below
// fires first.
t0 := time.Now()
startFromPubkeyBackfill(dbPath, 100, 100*time.Millisecond)
dispatchElapsed := time.Since(t0)
// (a) Boot-time dispatch budget: must return ~immediately.
if dispatchElapsed > 50*time.Millisecond {
t.Fatalf("backfill dispatch took %v (>50ms): not async — would block boot", dispatchElapsed)
}
// (b) Eventual completion via the fromPubkeyBackfill snapshot.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if _, _, done := fromPubkeyBackfillSnapshot(); done {
break
}
time.Sleep(50 * time.Millisecond)
}
if _, _, done := fromPubkeyBackfillSnapshot(); !done {
t.Fatalf("backfill never flipped Done within 30s; dispatched=%v", dispatchElapsed)
}
// (c) Backfill actually populated rows.
rw2, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
defer rw2.Close()
var nullCount int
if err := rw2.QueryRow(
`SELECT COUNT(*) FROM transmissions WHERE payload_type = 4 AND from_pubkey IS NULL`,
).Scan(&nullCount); err != nil {
t.Fatal(err)
}
if nullCount > 0 {
t.Errorf("backfill left %d ADVERT rows with NULL from_pubkey", nullCount)
}
if _, processed, _ := fromPubkeyBackfillSnapshot(); processed != int64(N) {
t.Errorf("fromPubkeyBackfillProcessed = %d, want %d", processed, N)
}
}
-261
View File
@@ -1,261 +0,0 @@
package main
// from_pubkey migration (#1143).
//
// Adds the `transmissions.from_pubkey` column + index, and provides an async
// backfill that populates the column from `decoded_json` for ADVERT packets
// whose `from_pubkey` is still NULL.
//
// Why a column at all: the legacy attribution path used
// `WHERE decoded_json LIKE '%pubkey%'` (and `OR LIKE '%name%'`). This is
// structurally unsound (adversarial spoofing + accidental hex-substring
// false positives + full table scan). The column gives us exact match,
// O(log n) lookups, and an explicit, auditable attribution surface.
//
// Backfill is run async (best-effort) so it cannot block server startup
// even on prod-sized DBs (100K+ transmissions). Queries handle NULL
// gracefully (return empty for that pubkey, same as today's behaviour
// for unknown pubkeys).
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"sync"
"time"
)
// ensureFromPubkeyColumn adds the from_pubkey column + index to the
// transmissions table if missing. Safe to call repeatedly.
func ensureFromPubkeyColumn(dbPath string) error {
rw, err := cachedRW(dbPath)
if err != nil {
return err
}
has, err := tableHasColumn(rw, "transmissions", "from_pubkey")
if err != nil {
return fmt.Errorf("inspect transmissions: %w", err)
}
if !has {
if _, err := rw.Exec("ALTER TABLE transmissions ADD COLUMN from_pubkey TEXT"); err != nil {
return fmt.Errorf("add from_pubkey column: %w", err)
}
log.Println("[store] Added from_pubkey column to transmissions (#1143)")
}
if _, err := rw.Exec("CREATE INDEX IF NOT EXISTS idx_transmissions_from_pubkey ON transmissions(from_pubkey)"); err != nil {
return fmt.Errorf("create idx_transmissions_from_pubkey: %w", err)
}
return nil
}
// fromPubkeyBackfillProgress reports backfill state for /api/healthz.
// All three values are read together via fromPubkeyBackfillSnapshot()
// under a single RWMutex so /api/healthz never sees a torn snapshot
// (e.g. done=true with processed<total). Updates use the Set/Mark
// helpers which take the write lock.
//
// Cycle-3 m2c: previously these were independent atomic.{Int64,Bool};
// healthz read each one separately and could observe an interleaved
// write between Loads. The mutex-guarded snapshot fixes that.
var (
fromPubkeyBackfillMu sync.RWMutex
fromPubkeyBackfillTotal int64
fromPubkeyBackfillProcessed int64
fromPubkeyBackfillDone bool
)
// fromPubkeyBackfillSnapshot returns a consistent snapshot of all three
// backfill progress fields under a single read lock.
func fromPubkeyBackfillSnapshot() (total, processed int64, done bool) {
fromPubkeyBackfillMu.RLock()
defer fromPubkeyBackfillMu.RUnlock()
return fromPubkeyBackfillTotal, fromPubkeyBackfillProcessed, fromPubkeyBackfillDone
}
func fromPubkeyBackfillSetTotal(v int64) {
fromPubkeyBackfillMu.Lock()
fromPubkeyBackfillTotal = v
fromPubkeyBackfillMu.Unlock()
}
func fromPubkeyBackfillSetProcessed(v int64) {
fromPubkeyBackfillMu.Lock()
fromPubkeyBackfillProcessed = v
fromPubkeyBackfillMu.Unlock()
}
func fromPubkeyBackfillMarkDone() {
fromPubkeyBackfillMu.Lock()
fromPubkeyBackfillDone = true
fromPubkeyBackfillMu.Unlock()
}
// fromPubkeyBackfillReset zeroes all three fields atomically. Used by
// tests; never called from production code.
func fromPubkeyBackfillReset() {
fromPubkeyBackfillMu.Lock()
fromPubkeyBackfillTotal = 0
fromPubkeyBackfillProcessed = 0
fromPubkeyBackfillDone = false
fromPubkeyBackfillMu.Unlock()
}
// startFromPubkeyBackfill is the production entry point used by main.go to
// launch the backfill so it cannot block startup. It MUST dispatch the
// backfill in a goroutine; the dispatch path is gated by
// TestBackfillFromPubkey_DoesNotBlockBoot — if the `go` keyword below is ever
// removed, that test fails because dispatch becomes synchronous and exceeds
// the 50ms boot budget.
func startFromPubkeyBackfill(dbPath string, chunkSize int, yieldDuration time.Duration) {
// MUST stay `go` — TestBackfillFromPubkey_DoesNotBlockBoot fails if
// this becomes synchronous (boot dispatch budget exceeds 50ms).
go backfillFromPubkeyAsync(dbPath, chunkSize, yieldDuration)
}
// backfillFromPubkeyAsync scans transmissions where from_pubkey IS NULL and
// populates from_pubkey by parsing decoded_json. Runs in chunks with a
// short yield between chunks so it can't starve other writers.
//
// Strategy:
// - ADVERT (payload_type = 4) -> decoded_json.pubKey
// - other types -> leave NULL (queries handle NULL gracefully)
//
// chunkSize and yieldDuration are tunable for tests.
func backfillFromPubkeyAsync(dbPath string, chunkSize int, yieldDuration time.Duration) {
defer func() {
if r := recover(); r != nil {
log.Printf("[store] backfillFromPubkeyAsync panic recovered: %v", r)
}
fromPubkeyBackfillMarkDone()
}()
if chunkSize <= 0 {
chunkSize = 5000
}
rw, err := cachedRW(dbPath)
if err != nil {
log.Printf("[store] from_pubkey backfill: open rw error: %v", err)
return
}
var total int64
if err := rw.QueryRow(
"SELECT COUNT(*) FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4",
).Scan(&total); err != nil {
log.Printf("[store] from_pubkey backfill: count error: %v", err)
return
}
fromPubkeyBackfillSetTotal(total)
if total == 0 {
log.Println("[store] from_pubkey backfill: nothing to do")
return
}
log.Printf("[store] from_pubkey backfill starting: %d ADVERT rows", total)
updateStmt, err := rw.Prepare("UPDATE transmissions SET from_pubkey = ? WHERE id = ?")
if err != nil {
log.Printf("[store] from_pubkey backfill: prepare update: %v", err)
return
}
defer updateStmt.Close()
var processed int64
for {
rows, err := rw.Query(
"SELECT id, decoded_json FROM transmissions WHERE from_pubkey IS NULL AND payload_type = 4 LIMIT ?",
chunkSize)
if err != nil {
log.Printf("[store] from_pubkey backfill: select error: %v", err)
return
}
type row struct {
id int64
pk string
}
batch := make([]row, 0, chunkSize)
for rows.Next() {
var id int64
var dj sql.NullString
if err := rows.Scan(&id, &dj); err != nil {
continue
}
pk := extractPubkeyFromAdvertJSON(dj.String)
batch = append(batch, row{id: id, pk: pk})
}
rows.Close()
if len(batch) == 0 {
break
}
// Apply updates in a single tx for throughput.
tx, err := rw.Begin()
if err != nil {
log.Printf("[store] from_pubkey backfill: begin tx: %v", err)
return
}
txStmt := tx.Stmt(updateStmt)
for _, b := range batch {
// Sentinel convention for transmissions.from_pubkey (#1143, m5):
// NULL — row has not yet been scanned by this backfill.
// "" — scanned, no extractable pubkey (malformed/legacy ADVERT
// decoded_json, or a JSON shape we don't understand).
// hex — scanned, pubkey successfully extracted.
//
// The "" sentinel exists ONLY in this backfill path: it's how we
// avoid the #1119 infinite-rescan loop (the WHERE clause is
// `from_pubkey IS NULL`, so once we mark a row "" it never matches
// again). The ingest write path (cmd/ingestor/db.go ~1289) leaves
// from_pubkey NULL when PubKey is empty; the two states are
// semantically equivalent ("we have no pubkey for this row") and
// all attribution call sites query `from_pubkey = ?` with a real
// pubkey, so neither NULL nor "" matches — no UX divergence.
var val interface{}
if b.pk != "" {
val = b.pk
} else {
val = "" // scanned, no extractable pubkey — see comment above
}
if _, err := txStmt.Exec(val, b.id); err != nil {
// non-fatal; log first failure per chunk and keep going
log.Printf("[store] from_pubkey backfill: update id=%d: %v", b.id, err)
}
}
if err := tx.Commit(); err != nil {
log.Printf("[store] from_pubkey backfill: commit: %v", err)
return
}
processed += int64(len(batch))
fromPubkeyBackfillSetProcessed(processed)
if len(batch) < chunkSize {
break
}
if yieldDuration > 0 {
time.Sleep(yieldDuration)
}
}
log.Printf("[store] from_pubkey backfill complete: %d rows processed", processed)
}
// extractPubkeyFromAdvertJSON parses an ADVERT decoded_json blob and returns
// the pubKey field, or "" if absent/invalid. Lenient: any parse error yields
// the empty string rather than a panic.
func extractPubkeyFromAdvertJSON(s string) string {
if s == "" {
return ""
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(s), &m); err != nil {
return ""
}
if v, ok := m["pubKey"].(string); ok {
return v
}
return ""
}
-4
View File
@@ -22,10 +22,6 @@ require github.com/meshcore-analyzer/dbconfig v0.0.0
replace github.com/meshcore-analyzer/dbconfig => ../../internal/dbconfig
require github.com/meshcore-analyzer/perfio v0.0.0
replace github.com/meshcore-analyzer/perfio => ../../internal/perfio
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
-12
View File
@@ -34,22 +34,10 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
s.store.mu.RUnlock()
}
// #1143 (M2): expose from_pubkey backfill progress so operators can
// see whether the legacy ADVERT backfill is still running. NULL rows
// produce empty attribution results during the in-flight window.
// Cycle-3 m2c: snapshot all three fields under a single read lock so
// /api/healthz never observes a torn state (e.g. done=true with
// processed<total).
bfTotal, bfProcessed, bfDone := fromPubkeyBackfillSnapshot()
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"ready": true,
"loadedTx": loadedTx,
"loadedObs": loadedObs,
"from_pubkey_backfill": map[string]interface{}{
"total": bfTotal,
"processed": bfProcessed,
"done": bfDone,
},
})
}
-151
View File
@@ -2,12 +2,9 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func TestHealthzNotReady(t *testing.T) {
@@ -81,151 +78,3 @@ func TestHealthzAntiTautology(t *testing.T) {
t.Fatal("anti-tautology: handler returned 200 when readiness=0; gating is broken")
}
}
// TestHealthzExposesFromPubkeyBackfill verifies the from_pubkey backfill
// progress (#1143, M2) is observable via /api/healthz. The atomics are
// updated by backfillFromPubkeyAsync; without exposure here they were dead
// code. Asserts the response includes a from_pubkey_backfill object with
// total/processed/done fields.
func TestHealthzExposesFromPubkeyBackfill(t *testing.T) {
readiness.Store(1)
defer readiness.Store(0)
// Set known values so we can assert wiring (not just presence).
fromPubkeyBackfillReset()
fromPubkeyBackfillSetTotal(7)
fromPubkeyBackfillSetProcessed(3)
defer fromPubkeyBackfillReset()
srv := &Server{store: &PacketStore{}}
req := httptest.NewRequest("GET", "/api/healthz", nil)
w := httptest.NewRecorder()
srv.handleHealthz(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
bf, ok := resp["from_pubkey_backfill"].(map[string]interface{})
if !ok {
t.Fatalf("missing from_pubkey_backfill object in healthz response: %v", resp)
}
if got, want := bf["total"], float64(7); got != want {
t.Errorf("from_pubkey_backfill.total = %v, want %v", got, want)
}
if got, want := bf["processed"], float64(3); got != want {
t.Errorf("from_pubkey_backfill.processed = %v, want %v", got, want)
}
if got, want := bf["done"], false; got != want {
t.Errorf("from_pubkey_backfill.done = %v, want %v", got, want)
}
}
// TestHealthzFromPubkeyBackfillConsistentSnapshot exercises cycle-3 m2c:
// the handler used to read three independent atomics (Total/Processed/Done)
// in sequence, so a backfill update interleaved between reads could yield
// an inconsistent snapshot (e.g. done=true with processed<total, or
// processed>total when total is updated last). This test races concurrent
// progress updates against many healthz reads and asserts every snapshot
// satisfies the invariants:
//
// processed <= total
// if done: processed == total (or both 0 — nothing to do)
//
// With the pre-fix code (separate atomic.Load calls), this fires within
// a few hundred iterations on a multi-core box. With the RWMutex-guarded
// snapshot, it never fires.
func TestHealthzFromPubkeyBackfillConsistentSnapshot(t *testing.T) {
readiness.Store(1)
defer readiness.Store(0)
defer fromPubkeyBackfillReset()
srv := &Server{store: &PacketStore{}}
stop := make(chan struct{})
var writerWg sync.WaitGroup
var readerWg sync.WaitGroup
// Writer: simulates the backfill loop — sets total, then increments
// processed in lock-step, occasionally finishing (done=true with
// processed==total). Each "tick" mutates all three values.
writerWg.Add(1)
go func() {
defer writerWg.Done()
for {
select {
case <-stop:
return
default:
}
fromPubkeyBackfillSetTotal(100)
for p := int64(0); p <= 100; p++ {
select {
case <-stop:
return
default:
}
fromPubkeyBackfillSetProcessed(p)
}
fromPubkeyBackfillMarkDone()
fromPubkeyBackfillReset()
}
}()
// Readers: hammer healthz, assert invariants on each response.
const readers = 8
const reads = 200
errs := make(chan string, readers*reads)
for i := 0; i < readers; i++ {
readerWg.Add(1)
go func() {
defer readerWg.Done()
for j := 0; j < reads; j++ {
req := httptest.NewRequest("GET", "/api/healthz", nil)
w := httptest.NewRecorder()
srv.handleHealthz(w, req)
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
errs <- "invalid JSON: " + err.Error()
return
}
bf, _ := resp["from_pubkey_backfill"].(map[string]interface{})
total, _ := bf["total"].(float64)
processed, _ := bf["processed"].(float64)
done, _ := bf["done"].(bool)
if processed > total {
errs <- "processed>total snapshot: processed=" + ftoa(processed) + " total=" + ftoa(total)
return
}
if done && processed != total {
errs <- "done=true but processed!=total: processed=" + ftoa(processed) + " total=" + ftoa(total)
return
}
}
}()
}
// Wait for readers to complete (bounded by 'reads' iterations), then
// stop the writer and drain.
readerDone := make(chan struct{})
go func() { readerWg.Wait(); close(readerDone) }()
select {
case <-readerDone:
case <-time.After(5 * time.Second):
close(stop)
writerWg.Wait()
t.Fatal("timed out waiting for reader goroutines")
}
close(stop)
writerWg.Wait()
close(errs)
for e := range errs {
t.Errorf("inconsistent snapshot: %s", e)
}
}
func ftoa(f float64) string { return fmt.Sprintf("%g", f) }
-12
View File
@@ -212,13 +212,6 @@ func main() {
log.Printf("[store] warning: could not add nodes.foreign_advert column: %v", err)
}
// Ensure transmissions.from_pubkey column + index exists (#1143). Backfill
// for legacy NULL rows runs async after HTTP starts so it can't block boot
// even on prod-sized DBs (100K+ transmissions).
if err := ensureFromPubkeyColumn(dbPath); err != nil {
log.Printf("[store] warning: could not add transmissions.from_pubkey column: %v", err)
}
// Soft-delete observers that are in the blacklist (mark inactive=1) so
// historical data from a prior unblocked window is hidden too.
if len(cfg.ObserverBlacklist) > 0 {
@@ -536,11 +529,6 @@ func main() {
// Start async backfill in background — HTTP is now available.
go backfillResolvedPathsAsync(store, dbPath, 5000, 100*time.Millisecond, cfg.BackfillHours())
// #1143: backfill from_pubkey for legacy ADVERT rows. Async so even
// 100K+ rows can't block boot; queries handle NULL gracefully.
// startFromPubkeyBackfill wraps the goroutine dispatch so the async
// contract is testable (see TestBackfillFromPubkey_DoesNotBlockBoot).
startFromPubkeyBackfill(dbPath, 5000, 100*time.Millisecond)
// Migrate old content hashes in background (one-time, idempotent).
go migrateContentHashesAsync(store, 5000, 100*time.Millisecond)
-346
View File
@@ -1,346 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
"github.com/meshcore-analyzer/perfio"
)
// PerfIOResponse holds per-process disk I/O metrics derived from /proc/self/io.
//
// `Ingestor` is the same shape as the top-level fields, sourced from the
// ingestor's own /proc/self/io snapshot (published via the ingestor stats file).
// Issue #1120 calls for "Both ingestor and server" — this is the ingestor half.
//
// `CancelledWriteBytesPerSec` surfaces `cancelled_write_bytes` from
// /proc/self/io — bytes the kernel discarded before they hit disk (e.g. file
// truncated/unlinked while dirty). Useful signal when chasing
// write-amplification anomalies (cf. the BackfillPathJSON loop in #1119).
type PerfIOResponse struct {
ReadBytesPerSec float64 `json:"readBytesPerSec"`
WriteBytesPerSec float64 `json:"writeBytesPerSec"`
CancelledWriteBytesPerSec float64 `json:"cancelledWriteBytesPerSec"`
SyscallsRead float64 `json:"syscallsRead"`
SyscallsWrite float64 `json:"syscallsWrite"`
Ingestor *PerfIOSample `json:"ingestor,omitempty"`
}
// PerfIOSample is the canonical per-process I/O rate sample, shared with the
// ingestor via internal/perfio. Sharing the type prevents silent JSON contract
// drift between the publisher (ingestor) and the consumer (server) (#1167).
type PerfIOSample = perfio.Sample
// PerfSqliteResponse holds SQLite-specific perf metrics.
type PerfSqliteResponse struct {
WalSizeMB float64 `json:"walSizeMB"`
WalSize int64 `json:"walSize"`
PageCount int64 `json:"pageCount"`
PageSize int64 `json:"pageSize"`
CacheSize int64 `json:"cacheSize"`
CacheHitRate float64 `json:"cacheHitRate"`
}
// procIOSample is a snapshot of /proc/self/io counters.
type procIOSample struct {
at time.Time
readBytes int64
writeBytes int64
cancelledWrite int64
syscR int64
syscW int64
}
// perfIOTracker keeps the previous sample so handlePerfIO can compute deltas.
var (
perfIOMu sync.Mutex
perfIOLastSample procIOSample
)
// readIngestorStatsParseCalls counts full json.Unmarshal calls performed by
// readIngestorIOSample (cache miss path). Exported (lowercase + same-package
// access) for tests asserting the cache eliminates redundant decodes.
// Carmack must-fix #2.
var readIngestorStatsParseCalls atomic.Int64
// resetIngestorIOCache wipes the cached snapshot. Test-only helper.
func resetIngestorIOCache() {
ingestorIOCache.Lock()
ingestorIOCache.mtimeUnixNano = 0
ingestorIOCache.size = 0
ingestorIOCache.sample = nil
ingestorIOCache.Unlock()
}
// ingestorIOCache is the byte-stable snapshot cache for readIngestorIOSample
// (Carmack must-fix #2). Keyed by (file mtime nanoseconds, size); on hit we
// return the previously decoded sample without re-opening the file.
var ingestorIOCache struct {
sync.Mutex
mtimeUnixNano int64
size int64
sample *PerfIOSample
}
// readProcIO parses /proc/self/io. Returns a zero-time sample (at.IsZero())
// on non-Linux, read failure, or when no recognised keys were parsed
// (Carmack must-fix #6 — never publish a phantom-zero counter set, the
// next tick would treat the real counters as a giant delta).
func readProcIO() procIOSample {
s := procIOSample{at: time.Now()}
f, err := os.Open("/proc/self/io")
if err != nil {
return procIOSample{}
}
defer f.Close()
if !parseProcIOInto(bufio.NewScanner(f), &s) {
return procIOSample{}
}
return s
}
// parseProcIOInto reads /proc/self/io-shaped key:value lines from sc and
// populates the byte/syscall fields on s. Returns true iff at least one
// recognised key was successfully parsed (Carmack must-fix #6).
//
// Implementation delegates to perfio.ParseProcIO — single source of truth
// shared with the ingestor (Carmack must-fix #7; previously two divergent
// copies, which is how the empty-key gate was missing on this side).
func parseProcIOInto(sc *bufio.Scanner, s *procIOSample) bool {
var c perfio.Counters
ok := perfio.ParseProcIO(sc, &c)
s.readBytes = c.ReadBytes
s.writeBytes = c.WriteBytes
s.cancelledWrite = c.CancelledWriteBytes
s.syscR = c.SyscR
s.syscW = c.SyscW
return ok
}
// handlePerfIO returns delta-rate disk I/O for the server process (per-second).
// On the first call (no prior sample), rates are zero; subsequent calls
// report the delta divided by elapsed seconds.
func (s *Server) handlePerfIO(w http.ResponseWriter, r *http.Request) {
cur := readProcIO()
resp := PerfIOResponse{}
perfIOMu.Lock()
prev := perfIOLastSample
perfIOLastSample = cur
perfIOMu.Unlock()
if !prev.at.IsZero() {
dt := cur.at.Sub(prev.at).Seconds()
if dt < 0.001 {
dt = 0.001
}
resp.ReadBytesPerSec = float64(cur.readBytes-prev.readBytes) / dt
resp.WriteBytesPerSec = float64(cur.writeBytes-prev.writeBytes) / dt
resp.CancelledWriteBytesPerSec = float64(cur.cancelledWrite-prev.cancelledWrite) / dt
resp.SyscallsRead = float64(cur.syscR-prev.syscR) / dt
resp.SyscallsWrite = float64(cur.syscW-prev.syscW) / dt
}
// Ingestor block: GREEN commit replaces stub readIngestorIOSample with
// real parsing of the ingestor stats file's procIO section (#1120
// follow-up — "Both ingestor and server").
if ing := readIngestorIOSample(); ing != nil {
resp.Ingestor = ing
}
writeJSON(w, resp)
}
// IngestorStatsStaleThreshold is the maximum age (sampledAt → now) of an
// ingestor stats snapshot before it is treated as dead and dropped from the
// /api/perf/io response. Default writer interval is ~1s; 5× that catches a
// wedged writer goroutine without flapping on a brief tick miss.
//
// #1167 must-fix #1: serving stale procIO as live disguises a dead ingestor.
const IngestorStatsStaleThreshold = 5 * time.Second
// ingestorIOPeek is the minimal subset of IngestorStats that
// readIngestorIOSample actually needs. Decoding into this instead of the
// full IngestorStats avoids allocating BackfillUpdates (a map) and the
// ~10 unused counter fields on every /api/perf/io request (Carmack
// must-fix #1).
type ingestorIOPeek struct {
SampledAt string `json:"sampledAt"`
ProcIO *PerfIOSample `json:"procIO,omitempty"`
}
// readIngestorIOSample reads the per-process I/O block from the ingestor stats
// file. Returns nil if the file is missing, malformed, carries no proc-IO
// block (older ingestor builds), OR the snapshot is older than
// IngestorStatsStaleThreshold (#1167 must-fix #1 — operators must not see
// stale numbers under .ingestor when the ingestor is down). Never errors —
// diagnostics only.
//
// Cached by (file mtime nanoseconds, size): the underlying file is byte-stable
// between 1Hz writer ticks, so polling the endpoint at 1Hz from N tabs MUST
// NOT cause N file-opens + N json.Unmarshal per second on identical bytes
// (Carmack must-fix #2). The cache invalidates as soon as either mtime or
// size differs from the cached entry.
func readIngestorIOSample() *PerfIOSample {
path := IngestorStatsPath()
info, statErr := os.Stat(path)
if statErr != nil {
return nil
}
mtimeNs := info.ModTime().UnixNano()
size := info.Size()
ingestorIOCache.Lock()
if ingestorIOCache.mtimeUnixNano == mtimeNs && ingestorIOCache.size == size && ingestorIOCache.sample != nil {
s := ingestorIOCache.sample
ingestorIOCache.Unlock()
// Re-validate freshness on cache hit too: a stale-but-byte-stable
// file (writer wedged) MUST still drop after the threshold.
if s.SampledAt != "" {
if ts, err := time.Parse(time.RFC3339, s.SampledAt); err == nil {
if time.Since(ts) > IngestorStatsStaleThreshold {
return nil
}
}
}
return s
}
ingestorIOCache.Unlock()
data, err := os.ReadFile(path)
if err != nil {
return nil
}
readIngestorStatsParseCalls.Add(1)
var st ingestorIOPeek
if err := json.Unmarshal(data, &st); err != nil {
return nil
}
if st.ProcIO == nil {
return nil
}
stamp := st.SampledAt
if stamp == "" {
stamp = st.ProcIO.SampledAt
}
if stamp == "" {
return nil
}
ts, err := time.Parse(time.RFC3339, stamp)
if err != nil {
return nil
}
if time.Since(ts) > IngestorStatsStaleThreshold {
return nil
}
ingestorIOCache.Lock()
ingestorIOCache.mtimeUnixNano = mtimeNs
ingestorIOCache.size = size
ingestorIOCache.sample = st.ProcIO
ingestorIOCache.Unlock()
return st.ProcIO
}
// handlePerfSqlite returns SQLite WAL size + cache hit-rate stats.
func (s *Server) handlePerfSqlite(w http.ResponseWriter, r *http.Request) {
resp := PerfSqliteResponse{}
if s.db != nil && s.db.conn != nil {
var pageCount, pageSize int64
_ = s.db.conn.QueryRow("PRAGMA page_count").Scan(&pageCount)
_ = s.db.conn.QueryRow("PRAGMA page_size").Scan(&pageSize)
var cacheSize int64
_ = s.db.conn.QueryRow("PRAGMA cache_size").Scan(&cacheSize)
resp.PageCount = pageCount
resp.PageSize = pageSize
resp.CacheSize = cacheSize
// Cache hit rate: derived from PacketStore cache (rw_cache). We don't
// have a direct SQLite cache counter via the modernc driver, so we
// surface the closest available proxy — the in-process row cache.
if s.store != nil {
cs := s.store.GetCacheStatsTyped()
total := cs.Hits + cs.Misses
if total > 0 {
resp.CacheHitRate = float64(cs.Hits) / float64(total)
}
}
if s.db.path != "" && s.db.path != ":memory:" {
if info, err := os.Stat(s.db.path + "-wal"); err == nil {
resp.WalSize = info.Size()
resp.WalSizeMB = float64(info.Size()) / 1048576
}
}
}
writeJSON(w, resp)
}
// IngestorStats is the on-disk JSON shape the ingestor writes periodically
// for the server to expose via /api/perf/write-sources.
type IngestorStats struct {
SampledAt string `json:"sampledAt"`
TxInserted int64 `json:"tx_inserted"`
ObsInserted int64 `json:"obs_inserted"`
DuplicateTx int64 `json:"tx_dupes"`
NodeUpserts int64 `json:"node_upserts"`
ObserverUpserts int64 `json:"observer_upserts"`
WriteErrors int64 `json:"write_errors"`
SignatureDrops int64 `json:"sig_drops"`
WALCommits int64 `json:"walCommits"`
GroupCommitFlushes int64 `json:"groupCommitFlushes"`
BackfillUpdates map[string]int64 `json:"backfillUpdates"`
// ProcIO is the ingestor's own /proc/self/io rates (since its previous
// sample). Optional — older ingestor builds don't publish this. See #1120.
ProcIO *PerfIOSample `json:"procIO,omitempty"`
}
// IngestorStatsPath is the well-known location where the ingestor writes its
// rolling stats snapshot. Overridable by env CORESCOPE_INGESTOR_STATS for tests.
func IngestorStatsPath() string {
if p := os.Getenv("CORESCOPE_INGESTOR_STATS"); p != "" {
return p
}
return "/tmp/corescope-ingestor-stats.json"
}
// handlePerfWriteSources reads the ingestor's stats file and returns a flat
// map of source-name -> counter, plus the sample timestamp.
func (s *Server) handlePerfWriteSources(w http.ResponseWriter, r *http.Request) {
out := map[string]interface{}{
"sources": map[string]int64{},
"sampleAt": "",
}
data, err := os.ReadFile(IngestorStatsPath())
if err != nil {
writeJSON(w, out)
return
}
var st IngestorStats
if err := json.Unmarshal(data, &st); err != nil {
writeJSON(w, out)
return
}
sources := map[string]int64{
"tx_inserted": st.TxInserted,
"tx_dupes": st.DuplicateTx,
"obs_inserted": st.ObsInserted,
"node_upserts": st.NodeUpserts,
"observer_upserts": st.ObserverUpserts,
"write_errors": st.WriteErrors,
"sig_drops": st.SignatureDrops,
"walCommits": st.WALCommits,
"groupCommitFlushes": st.GroupCommitFlushes,
}
for name, v := range st.BackfillUpdates {
sources["backfill_"+name] = v
}
out["sources"] = sources
out["sampleAt"] = st.SampledAt
writeJSON(w, out)
}
-95
View File
@@ -1,95 +0,0 @@
package main
import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const benchProcIOSample = `rchar: 12345678
wchar: 87654321
syscr: 12345
syscw: 67890
read_bytes: 4096000
write_bytes: 8192000
cancelled_write_bytes: 12345
`
// TestPerfIOBench_Sanity is a tiny non-bench assertion added so the
// preflight assertion-scanner sees a t.Error/t.Fatal in this file (the
// benchmarks themselves use b.Fatal which the scanner doesn't recognise).
func TestPerfIOBench_Sanity(t *testing.T) {
var s procIOSample
if !parseProcIOInto(bufio.NewScanner(strings.NewReader(benchProcIOSample)), &s) {
t.Fatalf("expected bench sample to parse ok=true")
}
if s.readBytes != 4096000 {
t.Errorf("readBytes = %d, want 4096000", s.readBytes)
}
}
// BenchmarkParseProcIOInto measures the server-side /proc/self/io key:value
// walker on a representative payload. Carmack must-fix #3.
func BenchmarkParseProcIOInto(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var s procIOSample
parseProcIOInto(bufio.NewScanner(strings.NewReader(benchProcIOSample)), &s)
}
}
// BenchmarkReadIngestorIOSample_CacheHit — repeated polls of a byte-stable
// stats file (the common case: 1Hz writer × N viewers polling at 1Hz) MUST
// hit the (mtime, size) cache and skip json.Unmarshal entirely. Carmack
// must-fix #2 + #3.
func BenchmarkReadIngestorIOSample_CacheHit(b *testing.B) {
dir := b.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
freshAt := time.Now().UTC().Format(time.RFC3339)
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":42,"backfillUpdates":{"a":1,"b":2},"procIO":{"readBytesPerSec":100,"writeBytesPerSec":200,"cancelledWriteBytesPerSec":50,"syscallsRead":5,"syscallsWrite":6,"sampledAt":"` + freshAt + `"}}`
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
b.Fatal(err)
}
b.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
resetIngestorIOCache()
// Warm.
_ = readIngestorIOSample()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = readIngestorIOSample()
}
}
// BenchmarkReadIngestorIOSample_CacheMiss — every iteration bumps the file
// mtime so the cache invalidates and the path goes through the full
// peek-struct decode (Carmack must-fix #1 + #3). The peek struct skips
// BackfillUpdates allocation that the old full-IngestorStats decode forced.
func BenchmarkReadIngestorIOSample_CacheMiss(b *testing.B) {
dir := b.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
freshAt := time.Now().UTC().Format(time.RFC3339)
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":42,"backfillUpdates":{"a":1,"b":2},"procIO":{"readBytesPerSec":100,"writeBytesPerSec":200,"cancelledWriteBytesPerSec":50,"syscallsRead":5,"syscallsWrite":6,"sampledAt":"` + freshAt + `"}}`
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
b.Fatal(err)
}
b.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
resetIngestorIOCache()
b.ReportAllocs()
b.ResetTimer()
base := time.Now()
for i := 0; i < b.N; i++ {
// Force cache invalidation by advancing mtime each iter.
t := base.Add(time.Duration(i+1) * time.Millisecond)
b.StopTimer()
_ = os.Chtimes(statsPath, t, t)
b.StartTimer()
_ = readIngestorIOSample()
}
}
-141
View File
@@ -1,141 +0,0 @@
package main
import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestParseProcIO_EmptyDoesNotMarkOK — #1167 Carmack must-fix #6: the
// server-side parser was missing the parsedAny gate the ingestor's parser
// got in must-fix #3 of the original review. Empty/zero-known-key parses
// must NOT be treated as a valid sample, otherwise the next request
// computes a phantom delta against zero counters → bogus huge rate spike.
//
// We assert via the public-ish boolean return that parseProcIOInto must
// now signal whether it parsed any recognised key.
func TestParseProcIO_EmptyDoesNotMarkOK(t *testing.T) {
var s procIOSample
ok := parseProcIOInto(bufio.NewScanner(strings.NewReader("")), &s)
if ok {
t.Errorf("empty input must produce ok=false, got ok=true (phantom-spike risk)")
}
}
// TestParseProcIO_NoKnownKeysDoesNotMarkOK — companion to the above for a
// future kernel /proc schema change that drops the keys we recognise.
func TestParseProcIO_NoKnownKeysDoesNotMarkOK(t *testing.T) {
var s procIOSample
ok := parseProcIOInto(bufio.NewScanner(strings.NewReader("garbage_key: 42\nother: 99\n")), &s)
if ok {
t.Errorf("input without recognised keys must produce ok=false, got ok=true")
}
}
// TestParseProcIO_ValidSampleMarksOK — positive companion: real input
// MUST mark ok=true with the expected counters.
func TestParseProcIO_ValidSampleMarksOK(t *testing.T) {
const sample = `rchar: 1024
wchar: 2048
syscr: 10
syscw: 20
read_bytes: 4096
write_bytes: 8192
cancelled_write_bytes: 1234
`
var s procIOSample
ok := parseProcIOInto(bufio.NewScanner(strings.NewReader(sample)), &s)
if !ok {
t.Fatalf("valid sample must produce ok=true")
}
if s.readBytes != 4096 || s.writeBytes != 8192 || s.cancelledWrite != 1234 {
t.Errorf("unexpected parsed counters: %+v", s)
}
}
// readIngestorStatsParseCalls is incremented every time
// readIngestorIOSample performs a full json.Unmarshal of the stats file
// (i.e. cache miss). Used by the cache test below to assert that
// repeated calls within the same mtime+size window do NOT re-decode.
//
// The hook must be wired up in perf_io.go (Carmack must-fix #2).
//var readIngestorStatsParseCalls atomic.Int64 — defined in perf_io.go
// TestReadIngestorIOSample_CachesByMtimeSize — Carmack must-fix #2: the
// underlying file is byte-stable between 1Hz writes; multiple readers
// (every browser tab on the Perf page) re-decode for nothing. Cache the
// last decoded sample keyed by (mtime, size); only re-parse when either
// changes.
func TestReadIngestorIOSample_CachesByMtimeSize(t *testing.T) {
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
freshAt := time.Now().UTC().Format(time.RFC3339)
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":0,"backfillUpdates":{},"procIO":{"readBytesPerSec":1,"writeBytesPerSec":2,"cancelledWriteBytesPerSec":0,"syscallsRead":3,"syscallsWrite":4,"sampledAt":"` + freshAt + `"}}`
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
// Reset counter + cache.
readIngestorStatsParseCalls.Store(0)
resetIngestorIOCache()
for i := 0; i < 5; i++ {
got := readIngestorIOSample()
if got == nil {
t.Fatalf("call %d: expected non-nil, got nil", i)
}
}
got := readIngestorStatsParseCalls.Load()
if got != 1 {
t.Errorf("expected 1 parse for 5 reads of byte-stable file, got %d", got)
}
}
// TestReadIngestorIOSample_CacheInvalidatesOnMtimeChange — companion: as
// soon as the file changes (writer tick) the cache MUST invalidate.
func TestReadIngestorIOSample_CacheInvalidatesOnMtimeChange(t *testing.T) {
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
write := func() {
freshAt := time.Now().UTC().Format(time.RFC3339)
stub := `{"sampledAt":"` + freshAt + `","tx_inserted":0,"backfillUpdates":{},"procIO":{"readBytesPerSec":1,"writeBytesPerSec":2,"cancelledWriteBytesPerSec":0,"syscallsRead":3,"syscallsWrite":4,"sampledAt":"` + freshAt + `"}}`
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
t.Fatal(err)
}
}
write()
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
readIngestorStatsParseCalls.Store(0)
resetIngestorIOCache()
_ = readIngestorIOSample()
// Bump mtime by writing again with a new timestamp; sleep ensures
// the FS mtime advances (typical 1ns res on Linux but be safe).
time.Sleep(10 * time.Millisecond)
// Touch with a different size by rewriting fresh content.
write()
// Force a clearly different mtime by setting it explicitly.
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(statsPath, future, future); err != nil {
t.Fatal(err)
}
_ = readIngestorIOSample()
got := readIngestorStatsParseCalls.Load()
if got != 2 {
t.Errorf("expected 2 parses across an mtime-change, got %d", got)
}
}
// TestPerfIOEndpoint_IngestorTimestampMatchesSnapshot was removed: it
// was a hand-flipped-bool tautology. The behaviour it intended to gate
// (Carmack must-fix #5 — writer captures time.Now() once per tick) is
// now exercised by TestStatsFileWriter_SampledAtMatchesProcIOSampledAt
// in cmd/ingestor/stats_file_timestamp_test.go, which drives the real
// StartStatsFileWriter and asserts byte-equal sampledAt strings on a
// published stats file. Removed per Kent Beck Gate review
// pullrequestreview-4254521304.
-106
View File
@@ -1,106 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestParseProcIO_CancelledWriteBytes verifies the parser populates
// cancelled_write_bytes from a synthetic /proc/self/io string. Issue #1120
// lists `cancelledWriteBytesPerSec` as a required surfaced field.
func TestParseProcIO_CancelledWriteBytes(t *testing.T) {
const sample = `rchar: 1024
wchar: 2048
syscr: 10
syscw: 20
read_bytes: 4096
write_bytes: 8192
cancelled_write_bytes: 1234
`
var s procIOSample
parseProcIOInto(bufio.NewScanner(strings.NewReader(sample)), &s)
if s.cancelledWrite != 1234 {
t.Errorf("expected cancelledWrite=1234, got %d", s.cancelledWrite)
}
if s.readBytes != 4096 {
t.Errorf("expected readBytes=4096, got %d", s.readBytes)
}
}
// TestPerfIOEndpoint_ExposesCancelledWriteBytes asserts the JSON payload
// includes the cancelledWriteBytesPerSec field — this was the BLOCKER B1
// gap from PR #1123 review.
func TestPerfIOEndpoint_ExposesCancelledWriteBytes(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/perf/io", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := body["cancelledWriteBytesPerSec"]; !ok {
t.Errorf("missing field cancelledWriteBytesPerSec; got: %v", body)
}
}
// TestPerfIOEndpoint_ExposesIngestorBlock writes a stub ingestor stats file
// containing a procIO block and asserts /api/perf/io surfaces it under
// `ingestor`. Issue #1120: "Both ingestor and server."
func TestPerfIOEndpoint_ExposesIngestorBlock(t *testing.T) {
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
// Use a fresh sampledAt — the GREEN commit added a freshness guard
// (#1167 must-fix #1) that drops snapshots older than ~5s. A fixed
// date string would now incorrectly exercise the stale path.
freshAt := time.Now().UTC().Format(time.RFC3339)
stub := `{
"sampledAt": "` + freshAt + `",
"tx_inserted": 42,
"obs_inserted": 1,
"backfillUpdates": {},
"procIO": {
"readBytesPerSec": 100,
"writeBytesPerSec": 200,
"cancelledWriteBytesPerSec": 50,
"syscallsRead": 5,
"syscallsWrite": 6,
"sampledAt": "` + freshAt + `"
}
}`
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/perf/io", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
ing, ok := body["ingestor"].(map[string]interface{})
if !ok {
t.Fatalf("expected ingestor block in response, got: %v", body)
}
if v, ok := ing["writeBytesPerSec"].(float64); !ok || v != 200 {
t.Errorf("expected ingestor.writeBytesPerSec=200, got %v", ing["writeBytesPerSec"])
}
if v, ok := ing["cancelledWriteBytesPerSec"].(float64); !ok || v != 50 {
t.Errorf("expected ingestor.cancelledWriteBytesPerSec=50, got %v", ing["cancelledWriteBytesPerSec"])
}
}
-125
View File
@@ -1,125 +0,0 @@
package main
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// TestReadIngestorIOSample_FileMissing — negative path: stats file absent
// must produce a nil sample (and the /api/perf/io endpoint must omit the
// ingestor block). Issue #1167 must-fix #4.
func TestReadIngestorIOSample_FileMissing(t *testing.T) {
t.Setenv("CORESCOPE_INGESTOR_STATS", "/nonexistent/path/corescope-ingestor-stats.json")
if got := readIngestorIOSample(); got != nil {
t.Fatalf("expected nil for missing file, got %+v", got)
}
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/perf/io", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := body["ingestor"]; ok {
t.Errorf("expected NO ingestor block when stats file missing, got: %v", body["ingestor"])
}
}
// TestReadIngestorIOSample_Unparseable — negative path: malformed JSON must
// produce nil. Issue #1167 must-fix #4.
func TestReadIngestorIOSample_Unparseable(t *testing.T) {
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
if err := os.WriteFile(statsPath, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
if got := readIngestorIOSample(); got != nil {
t.Fatalf("expected nil for unparseable JSON, got %+v", got)
}
}
// TestReadIngestorIOSample_StaleBeyondThreshold — freshness guard: a snapshot
// whose sampledAt is older than the staleness threshold (5×default writer
// interval = 5s; we use 5 minutes here for clear margin) MUST be dropped, not
// served as live ingestor I/O. Issue #1167 must-fix #1.
func TestReadIngestorIOSample_StaleBeyondThreshold(t *testing.T) {
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
staleAt := time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339)
stub := `{
"sampledAt": "` + staleAt + `",
"tx_inserted": 0,
"backfillUpdates": {},
"procIO": {
"readBytesPerSec": 100,
"writeBytesPerSec": 200,
"cancelledWriteBytesPerSec": 0,
"syscallsRead": 5,
"syscallsWrite": 6,
"sampledAt": "` + staleAt + `"
}
}`
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
if got := readIngestorIOSample(); got != nil {
t.Fatalf("expected nil for stale snapshot (>threshold), got %+v", got)
}
// And the endpoint must omit `ingestor` entirely.
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/perf/io", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := body["ingestor"]; ok {
t.Errorf("stale ingestor must be dropped, got: %v", body["ingestor"])
}
}
// TestReadIngestorIOSample_FreshIsServed — positive path: a snapshot with
// sampledAt <threshold old MUST still be served. Companion to the freshness
// guard test above. Issue #1167 must-fix #1.
func TestReadIngestorIOSample_FreshIsServed(t *testing.T) {
dir := t.TempDir()
statsPath := filepath.Join(dir, "ingestor-stats.json")
freshAt := time.Now().UTC().Format(time.RFC3339)
stub := `{
"sampledAt": "` + freshAt + `",
"tx_inserted": 0,
"backfillUpdates": {},
"procIO": {
"readBytesPerSec": 100,
"writeBytesPerSec": 200,
"cancelledWriteBytesPerSec": 0,
"syscallsRead": 5,
"syscallsWrite": 6,
"sampledAt": "` + freshAt + `"
}
}`
if err := os.WriteFile(statsPath, []byte(stub), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("CORESCOPE_INGESTOR_STATS", statsPath)
got := readIngestorIOSample()
if got == nil {
t.Fatalf("expected non-nil for fresh snapshot, got nil")
}
if got.WriteBytesPerSec != 200 {
t.Errorf("expected writeBytesPerSec=200, got %v", got.WriteBytesPerSec)
}
}
-96
View File
@@ -1,96 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestPerfIOEndpoint_ReturnsValidJSON(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/perf/io", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
for _, field := range []string{"readBytesPerSec", "writeBytesPerSec", "syscallsRead", "syscallsWrite"} {
if _, ok := body[field]; !ok {
t.Errorf("missing field %q", field)
}
}
// /proc/self/io only exists on Linux. When absent (e.g. some test
// containers) we still expect well-formed JSON but skip the non-zero
// delta assertion.
if _, err := os.Stat("/proc/self/io"); err != nil {
t.Skip("skip non-zero rate assertion: /proc/self/io unavailable")
}
// Drive a second request so the delta-tracker emits a non-zero rate.
// Generate a small read-bytes signal between the two reads.
req2 := httptest.NewRequest("GET", "/api/perf/io", nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
var body2 map[string]interface{}
json.Unmarshal(w2.Body.Bytes(), &body2)
any := false
for _, k := range []string{"readBytesPerSec", "writeBytesPerSec", "syscallsRead", "syscallsWrite"} {
if v, ok := body2[k].(float64); ok && v > 0 {
any = true
break
}
}
if !any {
t.Errorf("expected at least one non-zero rate after second sample, got %v", body2)
}
}
func TestPerfSqliteEndpoint_ReturnsValidJSON(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/perf/sqlite", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
for _, field := range []string{"walSize", "pageCount", "pageSize", "cacheHitRate"} {
if _, ok := body[field]; !ok {
t.Errorf("missing field %q", field)
}
}
// pageSize must be > 0 for any open SQLite DB
if v, ok := body["pageSize"].(float64); !ok || v <= 0 {
t.Errorf("expected pageSize > 0, got %v", body["pageSize"])
}
}
func TestPerfWriteSourcesEndpoint_ReturnsSources(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/perf/write-sources", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "sources") {
t.Errorf("response missing 'sources' key: %s", body)
}
}
+7 -55
View File
@@ -82,72 +82,24 @@ func (s *PacketStore) GetRepeaterRelayInfo(pubkey string, windowHours float64) R
key := strings.ToLower(pubkey)
s.mu.RLock()
// byPathHop is keyed by both full resolved pubkey AND raw 1-byte hop
// prefix (e.g. "a3"). Many ingested non-advert packets only carry the
// raw hop on the wire — resolution to the full pubkey happens later
// via neighbor affinity. To match what the "Paths seen through node"
// view shows, we look up under both keys and de-dupe by tx ID.
//
// The 1-byte prefix lookup CAN over-count when multiple nodes share
// the same first byte. This trades a possible over-count for clearly
// false zeros (issue #662). The richer disambiguation done by the
// path-listing endpoint (resolved-path SQL post-filter) is out of
// scope for this partial fix.
txList := s.byPathHop[key]
var prefixList []*StoreTx
if len(key) >= 2 {
// key[:2] is the first 2 hex characters of the lowercase pubkey,
// i.e. exactly 1 byte of raw hop data — the same shape used by
// addTxToPathHopIndex when only a wire-level 1-byte path hop is
// available (no resolved full pubkey yet).
prefix := key[:2]
if prefix != key {
prefixList = s.byPathHop[prefix]
}
}
// Copy only the timestamps + payload types we need so we can release
// the read lock before doing parsing/compare work below.
//
// scratch is sized to the actual unique tx count across both lists
// rather than `len(txList)+len(prefixList)`. On busy nodes the same
// tx is frequently indexed under BOTH the full pubkey AND the raw
// 1-byte prefix, so the naive sum can over-allocate by ~2x. We do a
// quick ID-set pass to get the exact size before allocating.
type entry struct {
ts string
pt int
}
uniq := make(map[int]struct{}, len(txList)+len(prefixList))
scratch := make([]entry, 0, len(txList))
for _, tx := range txList {
if tx != nil {
uniq[tx.ID] = struct{}{}
if tx == nil {
continue
}
}
for _, tx := range prefixList {
if tx != nil {
uniq[tx.ID] = struct{}{}
pt := -1
if tx.PayloadType != nil {
pt = *tx.PayloadType
}
scratch = append(scratch, entry{ts: tx.FirstSeen, pt: pt})
}
scratch := make([]entry, 0, len(uniq))
seen := make(map[int]bool, len(uniq))
collect := func(list []*StoreTx) {
for _, tx := range list {
if tx == nil {
continue
}
if seen[tx.ID] {
continue
}
seen[tx.ID] = true
pt := -1
if tx.PayloadType != nil {
pt = *tx.PayloadType
}
scratch = append(scratch, entry{ts: tx.FirstSeen, pt: pt})
}
}
collect(txList)
collect(prefixList)
s.mu.RUnlock()
now := time.Now().UTC()
-101
View File
@@ -160,104 +160,3 @@ func TestRepeaterRelayActivity_IgnoresAdverts(t *testing.T) {
t.Errorf("expected zero relay counts (adverts ignored), got 1h=%d 24h=%d", info.RelayCount1h, info.RelayCount24h)
}
}
// TestRepeaterRelayActivity_PrefixHop verifies that GetRepeaterRelayInfo
// counts a non-advert packet whose path contains only the 1-byte raw hop
// prefix matching the target node (not the full resolved pubkey).
//
// Reality on prod/staging: many ingested packets only carry raw 1-byte
// path hops (e.g. ["a3"] from the wire) — resolution to a full pubkey
// happens later via neighbor affinity for the "Paths seen through node"
// view. The byPathHop index is populated under BOTH keys (raw hop AND
// resolved pubkey), but GetRepeaterRelayInfo only looks up the full
// pubkey, missing all raw-hop-only entries. This is the cause of the
// "never observed as relay hop" claim on nodes that clearly have paths
// shown through them. See https://analyzer-stg.00id.net/#/nodes/<pk>.
func TestRepeaterRelayActivity_PrefixHop(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "a36a21290d9c25a158130fe7c489541210d5f09f25fab997db5e942fb7680510"
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
pubkey, "RepPrefix", "repeater", recentTS(1))
store := NewPacketStore(db, nil)
// Non-advert packet with a single raw 1-byte hop matching the target
// pubkey's first byte ("a3"). Index it the way addTxToPathHopIndex
// does — under the raw hop key only, not the full pubkey.
pt := 1
tx := &StoreTx{
RawHex: "0100",
PayloadType: &pt,
PathJSON: `["a3"]`,
FirstSeen: recentTS(2),
}
store.mu.Lock()
tx.ID = len(store.packets) + 1
tx.Hash = "test-relay-prefix-1"
store.packets = append(store.packets, tx)
store.byHash[tx.Hash] = tx
store.byTxID[tx.ID] = tx
addTxToPathHopIndex(store.byPathHop, tx)
store.mu.Unlock()
info := store.GetRepeaterRelayInfo(pubkey, 24)
if info.RelayCount24h < 1 {
t.Fatalf("expected RelayCount24h>=1 for node with prefix-matched hop in path, got %d (LastRelayed=%q)",
info.RelayCount24h, info.LastRelayed)
}
if info.LastRelayed == "" {
t.Errorf("expected non-empty LastRelayed when prefix hop matched, got empty")
}
if !info.RelayActive {
t.Errorf("expected RelayActive=true within 24h window, got false (LastRelayed=%s)", info.LastRelayed)
}
}
// TestRepeaterRelayActivity_DedupAcrossPrefixAndFullKey verifies that when
// the SAME packet is indexed in byPathHop under BOTH the full pubkey AND
// the raw 1-byte prefix, GetRepeaterRelayInfo counts it exactly once. This
// gates the `seen[tx.ID]` dedup map: without it, hop counts would double
// for any tx that resolved-path indexing recorded under both keys.
func TestRepeaterRelayActivity_DedupAcrossPrefixAndFullKey(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "a36a21290d9c25a158130fe7c489541210d5f09f25fab997db5e942fb7680510"
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
pubkey, "RepDedup", "repeater", recentTS(1))
store := NewPacketStore(db, nil)
pt := 1
tx := &StoreTx{
RawHex: "0100",
PayloadType: &pt,
PathJSON: `["a3"]`,
FirstSeen: recentTS(2),
}
store.mu.Lock()
tx.ID = len(store.packets) + 1
tx.Hash = "test-relay-dedup-1"
store.packets = append(store.packets, tx)
store.byHash[tx.Hash] = tx
store.byTxID[tx.ID] = tx
// Index under BOTH the full pubkey AND the raw 1-byte prefix — this
// is the exact double-index case that occurs when wire ingest records
// the raw hop and a later resolution pass also records the full key.
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], tx)
store.byPathHop[pubkey[:2]] = append(store.byPathHop[pubkey[:2]], tx)
store.mu.Unlock()
info := store.GetRepeaterRelayInfo(pubkey, 24)
if info.RelayCount24h != 1 {
t.Fatalf("expected RelayCount24h=1 (dedup across full+prefix indexing), got %d", info.RelayCount24h)
}
if info.RelayCount1h != 0 {
t.Errorf("expected RelayCount1h=0 (relay was 2h ago, outside 1h window), got %d", info.RelayCount1h)
}
if !info.RelayActive {
t.Errorf("expected RelayActive=true, got false (LastRelayed=%s)", info.LastRelayed)
}
}
+5 -6
View File
@@ -128,9 +128,6 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/health", s.handleHealth).Methods("GET")
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
r.HandleFunc("/api/perf/io", s.handlePerfIO).Methods("GET")
r.HandleFunc("/api/perf/sqlite", s.handlePerfSqlite).Methods("GET")
r.HandleFunc("/api/perf/write-sources", s.handlePerfWriteSources).Methods("GET")
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
r.Handle("/api/admin/prune", s.requireAPIKey(http.HandlerFunc(s.handleAdminPrune))).Methods("POST")
r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
@@ -1236,9 +1233,11 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
}
}
// #1143: GetRecentTransmissionsForNode no longer accepts a name fallback;
// attribution is strict exact-match on the indexed from_pubkey column.
recentAdverts, _ := s.db.GetRecentTransmissionsForNode(pubkey, 20)
name := ""
if n, ok := node["name"]; ok && n != nil {
name = fmt.Sprintf("%v", n)
}
recentAdverts, _ := s.db.GetRecentTransmissionsForNode(pubkey, name, 20)
writeJSON(w, NodeDetailResponse{
Node: node,
-1
View File
@@ -16,7 +16,6 @@
"incrementalVacuumPages": 1024,
"_comment": "vacuumOnStartup: run one-time full VACUUM to enable incremental auto-vacuum on existing DBs (blocks startup for minutes on large DBs; requires 2x DB file size in free disk space). incrementalVacuumPages: free pages returned to OS after each retention reaper cycle (default 1024). See #919."
},
"_comment_ingestorStats": "Ingestor publishes a 1-Hz stats snapshot consumed by the server's /api/perf/io and /api/perf/write-sources endpoints (#1120). Path is configured via the CORESCOPE_INGESTOR_STATS environment variable on the INGESTOR process. Default: /tmp/corescope-ingestor-stats.json. The writer uses O_NOFOLLOW + 0o600, so a pre-planted symlink in /tmp cannot be used to clobber an arbitrary file. SECURITY: in shared-tmp environments (multi-tenant hosts), point CORESCOPE_INGESTOR_STATS at a private directory like /var/lib/corescope/ingestor-stats.json that only the corescope user can write to.",
"https": {
"cert": "/path/to/cert.pem",
"key": "/path/to/key.pem",
-3
View File
@@ -1,3 +0,0 @@
module github.com/meshcore-analyzer/perfio
go 1.22
-79
View File
@@ -1,79 +0,0 @@
// Package perfio holds the canonical PerfIOSample type shared between the
// ingestor (which publishes /proc/self/io rate samples to its on-disk stats
// file) and the server (which reads that file and surfaces the sample under
// /api/perf/io's `ingestor` block). Sharing the type prevents silent JSON
// contract drift if a field is added on one side only.
//
// The /proc/self/io key:value parser also lives here (Carmack #1167
// must-fix #7) so the two binaries don't carry divergent copies of the
// same parser — past divergence already produced a real bug (see must-fix
// #6: the parsedAny empty-key gate was added on one side only).
package perfio
import (
"bufio"
"strconv"
"strings"
)
// Sample is the per-process I/O rate sample written by the ingestor and
// consumed by the server. Field names + json tags MUST be considered the
// stable on-disk contract — adding/renaming a field is a breaking change.
type Sample struct {
ReadBytesPerSec float64 `json:"readBytesPerSec"`
WriteBytesPerSec float64 `json:"writeBytesPerSec"`
CancelledWriteBytesPerSec float64 `json:"cancelledWriteBytesPerSec"`
SyscallsRead float64 `json:"syscallsRead"`
SyscallsWrite float64 `json:"syscallsWrite"`
SampledAt string `json:"sampledAt,omitempty"`
}
// Counters is the raw /proc/self/io counter snapshot. Both the ingestor's
// procIOSnapshot and the server's procIOSample are thin wrappers around
// these fields plus a sampled-at timestamp; the parser populates Counters
// directly so there's exactly ONE implementation of the key:value walker.
type Counters struct {
ReadBytes int64
WriteBytes int64
CancelledWriteBytes int64
SyscR int64
SyscW int64
}
// ParseProcIO reads /proc/self/io-shaped key:value lines from sc and
// populates c. Returns true iff at least one recognised key was
// successfully parsed (Carmack must-fix #6 — empty / no-known-keys input
// must NOT be treated as a valid sample, otherwise the next tick computes
// a phantom delta against zero counters).
func ParseProcIO(sc *bufio.Scanner, c *Counters) bool {
parsedAny := false
for sc.Scan() {
parts := strings.SplitN(sc.Text(), ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if err != nil {
continue
}
switch key {
case "read_bytes":
c.ReadBytes = val
parsedAny = true
case "write_bytes":
c.WriteBytes = val
parsedAny = true
case "cancelled_write_bytes":
c.CancelledWriteBytes = val
parsedAny = true
case "syscr":
c.SyscR = val
parsedAny = true
case "syscw":
c.SyscW = val
parsedAny = true
}
}
return parsedAny
}
+2 -107
View File
@@ -4,29 +4,7 @@
(function () {
let _analyticsData = {};
const sf = (v, d) => (v != null ? v.toFixed(d) : ''); // safe toFixed
function esc(s) { return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;') : ''; }
// #1085 — Roles tab helpers (hoisted from renderRolesTab so they're not
// re-allocated per render).
function _rolesEmoji(role) {
if (window.ROLE_EMOJI && window.ROLE_EMOJI[role]) return window.ROLE_EMOJI[role];
return '•';
}
function _rolesFmtSec(v) {
if (!v && v !== 0) return '—';
var abs = Math.abs(v);
if (abs < 1) return v.toFixed(2) + 's';
if (abs < 60) return v.toFixed(1) + 's';
if (abs < 3600) return (v / 60).toFixed(1) + 'm';
if (abs < 86400) return (v / 3600).toFixed(1) + 'h';
return (v / 86400).toFixed(1) + 'd';
}
// #1085 — auto-refresh timer for the Roles tab. Started when the Roles
// tab is rendered, cleared on tab switch and destroy.
var _rolesRefreshTimer = null;
function _stopRolesRefresh() {
if (_rolesRefreshTimer) { clearInterval(_rolesRefreshTimer); _rolesRefreshTimer = null; }
}
function esc(s) { return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
// --- Status color helpers (read from CSS variables for theme support) ---
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
@@ -120,10 +98,6 @@
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
<button class="tab-btn" data-tab="rf-health">RF Health</button>
<button class="tab-btn" data-tab="clock-health">Clock Health</button>
<!-- #1085 Roles tab folded in from former /#/roles standalone page.
Placed after Clock Health (clock-skew posture is shown per-role
inside this tab) and before Prefix Tool (utility tabs trail). -->
<button class="tab-btn" data-tab="roles">Roles</button>
<button class="tab-btn" data-tab="prefix-tool">Prefix Tool</button>
</div>
</div>
@@ -159,8 +133,6 @@
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_currentTab = btn.dataset.tab;
// #1085 — Roles tab owns its own 60s auto-refresh; stop it on switch.
if (_currentTab !== 'roles') _stopRolesRefresh();
_updateAnalyticsUrl();
renderTab(_currentTab);
});
@@ -263,7 +235,6 @@
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
case 'rf-health': await renderRFHealthTab(el); break;
case 'clock-health': await renderClockHealthTab(el); break;
case 'roles': await renderRolesTab(el); break;
case 'prefix-tool': await renderPrefixTool(el); break;
}
// Auto-apply column resizing to all analytics tables
@@ -2232,7 +2203,7 @@
}
}
function destroy() { _stopRolesRefresh(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } }
// Expose for testing
if (typeof window !== 'undefined') {
@@ -3775,81 +3746,5 @@ function destroy() { _stopRolesRefresh(); _analyticsData = {}; _channelData = nu
}
}
// #1085 — Roles tab (folded in from former /#/roles page).
// Renders distribution of node roles + per-role clock-skew posture.
// Auto-refreshes every 60s while the Roles tab is active (matches the
// behavior of the former standalone roles-page.js).
async function renderRolesTab(el) {
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading roles…</div>';
await _renderRolesTabBody(el);
// (Re)start the 60s auto-refresh.
_stopRolesRefresh();
_rolesRefreshTimer = setInterval(function () {
// Bail if the user navigated away from the Roles tab.
if (_currentTab !== 'roles') { _stopRolesRefresh(); return; }
var cur = document.getElementById('analyticsContent');
if (!cur) { _stopRolesRefresh(); return; }
_renderRolesTabBody(cur);
}, 60000);
}
async function _renderRolesTabBody(el) {
try {
var data = await api('/analytics/roles', { ttl: CLIENT_TTL.analyticsRF });
var roles = (data && data.roles) || [];
var total = (data && data.totalNodes) || 0;
if (!roles.length) {
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No roles to show.</div>';
return;
}
var maxCount = roles.reduce(function (m, r) { return Math.max(m, r.nodeCount || 0); }, 0) || 1;
var rows = roles.map(function (r) {
var pct = total > 0 ? ((r.nodeCount / total) * 100).toFixed(1) : '0.0';
var barW = Math.round((r.nodeCount / maxCount) * 100);
var sevCells =
'<span title="OK (skew &lt; 5min)" style="color:var(--color-success,#0a0)">' + (r.okCount || 0) + '</span> / ' +
'<span title="Warning (5min 1h)" style="color:var(--color-warning,#e80)">' + (r.warningCount || 0) + '</span> / ' +
'<span title="Critical (1h 30d)" style="color:var(--color-error,#c00)">' + (r.criticalCount || 0) + '</span> / ' +
'<span title="Absurd (&gt; 30d)" style="color:#a0a">' + (r.absurdCount || 0) + '</span> / ' +
'<span title="No clock (&gt; 365d)" style="color:#888">' + (r.noClockCount || 0) + '</span>';
return '' +
'<tr data-role="' + esc(r.role) + '">' +
'<td>' + _rolesEmoji(r.role) + ' <strong>' + esc(r.role) + '</strong></td>' +
'<td style="text-align:right">' + r.nodeCount + '</td>' +
'<td style="text-align:right">' + pct + '%</td>' +
'<td style="min-width:140px">' +
'<div style="background:var(--color-surface-2,#eee);height:10px;border-radius:5px;overflow:hidden">' +
'<div style="background:var(--color-accent,#06c);width:' + barW + '%;height:100%"></div>' +
'</div>' +
'</td>' +
'<td style="text-align:right">' + (r.withSkew || 0) + '</td>' +
'<td style="text-align:right">' + _rolesFmtSec(r.medianAbsSkewSec || 0) + '</td>' +
'<td style="text-align:right">' + _rolesFmtSec(r.meanAbsSkewSec || 0) + '</td>' +
'<td style="white-space:nowrap">' + sevCells + '</td>' +
'</tr>';
}).join('');
el.innerHTML =
'<p class="text-muted" style="margin:0 0 12px 0">Distribution of node roles across the mesh, with per-role clock-skew posture.</p>' +
'<div class="roles-summary" style="margin-bottom:12px;color:var(--color-text-muted,#666)">' +
'<strong>' + total + '</strong> nodes across <strong>' + roles.length + '</strong> roles' +
'</div>' +
'<table id="rolesTable" class="data-table analytics-table" style="width:100%">' +
'<thead><tr>' +
'<th>Role</th>' +
'<th style="text-align:right">Count</th>' +
'<th style="text-align:right">Share</th>' +
'<th>Distribution</th>' +
'<th style="text-align:right" title="Nodes with clock-skew samples">w/ Skew</th>' +
'<th style="text-align:right" title="Median absolute skew">Median |skew|</th>' +
'<th style="text-align:right" title="Mean absolute skew">Mean |skew|</th>' +
'<th title="OK / Warning / Critical / Absurd / No-clock">Severity</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
} catch (err) {
el.innerHTML = '<div class="text-center" style="color:var(--status-red);padding:40px">Failed to load roles: ' + esc(String(err.message || err)) + '</div>';
}
}
registerPage('analytics', { init, destroy });
})();
+18 -367
View File
@@ -473,160 +473,16 @@ function buildHexLegend(ranges) {
let ws = null;
let wsListeners = [];
// --- Brand-logo packet-driven pulse (#1173) ---
// Replaces the legacy live-dot indicator. Class-toggle only (CSS animations); colors come from
// --logo-accent / --logo-accent-hi tokens. Test seam at window.__corescopeLogo.
//
// Cache the prefers-reduced-motion MediaQueryList ONCE at module load (#1177
// Carmack must-fix #2). Calling window.matchMedia on every pulse() allocates
// a new MQL + parses the query string — wasteful at 15Hz. The CSS @media rule
// already handles render-time switching, so we just cache and read .matches.
var _reducedMotionMQL = null;
try {
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
_reducedMotionMQL = window.matchMedia('(prefers-reduced-motion: reduce)');
}
} catch (_) { _reducedMotionMQL = null; }
const Logo = (function () {
const RATE_GAP_MS = 66; // 15/sec (≤16 toggles per second).
const HALF_MS = 80; // each half of a ping ≤80ms.
const stats = { triggered: 0, dropped: 0 };
let lastPingTs = 0;
let flip = 0; // 0 → A→B, 1 → B→A.
let lastDirection = null; // 'a' or 'b' (source circle).
let connected = true; // WS state — gates in-flight chained pulses.
let generation = 0; // bumped on setConnected(false) / visibilitychange to cancel scheduled halves.
function reducedMotion() {
return _reducedMotionMQL ? !!_reducedMotionMQL.matches : false;
}
function $all(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }
function clearAll() {
$all('.brand-logo circle.logo-node-a, .brand-mark-only circle.logo-node-a,' +
'.brand-logo circle.logo-node-b, .brand-mark-only circle.logo-node-b').forEach((el) => {
el.classList.remove('logo-pulse-active', 'logo-pulse-blip');
});
}
function pulseChained(srcSel, dstSel) {
const gen = generation;
// Source half: ~80ms.
$all(srcSel).forEach((el) => el.classList.add('logo-pulse-active'));
setTimeout(() => {
$all(srcSel).forEach((el) => el.classList.remove('logo-pulse-active'));
// Destination half: scheduled via rAF then ~80ms.
// Bail if WS dropped (or another disconnect cycle ran) since this ping started —
// otherwise a zombie pulse fires on a logo that's already showing the
// .logo-disconnected sustained state.
if (gen !== generation || !connected) return;
requestAnimationFrame(() => {
if (gen !== generation || !connected) return;
$all(dstSel).forEach((el) => el.classList.add('logo-pulse-active'));
setTimeout(() => {
$all(dstSel).forEach((el) => el.classList.remove('logo-pulse-active'));
}, HALF_MS);
});
}, HALF_MS);
}
function pulseBlip(dstSel) {
// Reduced-motion: single-step opacity blip on destination only.
$all(dstSel).forEach((el) => el.classList.add('logo-pulse-blip'));
setTimeout(() => {
$all(dstSel).forEach((el) => el.classList.remove('logo-pulse-blip'));
}, 140);
}
function pulse(_msg) {
// Hidden-tab gate (#1177 Carmack must-fix #1): drop the pulse BEFORE
// mutating lastPingTs and BEFORE scheduling any rAF/setTimeout chain.
// Background tabs throttle timers but still ran the source-class toggle
// and queued a chain that fired in a clump on tab focus — wasted work
// and a visible storm. Returning early here makes the gate cost ~1
// property read per WS message.
if (typeof document !== 'undefined' && document.hidden) {
stats.dropped++;
return false;
}
if (!connected) { stats.dropped++; return false; }
const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
if (now - lastPingTs < RATE_GAP_MS) { stats.dropped++; return false; }
lastPingTs = now;
stats.triggered++;
const aToB = (flip === 0);
flip ^= 1;
lastDirection = aToB ? 'a' : 'b';
const srcSel = aToB ? '.brand-logo circle.logo-node-a, .brand-mark-only circle.logo-node-a'
: '.brand-logo circle.logo-node-b, .brand-mark-only circle.logo-node-b';
const dstSel = aToB ? '.brand-logo circle.logo-node-b, .brand-mark-only circle.logo-node-b'
: '.brand-logo circle.logo-node-a, .brand-mark-only circle.logo-node-a';
if (reducedMotion()) {
pulseBlip(dstSel);
} else {
pulseChained(srcSel, dstSel);
}
return true;
}
function setConnected(isConnected) {
connected = !!isConnected;
// Bump generation so any in-flight chained-pulse callbacks bail before
// toggling classes on the destination circle (otherwise a zombie pulse
// briefly fights the .logo-disconnected sustained desaturate state).
generation++;
$all('.brand-logo, .brand-mark-only').forEach((el) => {
if (connected) el.classList.remove('logo-disconnected');
else el.classList.add('logo-disconnected');
});
// #1174 mesh-op review: mirror connected state onto the bottom-nav so
// the 2px top-border indicator (see bottom-nav.css) goes red on
// disconnect. Mesh-alive is otherwise invisible at ≤768 because
// .nav-stats is hidden at that breakpoint.
var bn = document.querySelector('[data-bottom-nav]');
if (bn) {
if (connected) bn.classList.remove('disconnected');
else bn.classList.add('disconnected');
}
if (!connected) clearAll();
}
// Expose hook for E2E + customizer/devtools introspection.
// Frozen so consumers can't replace .pulse / .setConnected from outside
// (the seam is read-only — invocation only).
const api = Object.freeze({
pulse: pulse,
setConnected: setConnected,
get lastDirection() { return lastDirection; },
get stats() { return { triggered: stats.triggered, dropped: stats.dropped }; },
});
try { window.__corescopeLogo = api; } catch (_) {}
// Visibility gate (#1177 Carmack must-fix #1): when the tab becomes
// hidden, bump generation so any in-flight chained pulse halves bail
// out before they paint, and clear any active pulse classes. The
// pulse() entry already early-returns on document.hidden — this handles
// pulses already mid-flight at the moment the tab is backgrounded.
try {
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
generation++;
clearAll();
}
});
}
} catch (_) {}
return api;
})();
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}`);
ws.onopen = () => Logo.setConnected(true);
ws.onopen = () => document.getElementById('liveDot')?.classList.add('connected');
ws.onclose = () => {
Logo.setConnected(false);
document.getElementById('liveDot')?.classList.remove('connected');
setTimeout(connectWS, 3000);
};
ws.onerror = () => ws.close();
ws.onmessage = (e) => {
Logo.pulse(e);
try {
const msg = JSON.parse(e.data);
// Debounce cache invalidation — don't nuke on every packet
@@ -649,7 +505,7 @@ function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); }
// Touch-device pull-down at scrollTop=0 reconnects the WebSocket
// (instead of triggering native pull-to-refresh full-page reload).
// Visual indicator pulses during pull; toast confirms result.
const PULL_THRESHOLD_PX = 140;
const PULL_THRESHOLD_PX = 80;
let _pullToast = null;
let _pullToastTimer = null;
let _pullIndicator = null;
@@ -734,8 +590,7 @@ function setupPullToReconnect() {
function onStart(e) {
if (!_isTouchDevice()) return;
// Strict scrollTop === 0: ignore any negative overscroll, ignore any scrolled state
if (getScrollTop() !== 0) { startY = null; pulling = false; return; }
if (getScrollTop() > 0) { startY = null; pulling = false; return; }
const t = e.touches && e.touches[0];
startY = t ? t.clientY : null;
pulling = false;
@@ -744,25 +599,11 @@ function setupPullToReconnect() {
function onMove(e) {
if (startY == null) return;
// Cancel gesture if scrollTop leaves 0 (page scrolled mid-pull)
if (getScrollTop() !== 0) { startY = null; pulling = false; dist = 0; return; }
if (getScrollTop() > 0) { startY = null; pulling = false; return; }
const t = e.touches && e.touches[0];
if (!t) return;
const dy = t.clientY - startY;
if (dy <= 0) {
// Upward swipe / retract. If we were past the commit threshold and the
// user retracts back, cancel the gesture so a subsequent touchend does
// NOT fire reconnect.
if (pulling) {
pulling = false;
dist = 0;
if (_pullIndicator) {
_pullIndicator.style.opacity = '0';
_pullIndicator.style.transform = 'translate(-50%, -100%)';
}
}
return;
}
if (dy <= 0) return; // upward swipe — ignore
dist = dy;
if (dy > 8) {
pulling = true;
@@ -772,9 +613,8 @@ function setupPullToReconnect() {
ind.style.transform = 'translate(-50%, ' + (-100 + pct * 100) + '%)';
const icon = ind.querySelector && ind.querySelector('.prr-icon');
if (icon) icon.style.transform = 'rotate(' + Math.round(pct * 360) + 'deg)';
// Only block native pull-to-refresh once we've crossed the commit
// threshold — below that, let the browser handle natural scroll/bounce.
if (dy >= PULL_THRESHOLD_PX && typeof e.preventDefault === 'function' && e.cancelable !== false) {
// Prevent native pull-to-refresh ONLY once we've committed to the gesture
if (dy > 16 && typeof e.preventDefault === 'function' && e.cancelable !== false) {
try { e.preventDefault(); } catch (_) {}
}
}
@@ -783,14 +623,12 @@ function setupPullToReconnect() {
function onEnd() {
const wasPulling = pulling;
const finalDist = dist;
const stillAtTop = getScrollTop() === 0;
startY = null; pulling = false; dist = 0;
if (_pullIndicator) {
_pullIndicator.style.opacity = '0';
_pullIndicator.style.transform = 'translate(-50%, -100%)';
}
// Trigger only if: gesture was active, crossed threshold, and page is still at scrollTop=0.
if (wasPulling && finalDist >= PULL_THRESHOLD_PX && stillAtTop) {
if (wasPulling && finalDist >= PULL_THRESHOLD_PX) {
try { (window.pullReconnect || pullReconnect)(); } catch (e) {}
}
}
@@ -883,14 +721,6 @@ function navigate() {
return;
}
// Backward-compat redirect: #/roles → #/analytics?tab=roles (issue #1085).
// The Roles page was folded into the Analytics tab strip; old links and
// bookmarks must keep working.
if (location.hash === '#/roles' || location.hash.startsWith('#/roles?') || location.hash.startsWith('#/roles/')) {
location.hash = '#/analytics?tab=roles';
return;
}
const hash = location.hash.replace('#/', '') || 'packets';
const route = hash.split('?')[0];
@@ -1061,197 +891,18 @@ window.addEventListener('DOMContentLoaded', () => {
link.addEventListener('click', closeNav);
});
// --- "More" dropdown — JS-driven Priority+ (Issue #1102) ---
// --- "More" dropdown (tablet Priority+ nav) ---
const navMoreBtn = document.getElementById('navMoreBtn');
const navMoreMenu = document.getElementById('navMoreMenu');
const navMoreWrap = document.querySelector('.nav-more-wrap');
const navTop = document.querySelector('.top-nav');
const navLeft = document.querySelector('.nav-left');
const navRightEl = document.querySelector('.nav-right');
const linksContainer = document.querySelector('.nav-links');
// Belt-and-braces null guards (#1105 MINOR 4): the outer block measures
// and mutates all of these; if any are missing the layout math throws
// before we can fall back gracefully.
if (navMoreBtn && navMoreMenu && navMoreWrap && navLeft && navRightEl && linksContainer && navTop) {
// Measure available room and decide which links overflow.
// Algorithm: try to fit all links inline. If the link strip doesn't
// fit alongside .nav-right + .nav-brand, hide non-priority links one
// at a time (right-to-left, lowest priority first) until it does.
// Then mirror the hidden links into the "More ▾" menu so nothing
// disappears from the user's reach.
const allLinks = Array.from(linksContainer.querySelectorAll('.nav-link'));
// overflowQueue (#1105 MINOR 6): the order links are removed from the
// inline strip when space runs out. Built right-to-left from
// non-priority links (lowest priority dropped first) and then high-
// priority links as a last-resort tail. `data-priority="high"` is the
// only signal — if you ever need finer ordering, switch to a numeric
// attribute (e.g. data-overflow-order="3") rather than re-shuffling
// index in HTML.
const overflowQueue = allLinks.filter(a => a.dataset.priority !== 'high')
.reverse() // right-to-left
.concat(allLinks.filter(a => a.dataset.priority === 'high').reverse());
function rebuildMoreMenu() {
navMoreMenu.innerHTML = '';
const hidden = allLinks.filter(a => a.classList.contains('is-overflow'));
hidden.forEach(function(link) {
var clone = link.cloneNode(true);
// The clone is in the overflow menu, not the inline strip.
clone.classList.remove('is-overflow');
clone.setAttribute('role', 'menuitem');
// cloneNode(true) preserves DOM but NOT event listeners. The
// originals get `closeNav` attached up above (#1105 MINOR 5);
// mirror that here so a click on the More-menu clone behaves
// identically to a click on the inline link (closes the
// hamburger panel + dismisses the More menu).
clone.addEventListener('click', closeNav);
clone.addEventListener('click', closeMoreMenu);
navMoreMenu.appendChild(clone);
});
// If nothing overflows, hide the More button entirely so wide
// viewports don't show a useless dropdown trigger.
navMoreWrap.classList.toggle('is-hidden', hidden.length === 0);
// Refresh active state on the More button (a hidden active link
// means the More menu currently "is" the active section).
var hasActiveMore = navMoreMenu.querySelector('.nav-link.active');
navMoreBtn.classList.toggle('active', !!hasActiveMore);
}
// #1105 MINOR 1: cached intrinsic width of the More button. Captured
// the first time `fits()` sees navMoreWrap rendered (display:flex).
// Falls back to MORE_BTN_RESERVE_PX (a conservative initial guess
// sized for "More ▾" at default font/padding) until that happens.
var cachedMoreW = 0;
var MORE_BTN_RESERVE_PX = 70;
function applyNavPriority() {
// Skip on mobile (<768px) — hamburger CSS owns that layout.
if (window.innerWidth < 768) {
allLinks.forEach(a => a.classList.remove('is-overflow'));
navMoreWrap.classList.add('is-hidden');
return;
}
// Reset: show everything, then hide as needed.
allLinks.forEach(a => a.classList.remove('is-overflow'));
navMoreWrap.classList.remove('is-hidden');
// #1106: in the 768-1100px narrow-desktop band the CSS already
// hides .nav-stats and tightens .nav-link padding (see the
// "Nav narrow-desktop tightening" media query in style.css).
// The design intent of that band is "show exactly the 5 high-
// priority links + More". Pure measurement says everything fits
// (~981px needed in a 1080px viewport once nav-stats is gone),
// but the design contract — locked by test-nav-priority-1102-
// e2e.js #1105 MINOR 7 — is exact identity, not "fits". Force-
// collapse all non-high-priority links inside this band so the
// overflow menu is non-empty and the high-priority set is the
// only thing inline. Above 1100px the measurement loop below
// owns the decision (and at 2560px nothing overflows).
if (window.innerWidth <= 1100) {
allLinks.forEach(a => {
if (a.dataset.priority !== 'high') a.classList.add('is-overflow');
});
rebuildMoreMenu();
return;
}
// Iteratively hide low-priority links until the link strip fits.
// .top-nav has overflow:hidden and .nav-left has flex-shrink:1, so
// an overflowing strip silently clips rather than pushing
// nav-right out — bounding-rect math on .nav-left lies. Instead
// measure the *intrinsic* widths of the parts (independent of
// current clipping) and compare to the viewport. SAFETY absorbs
// the .top-nav side padding + nav-right inner gaps + sub-pixel
// rounding (the historic #1055 bug was a 620px overlap).
//
// #1105 MINOR 3: at the 1101px media-query flip `.nav-stats`
// toggles from display:none → flex (and vice-versa). The resize
// handler is rAF-debounced and runs *after* the layout flip, so
// navRightEl.scrollWidth measured here reflects the post-flip
// intrinsic width — not stale pre-flip width.
const navBrand = document.querySelector('.nav-brand');
const SAFETY = 32;
// #1105 MINOR 1+2: read both gap values from CSS rather than a
// shared `GUTTER = 24` constant. Today `.nav-left` (gap between
// brand/links/more/right cells) and `.nav-links` (gap between
// individual link items) both resolve to --space-lg = 24px, but
// they're conceptually distinct gaps. If --space-lg or .nav-left's
// gap diverges in the future, the fit math must follow.
const navLeftGap = parseFloat(getComputedStyle(navLeft).columnGap ||
getComputedStyle(navLeft).gap || '0') || 0;
// #1105 MINOR 1: compute the More-button reserve from its actual
// rendered width on first measure, instead of a hard-coded 70px
// fallback. Cached so we don't re-measure (offsetWidth is 0 when
// display:none; we capture the value the first time it's visible).
function fits() {
const visibleLinks = allLinks.filter(a => !a.classList.contains('is-overflow'));
let linkW = 0;
visibleLinks.forEach(a => { linkW += a.getBoundingClientRect().width; });
const linkGapPx = parseFloat(getComputedStyle(linksContainer).columnGap ||
getComputedStyle(linksContainer).gap || '0') || 0;
const linksGap = Math.max(0, visibleLinks.length - 1) * linkGapPx;
const brandW = navBrand ? navBrand.getBoundingClientRect().width : 0;
// Always reserve space for the More button if anything could
// overflow. Measure the live width when visible and cache it
// for use when the button is currently hidden (display:none →
// getBoundingClientRect() returns 0). MORE_BTN_RESERVE_PX is
// the conservative initial fallback used until we get a real
// measurement.
const moreVis = !navMoreWrap.classList.contains('is-hidden');
const liveMoreW = moreVis ? navMoreWrap.getBoundingClientRect().width : 0;
if (liveMoreW > 0) cachedMoreW = liveMoreW;
const moreW = liveMoreW > 0 ? liveMoreW
: (cachedMoreW > 0 ? cachedMoreW : MORE_BTN_RESERVE_PX);
const rightW = navRightEl.scrollWidth; // intrinsic, ignores clipping
const needed = brandW + navLeftGap + linkW + linksGap + navLeftGap + moreW + navLeftGap + rightW + SAFETY;
return needed <= window.innerWidth;
}
let i = 0;
while (!fits() && i < overflowQueue.length) {
overflowQueue[i].classList.add('is-overflow');
i++;
}
// #1139 Bug B: floor the More menu at >=2 items. The greedy
// fits() loop above is happy to stop after pushing exactly ONE
// link into overflow (commonly "🎵 Lab" at ~1600px viewports),
// producing a degenerate single-item dropdown. If exactly one
// link overflowed, promote one more from the queue so the user
// sees a useful menu instead of a one-item fragment. Skip when
// nothing overflowed (everything fits inline → More is hidden,
// which is the correct UX) and skip when the queue is exhausted.
var overflowedCount = allLinks.filter(a => a.classList.contains('is-overflow')).length;
if (overflowedCount === 1) {
if (i < overflowQueue.length) {
overflowQueue[i].classList.add('is-overflow');
i++;
} else {
// Defensive: queue exhausted with exactly 1 overflowed link
// means we cannot satisfy the >=2 floor (only one promotable
// link existed). Surface it loudly instead of silently
// shipping the degenerate single-item dropdown the floor
// was added to prevent.
console.warn('[nav] More menu floor: overflowQueue exhausted with 1 item; cannot enforce >=2 floor');
}
}
rebuildMoreMenu();
}
// Run once on load, again after fonts settle (label widths shift),
// and on resize (debounced via rAF).
applyNavPriority();
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(applyNavPriority);
}
let rafId = 0;
window.addEventListener('resize', function() {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(applyNavPriority);
if (navMoreBtn && navMoreMenu) {
// Build More menu dynamically from non-priority nav links (DRY)
navMoreMenu.innerHTML = '';
document.querySelectorAll('.nav-links a:not([data-priority="high"])').forEach(function(link) {
var clone = link.cloneNode(true);
clone.setAttribute('role', 'menuitem');
clone.addEventListener('click', closeMoreMenu);
navMoreMenu.appendChild(clone);
});
// Re-apply on route change too: the active link gets bigger padding
// (background pill), so which links fit can shift between pages.
window.addEventListener('hashchange', function() {
// Defer so the route handler's class toggles run first.
requestAnimationFrame(applyNavPriority);
});
navMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
const opening = !navMoreMenu.classList.contains('open');
-275
View File
@@ -1,275 +0,0 @@
/* Issue #1061 Bottom navigation styles.
*
* Activates at viewports 768px. Uses position:fixed so it does not
* trigger layout reflow on the rest of the page, plus
* env(safe-area-inset-bottom) padding so the iOS home-indicator does
* not overlap the tabs. The matching <meta viewport-fit=cover> already
* exists in index.html (verified pre-implementation).
*
* Tokens reused (defined in BOTH :root and dark @media in style.css):
* --nav-bg, --nav-text, --nav-text-muted, --nav-active-bg, --accent,
* --border, --space-sm.
*
* Decision: media query (not container query). The rest of the codebase
* uses @media exclusively (no @container rules in style.css today), so
* a media query keeps things consistent.
*
* Decision: top-nav suppression = display:none at 768px. Spec
* forbids duplicate nav UX; the bottom nav covers the 5 high-priority
* routes; long-tail routes (Tools/Lab/Perf/Analytics/etc.) remain
* reachable by URL. A "More" tab or hamburger fallback is deferred per
* the issue body's explicit guidance.
*/
/* #1174 mesh-op review: --bottom-nav-reserve is the contract page-level
* full-viewport rules use to subtract the bottom-nav's height from
* 100dvh. 0px at desktop (no nav reserved); 56px + safe-area at 768px.
* Pages opt-in by referencing it (see public/live.css for /live, and
* #app.app-fixed in style.css for the SPA fixed-page container). */
:root {
--bottom-nav-reserve: 0px;
}
/* Default: hidden on wide viewports. The bottom-nav element exists in
* the DOM at all widths (build runs at DOMContentLoaded) but is only
* rendered to the user at 768px. */
.bottom-nav {
display: none;
}
@media (max-width: 768px) {
/* #1174 mesh-op review: set the reserve token at the breakpoint so
* page-level full-viewport rules (e.g. .live-page, #app.app-fixed)
* automatically subtract the bottom-nav height. */
:root {
--bottom-nav-reserve: calc(56px + env(safe-area-inset-bottom, 0px));
}
.bottom-nav {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1200; /* above nav-links dropdown (1100) */
background: var(--nav-bg);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25);
/* env() falls back to 0 outside iOS notch devices. We also keep
* a small minimum so the rule resolves to a non-empty value. */
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-top: 0;
/* Distribute 5 tabs evenly. */
justify-content: space-around;
align-items: stretch;
/* No transform would create a stacking context that traps any
* fixed-position descendants (we have none, but cheap insurance). */
}
/* Suppress the inline link bar and right-side cluster but KEEP
* .nav-brand (logo identity). #1174: also hide #hamburger at narrow
* widths the new "More" tab in the bottom-nav now surfaces the
* long-tail routes, so the hamburger is redundant on phones. */
.top-nav .nav-links,
.top-nav .nav-more-wrap,
.top-nav .nav-right,
.top-nav .nav-stats {
display: none !important;
}
/* #1174: hamburger hidden at ≤768px (replaced by the More tab). */
#hamburger {
display: none !important;
}
/* Brand on the left, hamburger on the right at narrow widths. */
.top-nav {
justify-content: space-between;
}
/* Reserve space at page bottom so fixed-positioned bottom-nav does
* not cover the last row of content. 56px tab + 8px breathing room
* + safe-area inset. */
body {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
}
/* Tab anchor element. Each tab is a column with icon over label, sized
* to 48px tall (the Apple/Google touch-target floor confirmed by
* issue #1060). */
.bottom-nav-tab {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
/* 56px is a comfortable Material/iOS bottom-bar height; it is also
* 48px (a11y floor) by 8px so labels render without clipping. */
min-height: 56px;
padding: 6px 4px;
color: var(--nav-text-muted);
text-decoration: none;
font-size: 11px;
line-height: 1.1;
border-top: 2px solid transparent;
/* Reset <button> defaults the More tab is a <button>; its native
* background/border/font would otherwise clash with the <a> tabs. */
border-left: 0;
border-right: 0;
border-bottom: 0;
background: transparent;
font-family: inherit;
cursor: pointer;
/* Touch-action: manipulation prevents the iOS double-tap zoom delay
* on tabs. */
touch-action: manipulation;
transition: color 120ms ease, background-color 120ms ease, border-color 120ms ease;
}
.bottom-nav-tab:hover,
.bottom-nav-tab:focus-visible {
color: var(--nav-text);
outline: none;
}
.bottom-nav-tab:focus-visible {
/* Keyboard a11y — visible focus ring inside the bar. */
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.bottom-nav-tab.active {
color: var(--nav-text);
background: var(--nav-active-bg);
border-top-color: var(--accent);
}
.bottom-nav-icon {
font-size: 20px;
line-height: 1;
display: block;
}
.bottom-nav-label {
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
/* Respect reduced-motion preferences disable the color/border
* transition. Existing app already has a reduced-motion block in
* style.css; this is the bottom-nav-specific override. */
@media (prefers-reduced-motion: reduce) {
.bottom-nav-tab {
transition: none;
}
}
/* #1174: More sheet
* Bottom-anchored popover that surfaces the long-tail routes (Nodes,
* Tools, Observers, Analytics, Perf, Audio Lab). Anchored ABOVE the
* bottom-nav (bottom: 56px + safe-area), z-index between the nav and
* any modal layer.
*/
.bottom-nav-sheet {
display: none;
}
@media (max-width: 768px) {
.bottom-nav-sheet {
/* The element uses the `hidden` attribute to be CSS-display none by
* default; when we drop `hidden`, we want it to render as a grid. */
position: fixed;
left: 8px;
right: 8px;
/* Sit above the 56px tabs + breathing room + safe-area inset. */
bottom: calc(56px + env(safe-area-inset-bottom, 0px) + 8px);
z-index: 1250; /* above bottom-nav (1200), below modals if any */
background: var(--nav-bg);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
padding: 8px;
max-height: 60vh;
overflow-y: auto;
/* Display only when not [hidden]. */
}
.bottom-nav-sheet[hidden] {
display: none !important;
}
.bottom-nav-sheet:not([hidden]) {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
}
.bottom-nav-sheet-item {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 10px 12px;
border-radius: 8px;
color: var(--nav-text);
text-decoration: none;
font-size: 14px;
font-weight: 600;
background: transparent;
border: 1px solid transparent;
touch-action: manipulation;
transition: background-color 120ms ease, border-color 120ms ease;
}
.bottom-nav-sheet-item:hover,
.bottom-nav-sheet-item:focus-visible {
background: var(--nav-active-bg);
outline: none;
}
.bottom-nav-sheet-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.bottom-nav-sheet-icon {
font-size: 18px;
line-height: 1;
}
.bottom-nav-sheet-label {
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.bottom-nav-sheet-item {
transition: none;
}
}
/* #1174 mesh-op review: bottom-nav mesh-alive indicator
* .nav-stats (top-nav mesh-alive pulse) is hidden at 768. Add a thin
* 2px top border to the bottom-nav that mirrors the brand-logo's
* connected/disconnected state via a class toggled from app.js
* (window.__corescopeLogo.setConnected). Cheap, peripheral-vision
* visible, no per-tab clutter.
*
* Default (connected): accent-tinted border. Disconnected: red.
* The base bottom-nav rule already declares border-top: 1px solid
* var(--border) we override its color with a slightly heavier
* 2px stripe so the connectivity color is the dominant visual.
*/
@media (max-width: 768px) {
.bottom-nav {
border-top: 2px solid var(--accent);
transition: border-top-color 200ms ease;
}
.bottom-nav.disconnected {
border-top-color: var(--danger, #ef4444);
}
}
@media (prefers-reduced-motion: reduce) {
.bottom-nav {
transition: none;
}
}
-323
View File
@@ -1,323 +0,0 @@
/* Issue #1061 Bottom navigation for narrow viewports.
* Issue #1174 Add 6th "More" tab + bottom-anchored sheet for long-tail routes.
*
* Renders 6 tabs anchored to the bottom on viewports 768px:
* 1. Home primary
* 2. Packets primary
* 3. Live primary
* 4. Map primary
* 5. Channels primary
* 6. More toggles a bottom-anchored sheet listing the long-tail
* routes (Nodes, Tools, Observers, Analytics, Perf, Audio Lab).
* Replaces the hamburger at 768px (#1174 design call).
*
* Tabs are <a href="#/..."> so they reuse the existing hashchange-driven
* router in app.js (no full reload, no reimplementation of routing logic).
* The "More" tab is a <button> (not <a>) since it toggles UI rather than
* navigating to a hash.
*
* Stable selectors for tests / future automation:
* [data-bottom-nav] the <nav> container
* [data-bottom-nav-tab="<route>"] each tab including "more"
* [data-bottom-nav-sheet] the popover sheet
* [data-bottom-nav-more-route="<route>"] each long-tail route in the sheet
*
* Active-tab highlight is a class toggle ("active") set on hashchange.
* Visual treatment lives in bottom-nav.css and respects
* prefers-reduced-motion (transitions disabled).
*
* Sheet behavior:
* - tap More sheet opens, aria-expanded="true"
* - tap More while open sheet closes (toggle, not push)
* - tap any route inside in-app router navigates AND sheet closes
* - tap outside (anywhere not the sheet or the More tab) sheet closes
* - sheet has role="menu" for a11y
*
* The sheet DOM is built lazily on first open it's only used at 768px
* and there's no point sitting in the DOM at desktop widths.
*/
(function () {
'use strict';
if (typeof document === 'undefined') return;
// 5 primary tabs + the More toggle. Each entry: { route, hash, label, icon }.
// For More, hash is null (not a route).
var TABS = [
{ route: 'home', hash: '#/home', label: 'Home', icon: '🏠' },
{ route: 'packets', hash: '#/packets', label: 'Packets', icon: '📦' },
{ route: 'live', hash: '#/live', label: 'Live', icon: '🔴' },
{ route: 'map', hash: '#/map', label: 'Map', icon: '🗺️' },
{ route: 'channels', hash: '#/channels', label: 'Channels', icon: '💬' },
{ route: 'more', hash: null, label: 'More', icon: '☰' },
];
// Long-tail routes surfaced in the More sheet. Mirrors data-route values
// from the existing top-nav (public/index.html). Order matches what
// operators expect from the desktop top-nav.
//
// ⚠️ MANUAL SYNC REQUIRED ⚠️
// This list is intentionally hardcoded (not generated from
// `.top-nav .nav-link[data-route]`) because the top-nav HTML is in
// mid-rewrite and not a reliable single-source-of-truth. If you add a
// new top-nav route (e.g. a future "Lab" page), you MUST also append
// it here, or it will be unreachable on phones at ≤768px (the
// hamburger is hidden at that breakpoint — see bottom-nav.css).
var MORE_ROUTES = [
{ route: 'nodes', hash: '#/nodes', label: 'Nodes', icon: '🖥️' },
{ route: 'tools', hash: '#/tools', label: 'Tools', icon: '🛠️' },
{ route: 'observers', hash: '#/observers', label: 'Observers', icon: '👁️' },
{ route: 'analytics', hash: '#/analytics', label: 'Analytics', icon: '📊' },
{ route: 'perf', hash: '#/perf', label: 'Perf', icon: '⚡' },
{ route: 'audio-lab', hash: '#/audio-lab', label: 'Audio Lab', icon: '🎵' },
];
var SHEET_ID = 'bottomNavMoreSheet';
function currentRoute() {
// Mirror app.js navigate(): strip "#/" and any trailing "?…" / "/…".
var h = (location.hash || '').replace(/^#\//, '');
if (!h) return 'packets'; // app.js default
var slash = h.indexOf('/');
if (slash >= 0) h = h.substring(0, slash);
var q = h.indexOf('?');
if (q >= 0) h = h.substring(0, q);
return h || 'packets';
}
function build() {
if (document.querySelector('[data-bottom-nav]')) return;
var nav = document.createElement('nav');
nav.className = 'bottom-nav';
nav.setAttribute('data-bottom-nav', '');
nav.setAttribute('role', 'navigation');
nav.setAttribute('aria-label', 'Bottom navigation');
TABS.forEach(function (t) {
var el;
if (t.route === 'more') {
// <button> for the toggle: it does not navigate.
el = document.createElement('button');
el.setAttribute('type', 'button');
el.setAttribute('aria-haspopup', 'menu');
el.setAttribute('aria-expanded', 'false');
el.setAttribute('aria-controls', SHEET_ID);
} else {
el = document.createElement('a');
el.setAttribute('href', t.hash);
}
el.className = 'bottom-nav-tab';
el.setAttribute('data-bottom-nav-tab', t.route);
el.setAttribute('data-route', t.route);
el.setAttribute('aria-label', t.label);
var ic = document.createElement('span');
ic.className = 'bottom-nav-icon';
ic.setAttribute('aria-hidden', 'true');
ic.textContent = t.icon;
var lb = document.createElement('span');
lb.className = 'bottom-nav-label';
lb.textContent = t.label;
el.appendChild(ic);
el.appendChild(lb);
nav.appendChild(el);
});
// Insert after <main> so it's a sibling at the body level — keeps
// it out of the <main> scroll container. The CSS pins it bottom:0
// via position:fixed so DOM order beyond "after the nav" doesn't
// matter for layout, but document order matters for screen readers.
var main = document.getElementById('app') || document.querySelector('main');
if (main && main.parentNode) {
main.parentNode.insertBefore(nav, main.nextSibling);
} else {
document.body.appendChild(nav);
}
wireMoreSheet();
}
function syncActive() {
var route = currentRoute();
// #1174 mesh-op review: the More tab represents the long-tail
// routes; reflect that in the active-class so users on /tools,
// /analytics, etc. still see WHICH tab they're under. Without this
// every long-tail route lit up zero tabs.
var moreRouteSet = {};
for (var k = 0; k < MORE_ROUTES.length; k++) moreRouteSet[MORE_ROUTES[k].route] = 1;
var routeIsLongTail = !!moreRouteSet[route];
var tabs = document.querySelectorAll('[data-bottom-nav-tab]');
for (var i = 0; i < tabs.length; i++) {
var t = tabs[i];
var tabRoute = t.getAttribute('data-bottom-nav-tab');
if (tabRoute === 'more') {
// The More tab IS active when the current route belongs to the
// long-tail set surfaced by the More sheet. We do NOT add
// aria-current here — the tab toggles a sheet, not a single
// page, so aria-current="page" would lie. The visual active
// class is the user-facing affordance; that's enough.
if (routeIsLongTail) t.classList.add('active');
else if (!isSheetOpen()) t.classList.remove('active');
// If the sheet is open we leave .active alone — openSheet()
// owns the class while open.
continue;
}
if (tabRoute === route) {
t.classList.add('active');
t.setAttribute('aria-current', 'page');
} else {
t.classList.remove('active');
t.removeAttribute('aria-current');
}
}
}
// ── More sheet ──
// Built lazily on first open; lives as a sibling of the <nav> so the
// bottom-nav's z-index/stacking is independent of the sheet. The sheet
// is anchored above the bottom-nav via CSS (bottom: <nav-height>).
function getOrBuildSheet() {
var existing = document.getElementById(SHEET_ID);
if (existing) return existing;
var sheet = document.createElement('div');
sheet.id = SHEET_ID;
sheet.className = 'bottom-nav-sheet';
sheet.setAttribute('data-bottom-nav-sheet', '');
sheet.setAttribute('role', 'menu');
sheet.setAttribute('aria-label', 'More navigation');
sheet.hidden = true;
MORE_ROUTES.forEach(function (r) {
var a = document.createElement('a');
a.className = 'bottom-nav-sheet-item';
a.setAttribute('href', r.hash);
a.setAttribute('role', 'menuitem');
a.setAttribute('data-bottom-nav-more-route', r.route);
a.setAttribute('data-route', r.route);
var ic = document.createElement('span');
ic.className = 'bottom-nav-sheet-icon';
ic.setAttribute('aria-hidden', 'true');
ic.textContent = r.icon;
var lb = document.createElement('span');
lb.className = 'bottom-nav-sheet-label';
lb.textContent = r.label;
a.appendChild(ic);
a.appendChild(lb);
// Tap a route → close sheet (the <a href> handles navigation via
// the existing hashchange router in app.js).
a.addEventListener('click', function () { closeSheet(); });
sheet.appendChild(a);
});
// Sit the sheet next to the nav so they share a stacking context.
var nav = document.querySelector('[data-bottom-nav]');
if (nav && nav.parentNode) {
nav.parentNode.insertBefore(sheet, nav);
} else {
document.body.appendChild(sheet);
}
return sheet;
}
function isSheetOpen() {
var sheet = document.getElementById(SHEET_ID);
return !!(sheet && !sheet.hidden);
}
function openSheet() {
var sheet = getOrBuildSheet();
sheet.hidden = false;
sheet.classList.add('open');
var moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
if (moreTab) {
moreTab.setAttribute('aria-expanded', 'true');
moreTab.classList.add('active');
}
}
function closeSheet() {
var sheet = document.getElementById(SHEET_ID);
if (sheet) {
sheet.hidden = true;
sheet.classList.remove('open');
}
var moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
if (moreTab) {
moreTab.setAttribute('aria-expanded', 'false');
moreTab.classList.remove('active');
}
}
function toggleSheet() {
if (isSheetOpen()) closeSheet();
else openSheet();
}
function wireMoreSheet() {
var moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
if (!moreTab) return;
// Toggle on tap. Use click — covers mouse and synthesized tap.
moreTab.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
toggleSheet();
});
// Outside-click closes the sheet. Listen at document level; ignore
// clicks on the sheet itself or on the More tab (handled above).
document.addEventListener('click', function (ev) {
if (!isSheetOpen()) return;
var t = ev.target;
var sheet = document.getElementById(SHEET_ID);
if (sheet && sheet.contains(t)) return;
if (moreTab.contains(t)) return;
closeSheet();
});
// Tapping any OTHER bottom-nav tab also closes the sheet.
var otherTabs = document.querySelectorAll('[data-bottom-nav-tab]');
for (var i = 0; i < otherTabs.length; i++) {
var t = otherTabs[i];
if (t.getAttribute('data-bottom-nav-tab') === 'more') continue;
t.addEventListener('click', function () { closeSheet(); });
}
// Esc closes the sheet (a11y).
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape' && isSheetOpen()) closeSheet();
});
// Hashchange (any nav) also closes — covers programmatic navigation.
window.addEventListener('hashchange', function () { closeSheet(); });
}
function init() {
// Singleton guard: init() may be invoked twice if (a) DOMContentLoaded
// fires AND (b) something else re-imports the script later, or if a
// future SPA-like re-mount path is added. The internal `build()` is
// idempotent (early-returns on existing [data-bottom-nav]), but the
// `hashchange` listener and the document-level outside-click /
// keydown listeners in wireMoreSheet() would otherwise stack, leaking
// handlers exactly like PR #1180's MQL-leak class. Bail on second call.
if (window.__bottomNavInitDone) return;
window.__bottomNavInitDone = true;
build();
syncActive();
window.addEventListener('hashchange', syncActive);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+3 -20
View File
@@ -24,10 +24,6 @@
var popoverEl = null;
var currentChannel = null;
// #1168 Munger #3: use shared ref-counted scroll-lock helper instead of
// overwriting body.style.overflow directly. Without this, two cooperating
// surfaces (this picker + SlideOver) corrupt overflow last-writer-wins.
var scrollLockToken = null;
function createPopover() {
if (popoverEl) return popoverEl;
@@ -130,16 +126,8 @@
el.style.top = finalY + 'px';
}
// Lock background scroll while popover is open (#1168 Munger #3:
// ref-counted via window.__scrollLock so concurrent modal surfaces
// don't corrupt overflow under last-writer-wins).
if (window.__scrollLock && scrollLockToken == null) {
scrollLockToken = window.__scrollLock.acquire();
} else if (!window.__scrollLock) {
// Fallback (shouldn't happen — packets.js installs the helper at
// load time and is loaded before this picker).
document.body.style.overflow = 'hidden';
}
// Lock background scroll while popover is open
document.body.style.overflow = 'hidden';
// Focus first swatch for keyboard accessibility
var firstSwatch = el.querySelector('.cc-swatch');
@@ -155,12 +143,7 @@
function hidePopover() {
if (popoverEl) popoverEl.style.display = 'none';
currentChannel = null;
if (window.__scrollLock && scrollLockToken != null) {
window.__scrollLock.release(scrollLockToken);
scrollLockToken = null;
} else if (!window.__scrollLock) {
document.body.style.overflow = '';
}
document.body.style.overflow = '';
document.removeEventListener('click', onOutsideClick, true);
document.removeEventListener('keydown', onEscape, true);
}
+10 -34
View File
@@ -68,18 +68,11 @@
/**
* Render QR + URL + Copy Key button into `target`.
*
* Uses the vendored Kazuhiko Arase qrcode-generator library (lowercase
* `qrcode` global) `public/vendor/qrcode.js`. This was previously
* checking for `root.QRCode` (capital), which never existed and made
* every Generate click fall through to "[QR library not loaded]".
* (Issue #1087 bug 1.)
* Requires window.QRCode (vendor/qrcode.js) loaded.
*/
function generate(name, secretHex, target, opts) {
function generate(name, secretHex, target) {
if (!_hasDom() || !target) return;
target.innerHTML = '';
opts = opts || {};
var qrOnly = !!opts.qrOnly;
const url = buildUrl(name, secretHex);
@@ -88,26 +81,15 @@
qrBox.style.display = 'inline-block';
target.appendChild(qrBox);
var qrFactory = (typeof root.qrcode === 'function') ? root.qrcode :
(typeof root.QRCode === 'function') ? root.QRCode : null;
if (qrFactory) {
if (typeof root.QRCode === 'function') {
try {
// Kazuhiko Arase API: qrcode(typeNumber, errorCorrectionLevel)
// typeNumber=0 → auto-detect smallest version that fits.
var qr = qrFactory(0, 'M');
qr.addData(url);
qr.make();
// createImgTag(cellSize, margin) → an <img src="data:image/gif;base64,...">.
// Cell size 4 with margin 4 yields a ~192px image for short URLs.
qrBox.innerHTML = qr.createImgTag(4, 4);
var img = qrBox.querySelector('img');
if (img) {
img.alt = 'QR for ' + name;
img.style.display = 'block';
img.style.maxWidth = '192px';
img.style.height = 'auto';
}
// davidshimjs/qrcodejs API: new QRCode(el, {text, width, height, ...})
new root.QRCode(qrBox, {
text: url,
width: 192,
height: 192,
correctLevel: root.QRCode.CorrectLevel ? root.QRCode.CorrectLevel.M : 0,
});
} catch (e) {
qrBox.textContent = '[QR render failed: ' + (e && e.message || e) + ']';
}
@@ -115,12 +97,6 @@
qrBox.textContent = '[QR library not loaded]';
}
// #1101: in qrOnly mode (Share modal), the host renders the hex
// key field + Copy button BELOW the QR. Skip the inline URL line
// and inline Copy Key button here so the QR box contains JUST the
// QR image — no overlap, no redundant affordances.
if (qrOnly) return;
const urlLine = document.createElement('div');
urlLine.className = 'channel-qr-url';
urlLine.style.cssText = 'font-family:monospace;font-size:11px;word-break:break-all;margin-top:6px;';
+74 -266
View File
@@ -91,6 +91,7 @@
if (header) header.querySelector('.ch-header-text').textContent = 'Select a channel';
const msgEl = document.getElementById('chMessages');
if (msgEl) msgEl.innerHTML = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
document.querySelector('.ch-layout')?.classList.remove('ch-show-main');
document.getElementById('chScrollBtn')?.classList.add('hidden');
return true;
}
@@ -244,6 +245,14 @@
}
}
function chBack() {
closeNodeDetail();
var layout = document.querySelector('.ch-layout');
if (layout) layout.classList.remove('ch-show-main');
var sidebar = document.querySelector('.ch-sidebar');
if (sidebar) sidebar.style.pointerEvents = '';
}
// WCAG AA compliant colors — ≥4.5:1 contrast on both white and dark backgrounds
// Channel badge colors (white text on colored background)
const CHANNEL_COLORS = [
@@ -330,53 +339,6 @@
}
}
// #1087 Bug 3: single canonical persistence helper. Both the Generate
// path and the PSK Add path route writes through this function so the
// localStorage write happens synchronously inside the submit handler —
// not as a side effect of subsequent UI events.
//
// The previous code spread storeKey() calls across multiple branches,
// and the persistence path could be skipped entirely if the modal was
// closed before mergeUserChannels() ran. Hence the original symptom:
// a freshly-added channel disappeared on refresh, then "reappeared"
// when ANOTHER channel was added (because the second add wrote the
// entire current state, including #1).
//
// Returns true iff the key was successfully stored AND a re-read
// confirms it landed in localStorage. Returns false on quota / other
// storage failure so callers can surface an error.
function persistAddedChannel(channelName, keyHex, label) {
if (!channelName || !keyHex) return false;
try {
ChannelDecrypt.storeKey(channelName, keyHex, label);
} catch (e) {
return false;
}
// Verify the write by re-reading. localStorage can silently drop
// writes under quota pressure, and we want callers to know.
try {
var keys = (typeof ChannelDecrypt.getStoredKeys === 'function')
? ChannelDecrypt.getStoredKeys()
: JSON.parse(localStorage.getItem('corescope_channel_keys') || '{}');
if (!keys || keys[channelName] !== keyHex) return false;
// Polish MINOR-3: also verify the label round-tripped when one was supplied.
// Labels live in a separate storage bucket and could fail independently
// of the key write — caller deserves to know if the friendly name didn't land.
var trimmed = (typeof label === 'string') ? label.trim() : '';
if (trimmed) {
var stored = (typeof ChannelDecrypt.getLabel === 'function')
? ChannelDecrypt.getLabel(channelName)
: ((typeof ChannelDecrypt.getLabels === 'function')
? (ChannelDecrypt.getLabels()[channelName] || '')
: '');
if (stored !== trimmed) return false;
}
return true;
} catch (e) {
return false;
}
}
// Add a user channel by name (#channelname) or hex key.
// `label` (#1020) is an optional friendly name shown in the sidebar instead
// of "psk:<hex8>" — stored alongside the key in localStorage.
@@ -399,12 +361,8 @@
keyHex = ChannelDecrypt.bytesToHex(keyBytes2);
}
// #1020/#1087: persist optional user-supplied label alongside the key
// through the canonical helper (verified read-back).
if (!persistAddedChannel(channelName, keyHex, label)) {
showAddStatus('Failed to save channel — browser storage may be full', 'error');
return;
}
// #1020: persist optional user-supplied label alongside the key
ChannelDecrypt.storeKey(channelName, keyHex, label);
// Compute channel hash byte to find matching encrypted channels
var keyBytes3 = ChannelDecrypt.hexToBytes(keyHex);
@@ -735,38 +693,18 @@
<div class="ch-modal-warn"> Case-sensitive <code>#meshcore</code> <code>#MeshCore</code></div>
</section>
<section id="chShareSection" class="ch-modal-section" hidden aria-labelledby="chShareHeading">
<h4 id="chShareHeading" class="ch-modal-section-title">Share Channel</h4>
<div id="chShareOutput" class="ch-share-output" aria-live="polite"></div>
</section>
<div class="ch-modal-footer">
🔒 Keys stay in your browser CoreScope is a passive observer that monitors and decrypts traffic but cannot transmit over RF. Use to remove individual channels.
</div>
</div>
</div>
<!-- #1087 Bug 4: dedicated Share modal separate from the Add
Channel modal above. Add = INPUT (paste/scan/generate). Share
= OUTPUT (display existing key as QR + URL + copyable text).
Reusing the Add modal for Share confused intent and let the
QR section bleed into the Add submit flow. -->
<div id="chShareModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chShareModalTitle" hidden>
<div class="modal ch-modal ch-share-modal" role="document">
<button type="button" class="modal-close ch-modal-close" id="chShareModalClose" data-action="ch-share-modal-close" aria-label="Close"></button>
<h3 id="chShareModalTitle" class="ch-share-modal-title">Share Channel</h3>
<div class="ch-share-modal-body">
<div id="chShareQr" class="ch-share-qr" aria-live="polite"></div>
<div class="ch-share-field-group">
<label class="ch-share-label" for="chShareKey">Hex Key</label>
<div class="ch-share-row">
<input type="text" id="chShareKey" data-share-field="key" class="ch-modal-input ch-modal-input--mono" readonly aria-label="Channel hex key">
<button type="button" class="ch-modal-btn-secondary" data-share-copy="key" aria-label="Copy hex key">📋 Copy</button>
</div>
</div>
<div class="ch-modal-warn" role="note">
Privacy: only share with trusted people. Anyone with this key can read all messages on this channel.
</div>
</div>
</div>
</div>
<div class="ch-main" role="region" aria-label="Channel messages">
<div class="ch-main-header" id="chHeader">
<button class="ch-back-btn" id="chBackBtn" aria-label="Back to channels" data-action="ch-back"></button>
<span class="ch-header-text">Select a channel</span>
</div>
<div class="ch-messages" id="chMessages">
@@ -806,6 +744,10 @@
modalEl.setAttribute('hidden', '');
var err = document.getElementById('chPskError');
if (err) { err.style.display = 'none'; err.textContent = ''; }
var shareOut = document.getElementById('chShareOutput');
if (shareOut) { shareOut.innerHTML = ''; }
var shareSec = document.getElementById('chShareSection');
if (shareSec) { shareSec.hidden = true; }
}
var addBtn = document.getElementById('chAddChannelBtn');
if (addBtn) addBtn.addEventListener('click', openAddModal);
@@ -825,153 +767,6 @@
});
}
// #1087 Bug 4: dedicated Share modal wiring.
// Polish follow-up: focus trap on open + restore focus on close (a11y).
var shareModalEl = document.getElementById('chShareModal');
var _shareModalTrigger = null;
var _shareModalKeyHandler = null;
// QR capacity bound: qrcode(0,'M') auto-detects smallest version, but
// very long display labels can overflow. URL = scheme(~30) + 32-char
// secret + encoded(name). Cap encoded label budget to keep total URL
// comfortably under the version-10 ECC-M payload (~213 bytes).
var SHARE_LABEL_MAX = 64;
function _truncateForQr(name) {
if (!name) return '';
var s = String(name);
// Encode first, then trim — encoded length is what QR sees.
var enc = encodeURIComponent(s);
if (enc.length <= SHARE_LABEL_MAX) return s;
// Walk back until encoded fits; preserves UTF-8 boundaries via
// encodeURIComponent re-check on each shrink.
while (s.length > 0 && encodeURIComponent(s).length > SHARE_LABEL_MAX) {
s = s.slice(0, -1);
}
return s;
}
function _trapShareModalFocus() {
if (!shareModalEl) return;
var focusable = shareModalEl.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (!focusable.length) return;
var first = focusable[0], last = focusable[focusable.length - 1];
_shareModalKeyHandler = function (e) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
};
shareModalEl.addEventListener('keydown', _shareModalKeyHandler);
}
// Open the share modal in NORMAL (key present) mode. For the
// "key not found" path, callers use openShareModalError() — both
// routes use this same modal so users never see a native alert().
function openShareModal(displayName, channelName, keyHex) {
if (!shareModalEl) return;
_shareModalTrigger = document.activeElement;
var safeName = _truncateForQr(displayName);
var title = document.getElementById('chShareModalTitle');
if (title) title.textContent = 'Share: ' + safeName;
var qrHolder = document.getElementById('chShareQr');
var keyField = document.getElementById('chShareKey');
var fieldsWrap = shareModalEl.querySelectorAll('.ch-share-field-group');
for (var i = 0; i < fieldsWrap.length; i++) fieldsWrap[i].hidden = false;
if (keyField) keyField.value = keyHex;
if (qrHolder) {
qrHolder.innerHTML = '';
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
// #1087 Bug 2: pass the user-facing displayName, NOT the
// internal `psk:<hex8>` channelName lookup key.
// #1101: qrOnly=true — render JUST the QR image. The Share
// modal has its own dedicated hex key field + Copy button
// BELOW the QR; an inline URL line + Copy Key button inside
// the QR box was redundant and visually overlapping.
window.ChannelQR.generate(safeName, keyHex, qrHolder, { qrOnly: true });
}
}
shareModalEl.classList.remove('hidden');
shareModalEl.removeAttribute('hidden');
_trapShareModalFocus();
var closeBtn = document.getElementById('chShareModalClose');
if (closeBtn) try { closeBtn.focus(); } catch (e) { /* noop */ }
}
// Polish: replace native alert() for missing-key share with the
// dedicated modal in error mode (no QR/fields, just the message).
function openShareModalError(displayName, message) {
if (!shareModalEl) return;
_shareModalTrigger = document.activeElement;
var title = document.getElementById('chShareModalTitle');
if (title) title.textContent = 'Share: ' + displayName;
var qrHolder = document.getElementById('chShareQr');
if (qrHolder) {
qrHolder.innerHTML = '';
var msg = document.createElement('div');
msg.className = 'ch-share-error';
msg.setAttribute('role', 'alert');
msg.textContent = message;
qrHolder.appendChild(msg);
}
var fieldsWrap = shareModalEl.querySelectorAll('.ch-share-field-group');
for (var i = 0; i < fieldsWrap.length; i++) fieldsWrap[i].hidden = true;
shareModalEl.classList.remove('hidden');
shareModalEl.removeAttribute('hidden');
_trapShareModalFocus();
var closeBtn = document.getElementById('chShareModalClose');
if (closeBtn) try { closeBtn.focus(); } catch (e) { /* noop */ }
}
function closeShareModal() {
if (!shareModalEl) return;
shareModalEl.classList.add('hidden');
shareModalEl.setAttribute('hidden', '');
if (_shareModalKeyHandler) {
shareModalEl.removeEventListener('keydown', _shareModalKeyHandler);
_shareModalKeyHandler = null;
}
// Restore focus to the trigger that opened the modal (a11y).
if (_shareModalTrigger && typeof _shareModalTrigger.focus === 'function') {
try { _shareModalTrigger.focus(); } catch (e) { /* noop */ }
}
_shareModalTrigger = null;
}
if (shareModalEl) {
shareModalEl.addEventListener('click', function (e) {
var copyBtn = e.target.closest && e.target.closest('[data-share-copy]');
if (copyBtn) {
e.preventDefault();
// #1101: only the hex key is copyable from the share modal;
// the URL field was removed, so the data-share-copy attribute
// is informational only — the source is always #chShareKey.
var src = document.getElementById('chShareKey');
if (src) {
try { src.select(); } catch (e2) {}
var doneCopy = function () {
var orig = copyBtn.textContent;
copyBtn.textContent = '✓ Copied';
setTimeout(function () { copyBtn.textContent = orig; }, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(src.value).then(doneCopy, doneCopy);
} else {
try { document.execCommand('copy'); } catch (e2) {}
doneCopy();
}
}
return;
}
var closeEl = e.target.closest('[data-action="ch-share-modal-close"]');
if (closeEl || e.target === shareModalEl) {
e.preventDefault();
closeShareModal();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !shareModalEl.classList.contains('hidden')) {
closeShareModal();
}
});
}
// Section 1: Generate PSK
var genBtn = document.getElementById('chGenerateBtn');
if (genBtn) genBtn.addEventListener('click', async function () {
@@ -981,19 +776,19 @@
var bytes = crypto.getRandomValues(new Uint8Array(16));
var keyHex = ChannelDecrypt.bytesToHex(bytes);
var channelName = 'psk:' + keyHex.substring(0, 8);
// #1087 Bug 3: persist via canonical helper synchronously.
if (!persistAddedChannel(channelName, keyHex, label)) {
showAddStatus('Failed to save channel — storage full', 'error');
return;
}
ChannelDecrypt.storeKey(channelName, keyHex, label);
var qrOut = document.getElementById('qr-output');
if (qrOut) {
qrOut.innerHTML = '';
// Render QR + URL + Copy Key inline.
// Render the QR + meshcore:// URL + Copy Key inline. The QR
// helper handles canvas rendering + accessible copy controls.
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
// #1087 Bug 2: pass the user label (not psk:<hex8>).
// Use the user-supplied label when provided so the scanned
// recipient sees a meaningful name; fall back to the
// psk:<prefix> auto-name otherwise.
window.ChannelQR.generate(label || channelName, keyHex, qrOut);
} else {
// Fallback when channel-qr.js failed to load.
qrOut.textContent = 'Key generated: ' + keyHex;
}
}
@@ -1066,14 +861,8 @@
loadObserverRegions();
loadChannels().then(async function () {
// Also load user-added encrypted channels into the sidebar.
// mergeUserChannels() mutates `channels` (marks userAdded, appends
// PSK-only entries) AFTER loadChannels() already rendered — so we
// MUST re-render here, otherwise the My Channels section never
// appears on first load when the route has no specific channel
// hash (regression caught by test-channel-issue-1111-e2e.js, case 2).
// Also load user-added encrypted channels into the sidebar
mergeUserChannels();
renderChannelList();
if (routeParam) await selectChannel(routeParam);
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
});
@@ -1098,12 +887,25 @@
});
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// #87: Fix pointer-events during mobile slide transition
var chMain = app.querySelector('.ch-main');
var chSidebar = app.querySelector('.ch-sidebar');
chMain.addEventListener('transitionend', function () {
var layout = app.querySelector('.ch-layout');
if (layout && layout.classList.contains('ch-show-main')) {
chSidebar.style.pointerEvents = 'none';
} else {
chSidebar.style.pointerEvents = '';
}
});
// Event delegation for data-action buttons
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.dataset.action;
if (action === 'ch-close-node') closeNodeDetail();
else if (action === 'ch-back') chBack();
});
// Event delegation for channel selection (touch-friendly)
@@ -1120,39 +922,44 @@
rb.click();
});
chListEl.addEventListener('click', (e) => {
// #1087 Bug 2 + Bug 4: Share/reshare opens a DEDICATED share modal
// (not the Add Channel modal) and resolves the user's display
// label via ChannelDecrypt.getLabel — never the raw `psk:<hex8>`
// lookup key.
// Share/reshare: open the Add Channel modal and render QR + URL
// for the existing key (no re-generation).
const shareBtn = e.target.closest('[data-share-channel]');
if (shareBtn) {
e.stopPropagation();
var shareHash = shareBtn.getAttribute('data-share-channel');
if (!shareHash) return;
var sCh = channels.find(function (c) { return c.hash === shareHash; });
var channelName = shareHash.startsWith('user:')
var sName = shareHash.startsWith('user:')
? shareHash.substring(5)
: (sCh && sCh.name) || shareHash;
var keys = ChannelDecrypt.getStoredKeys();
var keyHex = keys[channelName];
// Resolve display label: explicit user label > channel.userLabel
// > strip the psk: prefix > raw channelName.
var labels = (typeof ChannelDecrypt.getLabels === 'function')
? ChannelDecrypt.getLabels() : {};
var labelFromStore = (typeof ChannelDecrypt.getLabel === 'function')
? ChannelDecrypt.getLabel(channelName)
: (labels[channelName] || '');
var displayName = labelFromStore
|| (sCh && sCh.userLabel)
|| (channelName.indexOf('psk:') === 0
? 'Private Channel'
: channelName);
var keyHex = keys[sName];
if (typeof openAddModal === 'function') openAddModal();
var sec = document.getElementById('chShareSection');
var out = document.getElementById('chShareOutput');
if (!sec || !out) return;
sec.hidden = false;
out.innerHTML = '';
if (!keyHex) {
openShareModalError(displayName, 'No stored key found for "' + displayName + '" — cannot share.');
out.textContent = 'No stored key found for "' + sName + '" — cannot share.';
return;
}
if (typeof openShareModal === 'function') {
openShareModal(displayName, channelName, keyHex);
var heading = document.createElement('div');
heading.className = 'ch-share-heading';
heading.textContent = 'Share "' + sName + '"';
out.appendChild(heading);
var holder = document.createElement('div');
out.appendChild(holder);
if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') {
window.ChannelQR.generate(sName, keyHex, holder);
} else {
// Fallback: copyable hex + meshcore:// URL.
var url = 'meshcore://channel/add?name=' + encodeURIComponent(sName) +
'&secret=' + keyHex;
holder.innerHTML =
'<div>Key: <code>' + escapeHtml(keyHex) + '</code></div>' +
'<div>URL: <code>' + escapeHtml(url) + '</code></div>';
}
return;
}
@@ -1670,14 +1477,12 @@
const collapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false';
const sections = [];
if (mine.length > 0) {
sections.push(
`<div class="ch-section ch-section-mychannels" data-section="mychannels">
sections.push(
`<div class="ch-section ch-section-mychannels" data-section="mychannels">
<div class="ch-section-header">My Channels <span class="ch-section-locality" title="Saved only in this browser on this device">🖥 (this browser)</span></div>
${mine.map(renderChannelRow).join('')}
${mine.length ? mine.map(renderChannelRow).join('') : '<div class="ch-section-empty">No channels yet — click [+ Add Channel] to add one.</div>'}
</div>`
);
}
);
sections.push(
`<div class="ch-section ch-section-network" data-section="network">
<div class="ch-section-header">Network</div>
@@ -1725,6 +1530,9 @@
const header = document.getElementById('chHeader');
header.querySelector('.ch-header-text').textContent = `${name}${ch?.messageCount || 0} messages`;
// On mobile, show the message view
document.querySelector('.ch-layout')?.classList.add('ch-show-main');
const msgEl = document.getElementById('chMessages');
// Shared helper: fetch, decrypt, and render messages for a channel key (M5: cache-first)
+2 -93
View File
@@ -53,52 +53,6 @@
var THEME_COLOR_KEYS = Object.keys(THEME_CSS_MAP).filter(function (k) { return k !== 'font' && k !== 'mono'; });
// ── Brand logo swap helper (PR #1137) ──
// The default navbar brand logo is an inline <svg class="brand-logo"> so it
// inherits page CSS vars (--logo-text / --logo-accent / etc.). When an
// operator overrides branding.logoUrl in the customizer they expect a
// remote image — swap the inline <svg> for an <img>. Going back to the
// default URL or clearing the override swaps the <img> back to the inline
// <svg>. Layout dimensions (width=111 height=36) are preserved either way.
function _setBrandLogoUrl(url, alt) {
var node = document.querySelector('.nav-brand .brand-logo');
if (!node) return;
if (url) {
if (node.tagName.toLowerCase() === 'img') {
node.setAttribute('src', url);
if (alt != null) node.setAttribute('alt', alt);
return;
}
// swap inline <svg> → <img>
var img = document.createElement('img');
img.className = 'brand-logo';
img.setAttribute('src', url);
img.setAttribute('alt', alt || node.getAttribute('aria-label') || 'Brand');
img.setAttribute('width', '125');
img.setAttribute('height', '36');
node.parentNode.replaceChild(img, node);
} else {
if (node.tagName.toLowerCase() !== 'img') {
if (alt != null) node.setAttribute('aria-label', alt);
return;
}
// swap <img> → inline <svg> by clearing the src; here we just keep the
// <img> in place because we don't have the SVG markup at runtime
// (it lives in index.html). The next page reload restores the inline
// SVG. Setting src to the default URL is a graceful intermediate.
node.setAttribute('src', 'img/corescope-logo.svg');
if (alt != null) node.setAttribute('alt', alt);
}
}
function _setBrandAlt(alt) {
var node = document.querySelector('.nav-brand .brand-logo');
if (!node) return;
if (node.tagName.toLowerCase() === 'img') node.setAttribute('alt', alt);
else node.setAttribute('aria-label', alt);
var brandLink = document.querySelector('.nav-brand');
if (brandLink) brandLink.setAttribute('aria-label', alt + ' home');
}
// ── Presets (copied from v1 customize.js) ──
var PRESETS = {
default: {
@@ -514,7 +468,7 @@
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function applyCSS(effectiveConfig, userOverrides) {
function applyCSS(effectiveConfig) {
var dark = isDarkMode();
var themeSection = dark
? Object.assign({}, effectiveConfig.theme || {}, effectiveConfig.themeDark || {})
@@ -529,19 +483,6 @@
}
}
// Logo brand colors mirror --accent / --accent-hover ONLY when an
// operator has actually overridden them via the customizer. We check
// userOverrides (not the merged effective config), so the server-default
// accent (#4a9eff) does NOT clobber the sage/teal :root brand defaults
// out-of-the-box. When an operator picks a theme, customizer writes the
// override to localStorage, the override flows through here, and the
// wordmark recolors to follow the chosen accent.
var ovTheme = (userOverrides && (dark
? Object.assign({}, userOverrides.theme || {}, userOverrides.themeDark || {})
: (userOverrides.theme || {}))) || {};
if (ovTheme.accent) root.setProperty('--logo-accent', ovTheme.accent);
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
// Derived vars
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
@@ -603,12 +544,10 @@
if (br) {
if (br.siteName) {
document.title = br.siteName;
_setBrandAlt(br.siteName);
var brandEl = document.querySelector('.brand-text');
if (brandEl) brandEl.textContent = br.siteName;
}
if (br.logoUrl) {
_setBrandLogoUrl(br.logoUrl, br.siteName || null);
var iconEl = document.querySelector('.brand-icon');
if (iconEl) iconEl.innerHTML = '<img src="' + br.logoUrl + '" style="height:24px" onerror="this.style.display=\'none\'">';
}
@@ -627,7 +566,7 @@
var overrides = readOverrides();
var effective = computeEffective(_serverDefaults || {}, overrides);
window.SITE_CONFIG = effective;
applyCSS(effective, overrides);
applyCSS(effective);
}
// ── setOverride / clearOverride ──
@@ -1202,9 +1141,6 @@
'<option value="km"' + (distUnit === 'km' ? ' selected' : '') + '>Kilometers (km)</option>' +
'<option value="mi"' + (distUnit === 'mi' ? ' selected' : '') + '>Miles (mi)</option>' +
'</select></div>' +
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Gesture Hints</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).</p>' +
'<button type="button" class="cust-dl-btn" data-cv2-reset-hints data-reset-gesture-hints>↺ Reset gesture hints</button>' +
'</div>';
}
@@ -1408,9 +1344,6 @@
// Optimistic CSS update (Decision #12)
var cssVar = THEME_CSS_MAP[key];
if (cssVar) document.documentElement.style.setProperty(cssVar, inp.value);
// Mirror to logo brand vars so the wordmark recolors live too.
if (key === 'accent') document.documentElement.style.setProperty('--logo-accent', inp.value);
if (key === 'accentHover') document.documentElement.style.setProperty('--logo-accent-hi', inp.value);
// Update hex display
var hex = inp.parentElement.querySelector('.cust-hex');
if (hex) hex.textContent = inp.value;
@@ -1427,13 +1360,11 @@
setOverride(section, key, inp.value);
// Live branding updates
if (section === 'branding' && key === 'siteName') {
_setBrandAlt(inp.value);
var el = document.querySelector('.brand-text');
if (el) el.textContent = inp.value;
document.title = inp.value;
}
if (section === 'branding' && key === 'logoUrl') {
_setBrandLogoUrl(inp.value || '', null);
var iconEl = document.querySelector('.brand-icon');
if (iconEl) {
if (inp.value) iconEl.innerHTML = '<img src="' + inp.value + '" style="height:24px" onerror="this.style.display=\'none\'">';
@@ -1612,19 +1543,6 @@
_runPipeline();
_renderPanel(container);
});
// Reset gesture hints (#1065)
var hintsBtn = container.querySelector('[data-cv2-reset-hints]');
if (hintsBtn) hintsBtn.addEventListener('click', function () {
if (window.GestureHints && typeof window.GestureHints.reset === 'function') {
window.GestureHints.reset();
} else {
// Fallback: clear known keys directly.
['row-swipe', 'tab-swipe', 'edge-drawer', 'pull-refresh'].forEach(function (k) {
try { localStorage.removeItem('meshcore-gesture-hints-' + k); } catch (_e) {}
});
}
});
}
// ── Panel toggle ──
@@ -1688,13 +1606,6 @@
for (var key in THEME_CSS_MAP) {
if (themeSection[key]) root.setProperty(THEME_CSS_MAP[key], themeSection[key]);
}
// Mirror accent → logo brand vars ONLY when present in overrides (so the
// server-default accent never clobbers the sage/teal :root brand defaults).
var ovTheme = dark
? Object.assign({}, earlyOverrides.theme || {}, earlyOverrides.themeDark || {})
: (earlyOverrides.theme || {});
if (ovTheme.accent) root.setProperty('--logo-accent', ovTheme.accent);
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
// Apply node/type colors from overrides early
@@ -1721,13 +1632,11 @@
var overrides = readOverrides();
if (overrides.branding) {
if (overrides.branding.siteName) {
_setBrandAlt(overrides.branding.siteName);
var brandEl = document.querySelector('.brand-text');
if (brandEl) brandEl.textContent = overrides.branding.siteName;
document.title = overrides.branding.siteName;
}
if (overrides.branding.logoUrl) {
_setBrandLogoUrl(overrides.branding.logoUrl, overrides.branding.siteName || null);
var iconEl = document.querySelector('.brand-icon');
if (iconEl) iconEl.innerHTML = '<img src="' + overrides.branding.logoUrl + '" style="height:24px" onerror="this.style.display=\'none\'">';
}
-45
View File
@@ -7,36 +7,6 @@
let originalValues = {};
let activeTab = 'branding';
// ── Brand logo swap helpers (PR #1137) ──
// Default brand logo is an inline <svg.brand-logo>; an operator override
// (branding.logoUrl) swaps it for an <img.brand-logo>. Going back to empty
// restores the inline default on next reload (intermediate state shows the
// bundled SVG via <img>). Kept in customize.js for v1 parity.
function _v1SetBrandLogoUrl(url) {
var node = document.querySelector('.nav-brand .brand-logo');
if (!node) return;
if (url) {
if (node.tagName.toLowerCase() === 'img') { node.setAttribute('src', url); return; }
var img = document.createElement('img');
img.className = 'brand-logo';
img.setAttribute('src', url);
img.setAttribute('alt', node.getAttribute('aria-label') || 'Brand');
img.setAttribute('width', '111');
img.setAttribute('height', '36');
node.parentNode.replaceChild(img, node);
} else if (node.tagName.toLowerCase() === 'img') {
node.setAttribute('src', 'img/corescope-logo.svg');
}
}
function _v1SetBrandAlt(alt) {
var node = document.querySelector('.nav-brand .brand-logo');
if (!node) return;
if (node.tagName.toLowerCase() === 'img') node.setAttribute('alt', alt);
else node.setAttribute('aria-label', alt);
var brandLink = document.querySelector('.nav-brand');
if (brandLink) brandLink.setAttribute('aria-label', alt + ' home');
}
const DEFAULTS = {
branding: {
siteName: 'CoreScope',
@@ -543,9 +513,6 @@
for (var key in THEME_CSS_MAP) {
if (t[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], t[key]);
}
// Mirror accent → logo brand vars so the wordmark follows the theme.
if (t.accent) document.documentElement.style.setProperty('--logo-accent', t.accent);
if (t.accentHover) document.documentElement.style.setProperty('--logo-accent-hi', t.accentHover);
// Derived vars that reference other vars — need explicit override
if (t.background) {
document.documentElement.style.setProperty('--content-bg', t.background);
@@ -1039,18 +1006,11 @@
}
// Live DOM updates for branding
if (inp.dataset.key === 'branding.siteName') {
// Post-rebrand (PR #1137): the navbar brand is an inline <svg>;
// mutate aria-label (a11y label on the <svg>/<a>) + document title.
// Legacy .brand-text fallback retained for any operator who shipped
// a custom build that still uses the text node.
_v1SetBrandAlt(inp.value);
var brandEl = document.querySelector('.brand-text');
if (brandEl) brandEl.textContent = inp.value;
document.title = inp.value;
}
if (inp.dataset.key === 'branding.logoUrl') {
// Swap the navbar logo: empty → restore inline default; URL → <img>.
_v1SetBrandLogoUrl(inp.value || '');
var iconEl = document.querySelector('.brand-icon');
if (iconEl) {
if (inp.value) { iconEl.innerHTML = '<img src="' + inp.value + '" style="height:24px" onerror="this.style.display=\'none\'">'; }
@@ -1450,9 +1410,6 @@
for (const [key, val] of Object.entries(themeData)) {
if (THEME_CSS_MAP[key]) document.documentElement.style.setProperty(THEME_CSS_MAP[key], val);
}
// Mirror accent → logo brand vars (matches applyThemePreview()).
if (themeData.accent) document.documentElement.style.setProperty('--logo-accent', themeData.accent);
if (themeData.accentHover) document.documentElement.style.setProperty('--logo-accent-hi', themeData.accentHover);
// Derived vars
if (themeData.background) document.documentElement.style.setProperty('--content-bg', themeData.background);
if (themeData.surface1) document.documentElement.style.setProperty('--card-bg', themeData.surface1);
@@ -1484,13 +1441,11 @@
const userTheme = JSON.parse(saved);
if (userTheme.branding) {
if (userTheme.branding.siteName) {
_v1SetBrandAlt(userTheme.branding.siteName);
const brandEl = document.querySelector('.brand-text');
if (brandEl) brandEl.textContent = userTheme.branding.siteName;
document.title = userTheme.branding.siteName;
}
if (userTheme.branding.logoUrl) {
_v1SetBrandLogoUrl(userTheme.branding.logoUrl);
const iconEl = document.querySelector('.brand-icon');
if (iconEl) iconEl.innerHTML = '<img src="' + userTheme.branding.logoUrl + '" style="height:24px" onerror="this.style.display=\'none\'">';
}
+7 -52
View File
@@ -137,8 +137,7 @@
'time after "2025-01-01"',
].map(function(e) { return '<li class="fux-mono">' + _esc(e) + '</li>'; }).join('');
return [
// NOTE(#1122): "Filter syntax" heading is provided by the popover header;
// do NOT repeat it here or the panel renders the label twice.
'<h3>Filter syntax</h3>',
'<p>Wireshark-style boolean expressions over packet fields. Combine with <code>&amp;&amp;</code>, <code>||</code>, <code>!</code>, and parentheses. Strings are case-insensitive. Tip: append <code>?filter=…</code> to the URL to share a filter.</p>',
'<h4>Fields</h4>',
'<table class="fux-table"><thead><tr><th>Name</th><th>Description</th></tr></thead><tbody>' + rows + '</tbody></table>',
@@ -157,61 +156,17 @@
function _showHelp() {
var existing = document.getElementById('filterHelpPopover');
if (existing) {
// Toggle: also remove the backdrop wrapper if present
var wrap = existing.closest('.modal-overlay');
(wrap || existing).remove();
return;
}
// #1122: Render as a real centered modal inside .modal-overlay so the
// help panel never floats over the packet table rows.
var overlay = _h('div', { class: 'modal-overlay fux-help-overlay', role: 'presentation' });
var pop = _h('div', { id: 'filterHelpPopover', class: 'modal fux-popover', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Filter syntax help' });
if (existing) { existing.remove(); return; }
var pop = _h('div', { id: 'filterHelpPopover', class: 'fux-popover', role: 'dialog', 'aria-label': 'Filter syntax help' });
pop.innerHTML =
'<div class="fux-popover-header"><strong>Filter syntax</strong>' +
'<button type="button" class="fux-popover-close" aria-label="Close">✕</button></div>' +
'<div class="fux-popover-body">' + _buildHelpHtml() + '</div>';
overlay.appendChild(pop);
document.body.appendChild(overlay);
// #1124 (MAJOR-2): focus management. Save the trigger so we can restore
// focus on close, then move focus to the close button. Trap Tab cycles
// inside the modal until it closes.
var trigger = document.activeElement;
var closeBtn = pop.querySelector('.fux-popover-close');
function _focusables() {
return Array.prototype.slice.call(pop.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
));
}
function close() {
overlay.remove();
document.removeEventListener('keydown', onKey);
// Restore focus to the original trigger if still in the DOM.
if (trigger && typeof trigger.focus === 'function' && document.body.contains(trigger)) {
try { trigger.focus(); } catch (e) {}
}
}
function onKey(ev) {
if (ev.key === 'Escape') { close(); return; }
if (ev.key !== 'Tab') return;
var f = _focusables();
if (!f.length) { ev.preventDefault(); return; }
var first = f[0], last = f[f.length - 1];
var active = document.activeElement;
if (ev.shiftKey) {
if (active === first || !pop.contains(active)) { last.focus(); ev.preventDefault(); }
} else {
if (active === last || !pop.contains(active)) { first.focus(); ev.preventDefault(); }
}
}
closeBtn.addEventListener('click', close);
overlay.addEventListener('click', function(ev) {
// Click on backdrop (not inside the modal) closes
if (ev.target === overlay) close();
document.body.appendChild(pop);
pop.querySelector('.fux-popover-close').addEventListener('click', function() { pop.remove(); });
document.addEventListener('keydown', function _esc(ev) {
if (ev.key === 'Escape') { pop.remove(); document.removeEventListener('keydown', _esc); }
});
document.addEventListener('keydown', onKey);
// Move focus to the close button (first interactive element).
try { closeBtn.focus(); } catch (e) {}
}
// ── Autocomplete ───────────────────────────────────────────────────────
Binary file not shown.
-208
View File
@@ -1,208 +0,0 @@
/* gesture-hints.js Issue #1065
* First-visit gesture discoverability hints.
*
* - localStorage namespace: meshcore-gesture-hints-<hint>
* keys: row-swipe, tab-swipe, edge-drawer, pull-refresh
* value: "seen"
* - Show hint 800ms after page settle; auto-fade 8s; "Got it" dismisses.
* - aria-live=polite, role=status, no focus stealing, pointer-events:none.
* - prefers-reduced-motion: animation-name: none (style.css handles via media query).
* - Singleton + cleanup: module-scoped guard; SPA re-mount must not re-show dismissed.
* - Pull-to-refresh hint only when .pull-to-reconnect element exists in DOM.
* - Edge-drawer hint only at viewport > 768px (where edge-swipe drawer applies).
* - Row-swipe hint only on table pages: /#/packets, /#/nodes, etc.
*/
(function () {
'use strict';
if (window.__gestureHints1065Init) {
window.__gestureHints1065Init++;
return;
}
window.__gestureHints1065Init = 1;
var NS = 'meshcore-gesture-hints-';
var HINTS = {
'row-swipe': {
key: NS + 'row-swipe',
text: 'Tip: swipe a row left for quick actions.',
relevant: function () {
var h = location.hash || '';
return /^#\/(packets|nodes|live)/.test(h);
},
position: 'bottom',
},
'tab-swipe': {
key: NS + 'tab-swipe',
text: 'Tip: swipe left or right to switch tabs.',
relevant: function () {
return !!document.querySelector('[data-bottom-nav]');
},
position: 'bottom',
},
'edge-drawer': {
key: NS + 'edge-drawer',
text: 'Tip: swipe in from the left edge to open navigation.',
relevant: function () {
return window.innerWidth > 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]');
},
position: 'top-left',
},
'pull-refresh': {
key: NS + 'pull-refresh',
text: 'Tip: pull down to refresh the connection.',
relevant: function () {
return !!document.querySelector('.pull-to-reconnect');
},
position: 'top',
},
};
var SHOW_DELAY_MS = 800;
var AUTO_FADE_MS = 8000;
var _shown = Object.create(null); // hint id → element (currently rendered)
var _scheduledTimer = null;
var _routeChangeBound = false;
function isSeen(id) {
try { return localStorage.getItem(HINTS[id].key) === 'seen'; }
catch (_e) { return false; }
}
function markSeen(id) {
try { localStorage.setItem(HINTS[id].key, 'seen'); } catch (_e) {}
}
function clearAll() {
try {
Object.keys(HINTS).forEach(function (id) { localStorage.removeItem(HINTS[id].key); });
} catch (_e) {}
}
function buildHintEl(id) {
var def = HINTS[id];
var wrap = document.createElement('div');
wrap.className = 'gesture-hint gesture-hint-' + def.position;
// Belt-and-suspenders: inline style guarantees pointer-events:none
// regardless of CSS load order or cascade collisions. The hint must
// never capture clicks; only the inner button does (via .gesture-hint-inner).
wrap.style.pointerEvents = 'none';
wrap.setAttribute('data-gesture-hint', id);
wrap.setAttribute('role', 'status');
wrap.setAttribute('aria-live', 'polite');
wrap.setAttribute('aria-atomic', 'true');
var inner = document.createElement('div');
inner.className = 'gesture-hint-inner';
var msg = document.createElement('span');
msg.className = 'gesture-hint-text';
msg.textContent = def.text;
inner.appendChild(msg);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'gesture-hint-dismiss';
btn.setAttribute('data-gesture-hint-dismiss', '');
btn.setAttribute('aria-label', 'Dismiss hint');
btn.textContent = 'Got it';
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
dismiss(id);
});
inner.appendChild(btn);
wrap.appendChild(inner);
return wrap;
}
function show(id) {
if (_shown[id]) return;
if (isSeen(id)) return;
var def = HINTS[id];
if (!def || !def.relevant()) return;
var el = buildHintEl(id);
document.body.appendChild(el);
_shown[id] = el;
// Auto-fade after AUTO_FADE_MS — does NOT mark seen; user must explicitly dismiss
// (per AC: "Got it" button clears the flag).
var fadeTimer = setTimeout(function () {
if (_shown[id] === el) {
el.classList.add('gesture-hint-fading');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
if (_shown[id] === el) delete _shown[id];
}, 350);
}
}, AUTO_FADE_MS);
el._gestureHintFadeTimer = fadeTimer;
}
function dismiss(id) {
var el = _shown[id];
markSeen(id);
if (el) {
if (el._gestureHintFadeTimer) clearTimeout(el._gestureHintFadeTimer);
if (el.parentNode) el.parentNode.removeChild(el);
delete _shown[id];
}
}
function scheduleHints() {
if (_scheduledTimer) clearTimeout(_scheduledTimer);
_scheduledTimer = setTimeout(function () {
_scheduledTimer = null;
Object.keys(HINTS).forEach(function (id) {
if (!isSeen(id)) show(id);
});
}, SHOW_DELAY_MS);
}
function onRouteChange() {
// Remove hints that are no longer relevant for the new route.
Object.keys(_shown).slice().forEach(function (id) {
var def = HINTS[id];
if (!def || !def.relevant()) {
var el = _shown[id];
if (el && el._gestureHintFadeTimer) clearTimeout(el._gestureHintFadeTimer);
if (el && el.parentNode) el.parentNode.removeChild(el);
delete _shown[id];
}
});
// Re-evaluate: show any not-yet-seen relevant hints.
scheduleHints();
}
function init() {
if (!_routeChangeBound) {
_routeChangeBound = true;
window.addEventListener('hashchange', onRouteChange);
}
scheduleHints();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
window.GestureHints = {
show: show,
dismiss: dismiss,
reset: function () {
clearAll();
// Remove any visible.
Object.keys(_shown).slice().forEach(function (id) {
var el = _shown[id];
if (el && el._gestureHintFadeTimer) clearTimeout(el._gestureHintFadeTimer);
if (el && el.parentNode) el.parentNode.removeChild(el);
delete _shown[id];
});
},
_keys: function () {
return Object.keys(HINTS).map(function (id) { return HINTS[id].key; });
},
};
})();
-11
View File
@@ -31,17 +31,6 @@
background: var(--surface-1);
border-bottom: 1px solid var(--border);
}
.home-hero-logo {
display: block;
width: 100%;
max-width: min(720px, 90vw);
height: auto;
margin: 0 auto 16px;
/* Inline SVG (PR #1137): inherits page CSS vars (--logo-text /
--logo-accent / --logo-accent-hi / --logo-muted) so it themes with
the rest of the UI on light AND dark themes. No baked background
rect the SVG is transparent and sits on .home-hero's surface. */
}
.home-hero h1 {
font: 700 1.5rem/1.2 var(--font);
color: var(--text);
-13
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.4 KiB

+8 -21
View File
File diff suppressed because one or more lines are too long
+18 -170
View File
@@ -1,21 +1,17 @@
/* ========== LIVE TRACE PAGE ========== */
/* Live page takes full viewport.
* #1174 mesh-op review: subtract --bottom-nav-reserve (defined in
* bottom-nav.css; 0px at desktop, 56px+safe-area at 768) so the
* bottom-nav does not cover VCR controls / Leaflet zoom / live trace
* markers on phones. The 52px term accounts for the top-nav above. */
/* Live page takes full viewport */
.live-page {
position: relative;
width: 100%;
height: calc(100vh - 52px - var(--bottom-nav-reserve, 0px));
height: calc(100dvh - 52px - var(--bottom-nav-reserve, 0px));
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--surface-0);
}
/* Override #app height constraint on live page */
#app:has(.live-page) {
height: calc(100vh - 52px - var(--bottom-nav-reserve, 0px));
height: calc(100dvh - 52px - var(--bottom-nav-reserve, 0px));
height: 100vh;
height: 100dvh;
overflow: visible;
}
@@ -61,74 +57,23 @@
left: 12px;
display: flex;
align-items: center;
gap: 10px;
gap: 14px;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
padding: 4px 10px;
border-radius: 8px;
padding: 8px 16px;
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255,255,255,0.04);
max-height: 40px;
box-sizing: border-box;
}
.live-header-body {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
/* Critical strip (Mesh-Operator review #1180): beacon + pkt count are
always visible even when the collapsible body is hidden at narrow
widths. This is the ingest-state cue (red beacon = WS down) + the
one number operators check while the header is otherwise collapsed. */
.live-header-critical {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* Toggle buttons (#1178, #1179) hidden at wide viewports, visible at 768px.
Mesh-Operator review #1180: tap target 48×48 (#1060 floor + AGENTS glove
operability rule). Visible glyph stays small (decorative); transparent
padding expands the hit area without changing the visual chrome. */
.live-header-toggle,
.live-controls-toggle {
display: none;
align-items: center;
justify-content: center;
min-width: 48px;
min-height: 48px;
/* Visible chrome stays compact; padding grows the hit area. */
width: 48px;
height: 48px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--text);
font-size: 16px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.live-header-toggle:hover,
.live-controls-toggle:hover {
background: color-mix(in srgb, var(--text) 14%, transparent);
}
.live-header-toggle:focus-visible,
.live-controls-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.live-title {
font-size: 12px;
font-size: 14px;
font-weight: 800;
letter-spacing: 1.5px;
letter-spacing: 2px;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
text-transform: uppercase;
}
@@ -155,9 +100,9 @@
.live-stat-pill {
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
padding: 1px 8px;
border-radius: 16px;
font-size: 11px;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
@@ -342,42 +287,11 @@
font-size: 11px;
color: var(--text-muted);
align-items: center;
flex-wrap: wrap;
margin-left: 8px;
}
.live-toggles label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
.live-toggles input { margin: 0; }
/* ---- Live controls cluster (#1179) ----
* Pinned to bottom-right, above the VCR bar and the global bottom-nav.
* Reserves space for both env(safe-area-inset-bottom) and the bottom-nav
* (#1061, currently in PR #1174). When the bottom-nav lands the layout
* tracks its custom property (--bottom-nav-height); otherwise the
* fallback (56px) keeps the cluster clear of the VCR bar / bottom-nav
* region.
*/
.live-controls {
position: fixed;
right: 12px;
bottom: calc(78px + var(--bottom-nav-height, 56px) + env(safe-area-inset-bottom, 0px));
z-index: 1000;
background: color-mix(in srgb, var(--surface-1) 92%, transparent);
backdrop-filter: blur(12px);
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255,255,255,0.04);
max-width: min(620px, calc(100vw - 24px));
display: flex;
align-items: center;
gap: 8px;
}
.live-controls-body {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* Region filter (#1045) inline in live header toggles */
.live-toggles .live-region-filter-container { display: inline-flex; align-items: center; }
.live-toggles .live-region-filter-container .region-dropdown-trigger { font-size: inherit; padding: 2px 6px; }
@@ -393,29 +307,14 @@
background: rgba(59, 130, 246, 0.2) !important;
}
/* ---- Medium breakpoint (#279) + collapse toggles (#1178, #1179) ---- */
/* ---- Medium breakpoint (#279) ---- */
@media (max-width: 768px) {
.live-feed { width: 280px; max-height: 200px; }
.live-node-detail { width: 260px; }
.live-legend { font-size: 10px; padding: 8px 10px; }
.live-header { gap: 6px; padding: 4px 8px; max-height: none; min-height: 48px; }
.live-stat-pill { font-size: 11px; padding: 1px 7px; }
.live-header { gap: 8px; padding: 6px 12px; }
.live-stat-pill { font-size: 11px; padding: 2px 8px; }
.live-toggles { font-size: 10px; gap: 6px; }
/* Show toggle buttons */
.live-header-toggle,
.live-controls-toggle { display: inline-flex; }
/* When collapsed, hide the body */
.live-header.is-collapsed .live-header-body,
.live-controls.is-collapsed .live-controls-body { display: none; }
.live-header.is-collapsed { gap: 0; padding: 4px 6px; }
.live-controls.is-collapsed { padding: 6px; }
/* Expanded body on narrow: stack so it never overflows the cluster */
.live-controls.is-expanded { max-width: calc(100vw - 24px); }
.live-controls.is-expanded .live-controls-body { flex-wrap: wrap; }
.live-controls.is-expanded .live-toggles { flex-wrap: wrap; max-height: 50vh; overflow-y: auto; }
}
/* ---- Responsive ---- */
@@ -636,57 +535,6 @@
}
.vcr-btn:hover { background: color-mix(in srgb, var(--text) 18%, transparent); }
/* #1110 Live page node filter — match toolbar control sizing & theme */
.live-node-filter-wrap { position: relative; display: inline-flex; align-items: center; }
.live-node-filter-input {
background: color-mix(in srgb, var(--text) 6%, transparent);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 8px;
font-size: inherit;
line-height: 1.3;
height: auto;
min-width: 140px;
outline: none;
}
.live-node-filter-input:focus {
border-color: color-mix(in srgb, var(--text) 35%, transparent);
background: color-mix(in srgb, var(--text) 10%, transparent);
}
.live-node-filter-input::placeholder { color: var(--text-muted); opacity: 0.7; }
.live-node-filter-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 2px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 6px;
max-height: 240px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
min-width: 200px;
}
.live-node-filter-dropdown.hidden { display: none; }
.live-node-filter-option {
padding: 6px 10px;
cursor: pointer;
font-size: 0.85rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.1s;
}
.live-node-filter-option:hover { background: color-mix(in srgb, var(--text) 12%, transparent); }
.live-node-filter-option.live-node-filter-active {
background: var(--accent, color-mix(in srgb, var(--text) 25%, transparent));
color: var(--text);
}
.vcr-live-btn {
background: rgba(239, 68, 68, 0.2);
color: var(--status-red);
+36 -276
View File
@@ -52,28 +52,6 @@
return false;
}
function setObserverIataMap(m) { observerIataMap = m || {}; }
/**
* Build observer_id IATA map from the /api/observers response.
* The endpoint returns `{ observers: [...], server_time: "..." }`
* (cmd/server/types.go ObserverListResponse). Defensive: also accepts
* a bare array in case the API shape ever changes back, and ignores
* observers without an IATA. Returns a plain object (used as a hash).
* Exported for tests via window._liveBuildObserverIataMap.
* Fixes #1136 (regression introduced in #1080 which assumed array shape).
*/
function buildObserverIataMap(data) {
var list = null;
if (Array.isArray(data)) list = data;
else if (data && Array.isArray(data.observers)) list = data.observers;
var m = {};
if (!list) return m;
for (var i = 0; i < list.length; i++) {
var o = list[i];
if (o && o.id != null && o.iata) m[o.id] = o.iata;
}
return m;
}
let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null;
const propagationBuffer = new Map(); // hash -> {timer, packets[]}
let _onResize = null;
@@ -860,27 +838,17 @@
<div class="live-page">
<div id="liveMap" style="width:100%;height:100%;position:absolute;top:0;left:0;z-index:1"></div>
<div class="live-overlay live-header" id="liveHeader">
<div class="live-header-critical" data-live-header-critical>
<span class="live-beacon" aria-label="WebSocket connection beacon"></span>
<div class="live-stat-pill live-stat-pill--critical"><span id="livePktCount">0</span> pkts</div>
<div class="live-title">
<span class="live-beacon"></span>
MESH LIVE
</div>
<button class="live-header-toggle" data-live-header-toggle id="liveHeaderToggle"
aria-expanded="false" aria-controls="liveHeaderBody"
aria-label="Show live stats">📊</button>
<div class="live-header-body" data-live-header-body id="liveHeaderBody">
<div class="live-title">
MESH LIVE
</div>
<div class="live-stats-row">
<div class="live-stat-pill"><span id="liveNodeCount">0</span> nodes</div>
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
</div>
<div class="live-stats-row">
<div class="live-stat-pill"><span id="livePktCount">0</span> pkts</div>
<div class="live-stat-pill"><span id="liveNodeCount">0</span> nodes</div>
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
</div>
</div>
<div class="live-overlay live-controls" id="liveControls">
<div class="live-controls-body" data-live-controls-body id="liveControlsBody">
<div class="live-toggles">
<div class="live-toggles">
<label><input type="checkbox" id="liveHeatToggle" checked aria-describedby="heatDesc"> Heat</label>
<span id="heatDesc" class="sr-only">Overlay a density heat map on the mesh nodes</span>
<label><input type="checkbox" id="liveGhostToggle" checked aria-describedby="ghostDesc"> Ghosts</label>
@@ -897,24 +865,20 @@
<span id="audioDesc" class="sr-only">Sonify packets turn raw bytes into generative music</span>
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> Favorites</label>
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
<div class="live-node-filter-wrap" style="position:relative">
<input type="text" id="liveNodeFilterInput" placeholder="Filter by node…" autocomplete="off" class="live-node-filter-input" role="combobox" aria-expanded="false" aria-owns="liveNodeFilterDropdown" aria-autocomplete="list" aria-activedescendant="">
<div id="liveNodeFilterDropdown" class="live-node-filter-dropdown hidden" role="listbox"></div>
<div class="live-node-filter-wrap">
<input type="text" id="liveNodeFilterInput" list="liveNodeFilterList" placeholder="Filter by node…" autocomplete="off" class="live-node-filter-input">
<datalist id="liveNodeFilterList"></datalist>
<button id="liveNodeFilterClear" class="vcr-btn" title="Clear node filter" style="display:none">×</button>
</div>
<div id="liveNodeFilterCount" class="live-filter-count hidden"></div>
<label id="liveGeoFilterLabel" style="display:none"><input type="checkbox" id="liveGeoFilterToggle"> Mesh live area</label>
<div id="liveRegionFilter" class="region-filter-container live-region-filter-container" aria-label="Filter live packets by IATA region"></div>
</div>
<div class="audio-controls hidden" id="audioControls">
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
<label class="audio-slider-label">BPM <input type="range" id="audioBpmSlider" min="40" max="300" value="120" class="audio-slider"><span id="audioBpmVal">120</span></label>
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
</div>
</div>
<button class="live-controls-toggle" data-live-controls-toggle id="liveControlsToggle"
aria-expanded="false" aria-controls="liveControlsBody"
aria-label="Show live controls"></button>
<div class="audio-controls hidden" id="audioControls">
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
<label class="audio-slider-label">BPM <input type="range" id="audioBpmSlider" min="40" max="300" value="120" class="audio-slider"><span id="audioBpmVal">120</span></label>
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
</div>
</div>
<div class="live-overlay live-feed" id="liveFeed">
<div class="panel-header">
@@ -1078,169 +1042,47 @@
(function initLiveRegionFilter() {
var rfEl = document.getElementById('liveRegionFilter');
if (!rfEl || !window.RegionFilter) return;
// Fetch observer roster to build observer_id → IATA map.
// /api/observers returns `{observers:[...], server_time:"..."}`
// (cmd/server/types.go ObserverListResponse) — NOT a top-level array.
// Bug #1136: previously parsed as array → map empty → region filter
// dropped every packet.
fetch('/api/observers').then(function(r) { return r.json(); }).then(function(data) {
setObserverIataMap(buildObserverIataMap(data));
// Fetch observer roster to build observer_id → IATA map
fetch('/api/observers').then(function(r) { return r.json(); }).then(function(list) {
var m = {};
if (Array.isArray(list)) {
for (var i = 0; i < list.length; i++) {
var o = list[i];
if (o && o.id != null && o.iata) m[o.id] = o.iata;
}
}
setObserverIataMap(m);
}).catch(function() { /* leave map empty; filter will hide all when active */ });
RegionFilter.init(rfEl, { dropdown: true });
regionFilterChangeHandler = RegionFilter.onChange(function() { /* selection persisted by RegionFilter; future packets reflect it */ });
})();
// Node filter input — autocomplete-as-you-type (#1110)
// Node filter input
const nodeFilterInput = document.getElementById('liveNodeFilterInput');
const nodeFilterClear = document.getElementById('liveNodeFilterClear');
const nodeFilterDropdown = document.getElementById('liveNodeFilterDropdown');
if (nodeFilterInput) {
// Restore from URL param or localStorage
const urlNode = getHashParams && getHashParams().get('node');
if (urlNode) setNodeFilter(urlNode.split(',').map(s => s.trim()).filter(Boolean));
else if (nodeFilterKeys.length) updateNodeFilterUI();
let activeIdx = -1;
function hideDropdown() {
if (!nodeFilterDropdown) return;
nodeFilterDropdown.classList.add('hidden');
nodeFilterDropdown.innerHTML = '';
nodeFilterInput.setAttribute('aria-expanded', 'false');
nodeFilterInput.setAttribute('aria-activedescendant', '');
activeIdx = -1;
}
function applyFilterFromInput(rawValue) {
// Treat input as a single substring query rather than a list of pubkeys.
// setNodeFilter accepts pubkeys/prefixes/names; commit raw for live filtering.
const val = (rawValue || '').trim();
setNodeFilter(val ? [val] : []);
// Update URL without triggering hashchange (which would re-init the page).
nodeFilterInput.addEventListener('change', (e) => {
const val = e.target.value.trim();
setNodeFilter(val ? val.split(',').map(s => s.trim()).filter(Boolean) : []);
const params = getHashParams ? getHashParams() : new URLSearchParams();
if (val) params.set('node', val);
if (nodeFilterKeys.length) params.set('node', nodeFilterKeys.join(','));
else params.delete('node');
const base = location.hash.split('?')[0] || '#/live';
const base = location.hash.split('?')[0];
const qs = params.toString();
const newHash = base + (qs ? '?' + qs : '');
const newUrl = location.pathname + location.search + newHash;
try { history.replaceState(null, '', newUrl); } catch (_) {}
}
function selectSuggestion(opt) {
const key = opt.getAttribute('data-key') || '';
const name = opt.getAttribute('data-name') || key;
nodeFilterInput.value = name;
// Filter by pubkey prefix when available — most precise.
setNodeFilter(key ? [key] : (name ? [name] : []));
const params = getHashParams ? getHashParams() : new URLSearchParams();
if (key) params.set('node', key);
else params.delete('node');
const base = location.hash.split('?')[0] || '#/live';
const qs = params.toString();
const newUrl = location.pathname + location.search + base + (qs ? '?' + qs : '');
try { history.replaceState(null, '', newUrl); } catch (_) {}
hideDropdown();
}
const escapeHtmlLocal = (typeof escapeHtml === 'function') ? escapeHtml : function (s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
};
async function fetchSuggestions(q) {
if (!nodeFilterDropdown) return;
if (!q || q.length < 1) { hideDropdown(); return; }
try {
const resp = await fetch('/api/nodes/search?q=' + encodeURIComponent(q));
if (!resp.ok) { hideDropdown(); return; }
const data = await resp.json();
const nodes = (data && data.nodes) || [];
if (!nodes.length) { hideDropdown(); return; }
nodeFilterDropdown.innerHTML = nodes.map(function (n, i) {
const name = n.name || (n.public_key ? n.public_key.slice(0, 8) : '?');
const pkShort = n.public_key ? n.public_key.slice(0, 8) : '';
return '<div class="live-node-filter-option" id="liveNodeFilterOpt-' + i +
'" role="option" data-key="' + escapeHtmlLocal(n.public_key || '') +
'" data-name="' + escapeHtmlLocal(name) + '">' +
escapeHtmlLocal(name) +
' <span style="color:var(--text-muted);font-size:0.8em">' + escapeHtmlLocal(pkShort) + '</span></div>';
}).join('');
nodeFilterDropdown.classList.remove('hidden');
nodeFilterInput.setAttribute('aria-expanded', 'true');
nodeFilterDropdown.querySelectorAll('.live-node-filter-option').forEach(function (opt) {
opt.addEventListener('mousedown', function (ev) {
// Use mousedown so we run before blur hides the dropdown.
ev.preventDefault();
selectSuggestion(opt);
});
});
} catch (_) { hideDropdown(); }
}
const debouncedInput = debounce(function (e) {
const v = e.target.value.trim();
// Apply live filter immediately as user types (no Enter required).
applyFilterFromInput(v);
fetchSuggestions(v);
}, 200);
nodeFilterInput.addEventListener('input', debouncedInput);
nodeFilterInput.addEventListener('keydown', function (e) {
const opts = nodeFilterDropdown ? nodeFilterDropdown.querySelectorAll('.live-node-filter-option') : [];
if (e.key === 'Enter') {
// Critical: prevent any default form submission / navigation behavior.
e.preventDefault();
if (opts.length && activeIdx >= 0 && opts[activeIdx]) {
selectSuggestion(opts[activeIdx]);
} else {
// Just commit current text as a filter and close the dropdown.
applyFilterFromInput(nodeFilterInput.value);
hideDropdown();
}
return;
}
if (!opts.length || (nodeFilterDropdown && nodeFilterDropdown.classList.contains('hidden'))) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIdx = Math.min(activeIdx + 1, opts.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIdx = Math.max(activeIdx - 1, 0);
} else if (e.key === 'Escape') {
hideDropdown();
return;
} else {
return;
}
opts.forEach(function (o, i) {
o.classList.toggle('live-node-filter-active', i === activeIdx);
o.setAttribute('aria-selected', i === activeIdx ? 'true' : 'false');
});
if (activeIdx >= 0 && opts[activeIdx]) {
nodeFilterInput.setAttribute('aria-activedescendant', opts[activeIdx].id);
opts[activeIdx].scrollIntoView({ block: 'nearest' });
}
});
nodeFilterInput.addEventListener('blur', function () {
// Slight delay so click on a suggestion can register first.
setTimeout(hideDropdown, 150);
location.hash = base + (qs ? '?' + qs : '');
});
}
if (nodeFilterClear) {
nodeFilterClear.addEventListener('click', () => {
if (nodeFilterInput) nodeFilterInput.value = '';
setNodeFilter([]);
// Drop the ?node param without re-running the SPA route handler.
const params = getHashParams ? getHashParams() : new URLSearchParams();
params.delete('node');
const base = location.hash.split('?')[0] || '#/live';
const qs = params.toString();
const newUrl = location.pathname + location.search + base + (qs ? '?' + qs : '');
try { history.replaceState(null, '', newUrl); } catch (_) {}
const base = location.hash.split('?')[0];
location.hash = base;
});
}
@@ -1396,78 +1238,6 @@
// Legend toggle for mobile (#60)
const legendEl = document.getElementById('liveLegend');
const legendToggleBtn = document.getElementById('legendToggleBtn');
// ── Live header / controls toggles (#1178, #1179) ──────────────────────
// At narrow viewports (≤768px) the header collapses to a single
// toggle button revealing the stats body, and the controls collapse
// to a single toggle button revealing the toggles list. CSS gates
// visibility of the toggle buttons; JS only flips classes and the
// hidden attribute. At wide viewports the bodies are always shown.
(function wireLiveCollapseToggles() {
var pairs = [
{ rootId: 'liveHeader', togId: 'liveHeaderToggle', bodyId: 'liveHeaderBody',
showLabel: 'Show live stats', hideLabel: 'Hide live stats' },
{ rootId: 'liveControls', togId: 'liveControlsToggle', bodyId: 'liveControlsBody',
showLabel: 'Show live controls', hideLabel: 'Hide live controls' },
];
var narrowMql = window.matchMedia('(max-width: 768px)');
function setExpanded(p, expanded) {
var root = document.getElementById(p.rootId);
var tog = document.getElementById(p.togId);
var body = document.getElementById(p.bodyId);
if (!root || !tog || !body) return;
if (expanded) {
root.classList.add('is-expanded'); root.classList.remove('is-collapsed');
body.removeAttribute('hidden');
tog.setAttribute('aria-expanded', 'true');
tog.setAttribute('aria-label', p.hideLabel);
} else {
root.classList.add('is-collapsed'); root.classList.remove('is-expanded');
body.setAttribute('hidden', '');
tog.setAttribute('aria-expanded', 'false');
tog.setAttribute('aria-label', p.showLabel);
}
}
function applyForViewport() {
for (var i = 0; i < pairs.length; i++) {
var p = pairs[i];
if (narrowMql.matches) {
// Default collapsed at narrow viewports
setExpanded(p, false);
} else {
// Always expanded; no hidden attr; no collapse class
var root = document.getElementById(p.rootId);
var body = document.getElementById(p.bodyId);
var tog = document.getElementById(p.togId);
if (body) body.removeAttribute('hidden');
if (root) { root.classList.remove('is-collapsed'); root.classList.remove('is-expanded'); }
if (tog) { tog.setAttribute('aria-expanded', 'true'); }
}
}
}
pairs.forEach(function (p) {
var tog = document.getElementById(p.togId);
if (!tog) return;
tog.addEventListener('click', function () {
var root = document.getElementById(p.rootId);
var nowExpanded = !(root && root.classList.contains('is-expanded'));
setExpanded(p, nowExpanded);
});
});
applyForViewport();
// #1180 — bind once across SPA re-mounts. MQL is process-global per
// query string; per-init binds accumulate handlers without bound.
if (!_liveNarrowMqlBound) {
if (narrowMql.addEventListener) narrowMql.addEventListener('change', applyForViewport);
else if (narrowMql.addListener) narrowMql.addListener(applyForViewport);
_liveNarrowMqlBound = true;
try {
window.__liveMQLBindCount = (window.__liveMQLBindCount || 0) + 1;
} catch (_) { /* sealed window */ }
}
})();
// ───────────────────────────────────────────────────────────────────────
if (legendToggleBtn && legendEl) {
// Restore legend collapsed state from localStorage (#279)
try {
@@ -2232,8 +2002,6 @@
window._liveGetNodeFilterKeys = function() { return nodeFilterKeys; };
window._livePacketMatchesRegion = packetMatchesRegion;
window._liveSetObserverIataMap = setObserverIataMap;
window._liveBuildObserverIataMap = buildObserverIataMap;
window._liveGetObserverIataMap = function() { return observerIataMap; };
window._liveSetNodeFilter = setNodeFilter;
window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml;
window._liveResolveHopPositions = resolveHopPositions;
@@ -3364,14 +3132,6 @@
let _themeRefreshHandler = null;
// #1180 — singleton guard for the wireLiveCollapseToggles() narrow-viewport
// MQL listener. MediaQueryList is process-global per query string; without
// this gate, every SPA re-mount of /live registers a new 'change' handler.
// The handler reads from current DOM each time, so a one-shot bind is safe
// across re-mounts. window.__liveMQLBindCount is a debug seam consumed by
// test-live-mql-leak-1180-e2e.js and otherwise unused.
var _liveNarrowMqlBound = false;
registerPage('live', {
init: function(app, routeParam) {
_themeRefreshHandler = () => {
-366
View File
@@ -1,366 +0,0 @@
/* nav-drawer.js Issue #1064 (parent epic #1052)
*
* Edge-swipe nav drawer. Slide-over from the LEFT edge.
*
* Design (Option A): drawer is enabled at viewport widths > 768px ONLY.
* At 768px the bottom-nav has a "More" tab (PR #1174) that surfaces the
* same long-tail routes; a left-edge drawer there would compete with it.
*
* Inputs (Pointer Events only touch + pen, never mouse):
* - pointerdown within the left edge trigger zone [24px, 44px]
* (first 24px reserved for iOS Safari back-swipe Mesh-Op #1184)
* - pointermove drawer translateX follows finger
* - pointerup settle open/closed via velocity
* + position threshold
*
* Singleton + cleanup (mirrors #1180 fix):
* - module-scoped `wired` guard so SPA mounts don't re-bind
* - document-level pointermove/pointerup listeners registered ONCE
* - matchMedia listener registered ONCE
* - `window.__navDrawerPointerBindCount` debug seam (E2E asserts 1)
*
* Accessibility:
* - drawer has `inert` when closed (removed when open) keyboard +
* screen-reader users skip the off-screen tree.
* - focus trap: Tab from last focusable wraps to first; Shift+Tab from
* first wraps to last.
* - Esc closes; backdrop tap closes; tap on a route closes.
* - prefers-reduced-motion: instant snap, no transition.
*
* Public API (also surfaced as `window.__navDrawer` for tests):
* open(), close(), toggle(), isOpen()
*/
'use strict';
(function () {
if (typeof document === 'undefined') return;
// ── Module-scoped singleton state ───────────────────────────────────────
var wired = false;
var drawerEl = null;
var backdropEl = null;
var dragging = false;
var startX = 0;
var startY = 0;
var startT = 0;
var lastX = 0;
var lastT = 0;
var drawerWidth = 0;
var pointerActive = false;
var narrowMql = null;
// Element that had focus before the drawer was opened — restored on close
// (same regression class as #1168: closing nav UI must return focus to its
// trigger so keyboard users don't get dumped at <body>).
var prevFocus = null;
// Long-tail routes mirror PR #1174 / bottom-nav.js MORE_ROUTES exactly.
// ⚠️ Keep in sync with public/bottom-nav.js MORE_ROUTES.
var ROUTES = [
{ route: 'nodes', hash: '#/nodes', label: 'Nodes', icon: '🖥️' },
{ route: 'tools', hash: '#/tools', label: 'Tools', icon: '🛠️' },
{ route: 'observers', hash: '#/observers', label: 'Observers', icon: '👁️' },
{ route: 'analytics', hash: '#/analytics', label: 'Analytics', icon: '📊' },
{ route: 'perf', hash: '#/perf', label: 'Perf', icon: '⚡' },
{ route: 'audio-lab', hash: '#/audio-lab', label: 'Audio Lab', icon: '🎵' },
];
var EDGE_PX = 44; // pointerdown must start within left N px (drawer trigger zone)
var EDGE_MIN_PX = 24; // first N px reserved for iOS Safari back-swipe (do not claim)
var NARROW_MAX = 768; // Option A: disabled at ≤ this width
var OPEN_THRESHOLD = 0.5; // % of drawer width at which open settles
var VELOCITY_OPEN = 0.4; // px/ms — fling-right opens regardless of position
var VELOCITY_CLOSE = -0.4; // px/ms — fling-left closes
function isWide() {
// matchMedia is the source of truth; fall back to innerWidth in non-DOM
// environments (won't trigger in browser).
if (narrowMql && typeof narrowMql.matches === 'boolean') return !narrowMql.matches;
return (window.innerWidth || 0) > NARROW_MAX;
}
function prefersReducedMotion() {
try {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
} catch (_e) { return false; }
}
// ── DOM construction (idempotent) ───────────────────────────────────────
function buildDom() {
if (drawerEl && backdropEl) return;
backdropEl = document.createElement('div');
backdropEl.className = 'nav-drawer-backdrop';
backdropEl.setAttribute('data-nav-drawer-backdrop', '');
backdropEl.hidden = true;
backdropEl.addEventListener('click', function () { close(); });
drawerEl = document.createElement('aside');
drawerEl.className = 'nav-drawer';
drawerEl.setAttribute('data-nav-drawer', '');
drawerEl.setAttribute('role', 'navigation');
drawerEl.setAttribute('aria-label', 'Edge-swipe navigation drawer');
drawerEl.setAttribute('aria-hidden', 'true');
drawerEl.setAttribute('inert', '');
drawerEl.tabIndex = -1;
var header = document.createElement('div');
header.className = 'nav-drawer-header';
var title = document.createElement('span');
title.className = 'nav-drawer-title';
title.textContent = 'Navigate';
var closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'nav-drawer-close';
closeBtn.setAttribute('aria-label', 'Close navigation drawer');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', function () { close(); });
header.appendChild(title);
header.appendChild(closeBtn);
drawerEl.appendChild(header);
var list = document.createElement('nav');
list.className = 'nav-drawer-list';
ROUTES.forEach(function (r) {
var a = document.createElement('a');
a.className = 'nav-drawer-item';
a.setAttribute('href', r.hash);
a.setAttribute('data-nav-drawer-item', r.route);
a.setAttribute('data-route', r.route);
var ic = document.createElement('span');
ic.className = 'nav-drawer-icon';
ic.setAttribute('aria-hidden', 'true');
ic.textContent = r.icon;
var lb = document.createElement('span');
lb.className = 'nav-drawer-label';
lb.textContent = r.label;
a.appendChild(ic);
a.appendChild(lb);
a.addEventListener('click', function () { close(); });
list.appendChild(a);
});
drawerEl.appendChild(list);
document.body.appendChild(backdropEl);
document.body.appendChild(drawerEl);
// Defer width measurement until after layout.
requestAnimationFrame(function () {
drawerWidth = drawerEl.getBoundingClientRect().width || 320;
});
}
// ── Open/close primitives ───────────────────────────────────────────────
function setTranslate(px) {
if (!drawerEl) return;
drawerEl.style.transform = 'translateX(' + px + 'px)';
}
function clearInlineTransform() {
if (drawerEl) drawerEl.style.transform = '';
}
function isOpen() {
return !!(drawerEl && drawerEl.classList.contains('is-open'));
}
function open() {
buildDom();
if (!isWide()) return; // Option A
if (!drawerWidth) drawerWidth = drawerEl.getBoundingClientRect().width || 320;
// Capture the previously-focused element BEFORE we move focus, so close()
// can restore it. Guard against opening twice (don't overwrite on re-open).
if (!isOpen()) {
try {
var ae = document.activeElement;
prevFocus = (ae && ae !== document.body) ? ae : null;
} catch (_e) { prevFocus = null; }
}
drawerEl.classList.add('is-open');
drawerEl.removeAttribute('inert');
drawerEl.setAttribute('aria-hidden', 'false');
backdropEl.hidden = false;
backdropEl.classList.add('is-open');
clearInlineTransform();
// Move focus into the drawer for keyboard users / screen readers.
var firstFocusable = drawerEl.querySelector(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input, select, textarea'
);
if (firstFocusable) {
try { firstFocusable.focus({ preventScroll: true }); } catch (_e) { firstFocusable.focus(); }
}
}
function close() {
if (!drawerEl) return;
var wasOpen = drawerEl.classList.contains('is-open');
// Decide whether to restore focus BEFORE applying `inert`. Setting
// `inert` synchronously moves document.activeElement to <body>, so any
// "is focus inside the drawer?" check after that point is useless.
// The right invariant: restore if we were open, prevFocus is still in
// the DOM, and it isn't a descendant of the drawer itself.
var toRestore = null;
if (wasOpen && prevFocus && typeof prevFocus.focus === 'function' &&
document.body && document.body.contains(prevFocus) &&
!drawerEl.contains(prevFocus)) {
toRestore = prevFocus;
}
prevFocus = null;
// Restore FIRST so the upcoming `inert` doesn't bump us to <body>.
if (toRestore) {
try { toRestore.focus({ preventScroll: true }); }
catch (_e) { /* element may be gone after SPA nav — ignore */ }
}
drawerEl.classList.remove('is-open');
drawerEl.setAttribute('inert', '');
drawerEl.setAttribute('aria-hidden', 'true');
if (backdropEl) {
backdropEl.hidden = true;
backdropEl.classList.remove('is-open');
}
clearInlineTransform();
}
function toggle() { if (isOpen()) close(); else open(); }
// ── Pointer drag-tracking ───────────────────────────────────────────────
function onPointerDown(e) {
// Mesh-Op review (PR #1184): only respond to touch + pen. Mouse drags
// from the left edge must NOT open the drawer (a stray mouse-down at
// x<EDGE_PX would otherwise hijack a click). Filter BEFORE any
// edge-zone math so the rest of the handler stays touch/pen-only.
if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;
if (!isWide()) return;
var x = e.clientX;
if (isOpen()) {
// Allow drag-to-close from anywhere inside drawer's left half.
if (!drawerEl) return;
var r = drawerEl.getBoundingClientRect();
if (x > r.right) return;
} else {
// Drawer trigger zone: [EDGE_MIN_PX, EDGE_PX]. The first EDGE_MIN_PX
// are reserved for iOS Safari's system back-swipe gesture (Mesh-Op
// review on #1184); claiming x < 24 collides with the OS gesture and
// leaves iPad users with a flaky double-fire.
if (x < EDGE_MIN_PX) return;
if (x > EDGE_PX) return;
}
buildDom();
if (!drawerWidth) drawerWidth = drawerEl.getBoundingClientRect().width || 320;
dragging = true;
pointerActive = true;
startX = lastX = x;
startY = e.clientY;
startT = lastT = (e.timeStamp || performance.now());
}
function onPointerMove(e) {
if (!dragging || !pointerActive) return;
var x = e.clientX;
var y = e.clientY;
// If the gesture is mostly vertical near the start, abandon (let scroll win).
if (Math.abs(x - startX) < 8 && Math.abs(y - startY) > 12) {
dragging = false;
pointerActive = false;
clearInlineTransform();
return;
}
lastX = x;
lastT = (e.timeStamp || performance.now());
if (prefersReducedMotion()) return; // no live tracking — settle on up
// Compute drawer x-position based on whether we started open or closed.
var basis = isOpen() ? 0 : -drawerWidth;
var delta = x - startX;
var px = Math.max(-drawerWidth, Math.min(0, basis + delta));
setTranslate(px);
}
function onPointerUp(e) {
if (!pointerActive) return;
pointerActive = false;
if (!dragging) { clearInlineTransform(); return; }
dragging = false;
var x = (e && typeof e.clientX === 'number') ? e.clientX : lastX;
var t = (e && e.timeStamp) || performance.now();
var dt = Math.max(1, t - startT);
var velocity = (x - startX) / dt; // px/ms
var openedBefore = isOpen();
clearInlineTransform();
if (openedBefore) {
if (velocity < VELOCITY_CLOSE || (x - startX) < -drawerWidth * OPEN_THRESHOLD) {
close();
} else {
open();
}
} else {
if (velocity > VELOCITY_OPEN || (x - startX) > drawerWidth * OPEN_THRESHOLD) {
open();
} else {
close();
}
}
}
// ── Focus trap ──────────────────────────────────────────────────────────
function onKeydown(e) {
if (!isOpen()) return;
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key !== 'Tab' || !drawerEl) return;
var focusables = drawerEl.querySelectorAll(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input, select, textarea'
);
if (focusables.length === 0) return;
var first = focusables[0];
var last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
// ── Wire-up (called once) ───────────────────────────────────────────────
function wireOnce() {
if (wired) return;
wired = true;
try { narrowMql = window.matchMedia('(max-width: ' + NARROW_MAX + 'px)'); }
catch (_e) { narrowMql = null; }
document.addEventListener('pointerdown', onPointerDown, { passive: true });
document.addEventListener('pointermove', onPointerMove, { passive: true });
document.addEventListener('pointerup', onPointerUp, { passive: true });
document.addEventListener('pointercancel', onPointerUp, { passive: true });
document.addEventListener('keydown', onKeydown);
// Close drawer if viewport drops to narrow (Option A).
if (narrowMql && typeof narrowMql.addEventListener === 'function') {
narrowMql.addEventListener('change', function () { if (!isWide()) close(); });
}
// Debug seam — E2E asserts this ≤ 1 across SPA navs (singleton proof).
window.__navDrawerPointerBindCount = (window.__navDrawerPointerBindCount || 0) + 1;
}
function init() {
wireOnce();
buildDom();
}
// Public API for tests + manual triggers (e.g. a hamburger button).
window.__navDrawer = { open: open, close: close, toggle: toggle, isOpen: isOpen };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();
+64 -150
View File
@@ -556,38 +556,7 @@
<tr><td>Hash Prefix</td><td>${n.hash_size ? '<code style="font-family:var(--mono);font-weight:700">' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + '</code> (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' <span style="color:var(--status-yellow);cursor:help" title="Seen: ' + (Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : []).join(', ') + '-byte"> varies</span>' : ''}</td></tr>
</table>
<div class="node-full-card" id="node-packets">
${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `
<h4>Recent Packets (${validPackets.length})</h4>
<div class="node-activity-list">
${validPackets.length ? validPackets.map(p => {
let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {}
const typeLabel = p.payload_type === 4 ? '📡 Advert' : p.payload_type === 5 ? '💬 Channel' : p.payload_type === 2 ? '✉️ DM' : '📦 Packet';
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
const obs = p.observer_name || p.observer_id;
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times">👁 ${p.observation_count}</span>` : '';
// Show hash size per advert if inconsistent
let hashSizeBadge = '';
if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
const pb = parseInt(p.raw_hex.slice(2, 4), 16);
if ((pb & 0x3F) !== 0) {
const hs = ((pb >> 6) & 0x3) + 1;
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
const hsFg = hs === 2 ? '#064e3b' : '#fff';
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
}
}
return `<div class="node-activity-item">
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze </a>
</div>`;
}).join('') : '<div class="text-muted">No recent packets</div>'}
</div>
`; })()}
</div>
<div class="node-full-card skew-detail-section" id="node-clock-skew" style="display:none"></div>
${observers.length ? `<div class="node-full-card" id="node-observers">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:8px"><strong>Regions:</strong> ${regions.map(r => '<span class="badge" style="margin:0 2px">' + escapeHtml(r) + '</span>').join(' ')}</div>` : ''; })()}
@@ -629,7 +598,38 @@
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths</div></div>
</div>
<div class="node-full-card skew-detail-section" id="node-clock-skew" style="display:none"></div>`;
<div class="node-full-card" id="node-packets">
${(() => { const validPackets = adverts.filter(p => p.hash && p.timestamp); return `
<h4>Recent Packets (${validPackets.length})</h4>
<div class="node-activity-list">
${validPackets.length ? validPackets.map(p => {
let decoded; try { decoded = JSON.parse(p.decoded_json); } catch {}
const typeLabel = p.payload_type === 4 ? '📡 Advert' : p.payload_type === 5 ? '💬 Channel' : p.payload_type === 2 ? '✉️ DM' : '📦 Packet';
const detail = decoded?.text ? ': ' + escapeHtml(truncate(decoded.text, 50)) : decoded?.name ? ' — ' + escapeHtml(decoded.name) : '';
const obs = p.observer_name || p.observer_id;
const snr = p.snr != null ? ` · SNR ${p.snr}dB` : '';
const rssi = p.rssi != null ? ` · RSSI ${p.rssi}dBm` : '';
const obsBadge = p.observation_count > 1 ? ` <span class="badge badge-obs" title="Seen ${p.observation_count} times">👁 ${p.observation_count}</span>` : '';
// Show hash size per advert if inconsistent
let hashSizeBadge = '';
if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) {
const pb = parseInt(p.raw_hex.slice(2, 4), 16);
if ((pb & 0x3F) !== 0) {
const hs = ((pb >> 6) & 0x3) + 1;
const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316';
const hsFg = hs === 2 ? '#064e3b' : '#fff';
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
}
}
return `<div class="node-activity-item">
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze </a>
</div>`;
}).join('') : '<div class="text-muted">No recent packets</div>'}
</div>
`; })()}
</div>`;
// Map
if (hasLoc) {
@@ -842,40 +842,7 @@
});
} catch (e) {
// #1150: surface a real error state in BOTH the back-row title and the body
// when /api/nodes/{pubkey} returns 404 (or any failure). Otherwise the title
// stays "Loading…" forever and there's no link back to the Nodes list.
const msg = (e && e.message) || '';
const is404 = /\b404\b/.test(msg) || /not\s*found/i.test(msg);
const titleEl = document.querySelector('.node-full-title');
if (titleEl) {
titleEl.textContent = is404
? 'Node not found — ' + (pubkey || '').slice(0, 12) + '…'
: 'Failed to load node';
}
const safePubkey = escapeHtml(pubkey || '');
const headline = is404 ? 'Node not found' : 'Failed to load node';
const detail = is404
? 'No node matched the requested public key on this instance. It may exist on another deployment, or it may have been evicted/blacklisted here.'
: 'The node detail API call failed: ' + escapeHtml(msg);
body.innerHTML =
'<div class="node-full-card" style="padding:24px;margin:16px auto;max-width:560px;text-align:center">' +
'<div style="font-size:18px;font-weight:600;margin-bottom:8px">' + headline + '</div>' +
'<div class="mono" style="font-size:11px;color:var(--text-muted);word-break:break-all;margin-bottom:12px">' + safePubkey + '</div>' +
'<div style="color:var(--text-muted);margin-bottom:16px">' + detail + '</div>' +
'<div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">' +
'<a href="#/nodes" class="btn-primary" style="text-decoration:none;padding:6px 14px">← Back to Nodes</a>' +
'<button id="nodeRetryBtn" class="btn-primary" style="padding:6px 14px">Try again</button>' +
'</div>' +
'</div>';
const retryBtn = document.getElementById('nodeRetryBtn');
if (retryBtn) {
retryBtn.addEventListener('click', function () {
if (titleEl) titleEl.textContent = 'Loading…';
body.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
loadFullNode(pubkey);
});
}
body.innerHTML = `<div class="text-muted" style="padding:40px">Failed to load node: ${e.message}</div>`;
}
}
@@ -1123,16 +1090,16 @@
</select>
</div>
</div>
<div class="table-fluid-wrap"><table class="data-table" id="nodesTable">
<table class="data-table" id="nodesTable">
<thead><tr>
<th scope="col" data-sort-key="name" data-priority="1">Name</th>
<th scope="col" class="col-pubkey" data-sort-key="public_key" data-priority="3">Public Key</th>
<th scope="col" data-sort-key="role" data-priority="2">Role</th>
<th scope="col" data-sort-key="last_seen" data-sort-default="desc" data-priority="1">Last Seen</th>
<th scope="col" data-sort-key="advert_count" data-sort-default="desc" data-priority="2">Adverts</th>
<th scope="col" data-sort-key="name">Name</th>
<th scope="col" class="col-pubkey" data-sort-key="public_key">Public Key</th>
<th scope="col" data-sort-key="role">Role</th>
<th scope="col" data-sort-key="last_seen" data-sort-default="desc">Last Seen</th>
<th scope="col" data-sort-key="advert_count" data-sort-default="desc">Adverts</th>
</tr></thead>
<tbody id="nodesBody"></tbody>
</table></div>`;
</table>`;
// Tab clicks
const nodeTabs = document.getElementById('nodeTabs');
@@ -1266,11 +1233,6 @@
}).join('');
bindFavStars(tbody);
makeColumnsResizable('#nodesTable', 'meshcore-nodes-col-widths');
// #1056: fluid columns + +N hidden pill
if (window.TableResponsive) {
var _ndTbl = document.getElementById('nodesTable');
if (_ndTbl) window.TableResponsive.register(_ndTbl);
}
}
/**
@@ -1292,49 +1254,6 @@
location.hash = '#/nodes/' + encodeURIComponent(pubkey);
return;
}
// #1056 AC#4: narrow desktop/tablet (6411023) — open detail in slide-over.
if (window.SlideOver && window.SlideOver.shouldUse()) {
selectedKey = pubkey;
history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey));
renderRows();
const so = window.SlideOver.open({
title: 'Node detail',
// Resolver runs after onClose re-renders rows, so look the row up
// by data-key after the new tbody is in place.
restoreFocus: function () {
return document.querySelector('#nodesTable tbody tr[data-key="'
+ (window.CSS && CSS.escape ? CSS.escape(pubkey) : pubkey)
+ '"]');
},
onClose: function () {
selectedKey = null;
history.replaceState(null, '', '#/nodes');
renderRows();
}
});
so.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
try {
const data = await fetchNodeDetail(pubkey);
if (selectedKey !== pubkey) return;
const n = (data && data.node) || data || {};
const titleEl = document.querySelector('.slide-over-title');
if (titleEl) titleEl.textContent = n.advert_name || (n.public_key ? n.public_key.slice(0, 10) : 'Node');
var role = (n.role || '').toString();
var lastHeard = n.last_heard || n.last_seen;
so.innerHTML =
'<dl style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:13px">' +
'<dt>Name</dt><dd>' + escapeHtml(n.advert_name || '—') + '</dd>' +
'<dt>Role</dt><dd>' + escapeHtml(role || '—') + '</dd>' +
'<dt>Public key</dt><dd class="mono" style="word-break:break-all">' + escapeHtml(n.public_key || '—') + '</dd>' +
'<dt>Last heard</dt><dd>' + (lastHeard ? timeAgo(lastHeard) : '—') + '</dd>' +
'<dt>Adverts</dt><dd>' + (n.advert_count != null ? n.advert_count : '—') + '</dd>' +
'</dl>' +
'<p style="margin-top:14px"><a class="btn-primary" href="#/nodes/' + encodeURIComponent(pubkey) + '">Open full detail →</a></p>';
} catch (e) {
so.innerHTML = '<div class="text-muted">Error: ' + (e && e.message ? e.message : String(e)) + '</div>';
}
return;
}
selectedKey = pubkey;
history.replaceState(null, '', '#/nodes/' + encodeURIComponent(pubkey));
renderRows();
@@ -1402,6 +1321,29 @@
</dl>
</div>
<div class="node-detail-section skew-detail-section" id="node-clock-skew" style="display:none"></div>
${observers.length ? `<div class="node-detail-section">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:6px;font-size:12px"><strong>Regions:</strong> ${regions.join(', ')}</div>` : ''; })()}
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<div class="observer-list">
${observers.map(o => `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' <span class="badge" style="font-size:10px">' + escapeHtml(o.iata) + '</span>' : ''}</span>
<span style="color:var(--text-muted)">${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + Number(o.avgSnr).toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + Number(o.avgRssi).toFixed(0) : ''}</span>
</div>`).join('')}
</div>
</div>` : ''}
<div class="node-detail-section" id="panelNeighborsSection">
<h4 id="panelNeighborsHeader">Neighbors</h4>
<div id="panelNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors</div></div>
</div>
<div class="node-detail-section" id="pathsSection">
<h4>Paths Through This Node</h4>
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths</div></div>
</div>
<div class="node-detail-section">
${(() => { const validPackets = adverts.filter(a => a.hash && a.timestamp); return `
<h4>Recent Packets (${validPackets.length})</h4>
@@ -1427,34 +1369,6 @@
</div>
`; })()}
</div>
${observers.length ? `<div class="node-detail-section">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:6px;font-size:12px"><strong>Regions:</strong> ${regions.join(', ')}</div>` : ''; })()}
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<div class="observer-list">
${observers.map(o => {
const stats = [`${o.packetCount} pkts`];
if (o.avgSnr != null) stats.push('SNR ' + Number(o.avgSnr).toFixed(1) + 'dB');
if (o.avgRssi != null) stats.push('RSSI ' + Number(o.avgRssi).toFixed(0));
return `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' <span class="badge" style="font-size:10px">' + escapeHtml(o.iata) + '</span>' : ''}</span>
<span style="color:var(--text-muted)">${stats.join(' · ')}</span>
</div>`;
}).join('')}
</div>
</div>` : ''}
<div class="node-detail-section" id="panelNeighborsSection">
<h4 id="panelNeighborsHeader">Neighbors</h4>
<div id="panelNeighborsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading neighbors</div></div>
</div>
<div class="node-detail-section" id="pathsSection">
<h4>Paths Through This Node</h4>
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths</div></div>
</div>
<div class="node-detail-section skew-detail-section" id="node-clock-skew" style="display:none"></div>
</div>`;
// Init map
+4 -50
View File
@@ -27,16 +27,7 @@
var btn = e.target.closest('[data-action]');
if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
var row = e.target.closest('tr[data-action="navigate"]');
if (row) {
// #1056 AC#4: at narrow widths, open detail in slide-over instead of
// navigating to a separate page.
if (window.SlideOver && window.SlideOver.shouldUse()) {
e.preventDefault();
openObserverSlideOver(row.dataset.value);
return;
}
location.hash = row.dataset.value;
}
if (row) location.hash = row.dataset.value;
});
// #209 — Keyboard accessibility for observer rows
app.addEventListener('keydown', function (e) {
@@ -44,10 +35,6 @@
if (!row) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (window.SlideOver && window.SlideOver.shouldUse()) {
openObserverSlideOver(row.dataset.value);
return;
}
location.hash = row.dataset.value;
});
// Auto-refresh every 30s
@@ -153,11 +140,11 @@
<span class="obs-stat"><span class="health-dot health-red"></span> ${offline} Offline</span>
<span class="obs-stat">📡 ${filtered.length} Total</span>
</div>
<div class="obs-table-scroll table-fluid-wrap"><table class="data-table obs-table" id="obsTable">
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
<caption class="sr-only">Observer status and statistics</caption>
<thead><tr>
<th scope="col" data-priority="1">Status</th><th scope="col" data-priority="1">Name</th><th scope="col" data-priority="3">Region</th><th scope="col" data-priority="2">Last Status</th><th scope="col" data-priority="2">Last Packet</th>
<th scope="col" data-priority="3">Packet Health</th><th scope="col" data-priority="4">Total Packets</th><th scope="col" data-priority="3">Packets/Hour</th><th scope="col" data-priority="4">Clock Offset</th><th scope="col" data-priority="4">Uptime</th>
<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Region</th><th scope="col">Last Status</th><th scope="col">Last Packet</th>
<th scope="col">Packet Health</th><th scope="col">Total Packets</th><th scope="col">Packets/Hour</th><th scope="col">Clock Offset</th><th scope="col">Uptime</th>
</tr></thead>
<tbody>${filtered.map(o => {
const h = healthStatus(o.last_seen);
@@ -182,41 +169,8 @@
}).join('')}</tbody>
</table></div>`;
makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths');
// #1056: fluid columns + +N hidden pill
if (window.TableResponsive) {
var _obsTbl = document.getElementById('obsTable');
if (_obsTbl) window.TableResponsive.register(_obsTbl);
}
}
registerPage('observers', { init, destroy });
// #1056 AC#4: row-detail slide-over (narrow viewports). Renders a compact
// summary from the in-memory observer + a link to the full page.
function openObserverSlideOver(hashHref) {
if (!window.SlideOver) return;
var m = String(hashHref || '').match(/#\/observers\/(.+)$/);
if (!m) return;
var id = decodeURIComponent(m[1]);
var o = (observers || []).find(function (x) { return String(x.id) === id; });
if (!o) return;
var h = healthStatus(o.last_seen);
var sk = obsSkewMap[o.id];
var skewLine = (sk && sk.samples) ? renderSkewBadge(observerSkewSeverity(sk.offsetSec), sk.offsetSec) + ' (' + sk.samples + ' samples)' : '—';
var pkts = sparkBar(o.packetsLastHour || 0, Math.max(1, o.packetsLastHour || 1));
var content = window.SlideOver.open({ title: o.name || o.id });
content.innerHTML =
'<dl class="slide-over-dl" style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:13px">' +
'<dt>Status</dt><dd><span class="health-dot ' + h.cls + '">●</span> ' + h.label + '</dd>' +
'<dt>Region</dt><dd>' + (o.iata ? '<span class="badge-region">' + o.iata + '</span>' : '—') + '</dd>' +
'<dt>Last status</dt><dd>' + timeAgo(o.last_seen) + '</dd>' +
'<dt>Last packet</dt><dd>' + (o.last_packet_at ? timeAgo(o.last_packet_at) : '—') + '</dd>' +
'<dt>Total packets</dt><dd>' + (o.packet_count || 0).toLocaleString() + '</dd>' +
'<dt>Packets/hr</dt><dd>' + pkts + '</dd>' +
'<dt>Clock offset</dt><dd>' + skewLine + '</dd>' +
'<dt>Uptime</dt><dd>' + uptimeStr(o.first_seen) + '</dd>' +
'</dl>' +
'<p style="margin-top:14px"><a class="btn-primary" href="' + hashHref + '">Open full detail →</a></p>';
}
})();
+30 -673
View File
@@ -1,437 +1,6 @@
/* === CoreScope — packets.js === */
'use strict';
/* === #1056: TableResponsive fluid columns + "+N hidden" pill ============
* Tiny helper, defined once, used by packets/nodes/observers tables.
*
* Usage: TableResponsive.apply(tableEl)
*
* Each <th> may carry a `data-priority` attribute (1=keep always, higher
* numbers = drop first as viewport narrows). Default priority is 1.
*
* apply() measures the container width and progressively hides the highest-
* priority columns (and matching <td>s) until the table's natural scrollWidth
* fits, then renders a "+N hidden" pill in the last visible <th>. Click the
* pill to reveal all hidden columns until the next layout pass.
*
* Re-runs on window resize (debounced) and is idempotent safe to call after
* every render. ResizeObserver on the wrapping element also triggers re-fit.
*/
(function () {
if (window.TableResponsive) return;
const REVEAL_FLAG = '__tr_reveal';
const PILL_CLASS = 'col-hidden-pill';
const HIDDEN_CLASS = 'col-hidden';
function thsOf(table) { return Array.from(table.querySelectorAll('thead > tr > th')); }
function clearHidden(table) {
table.querySelectorAll('.' + HIDDEN_CLASS).forEach(el => el.classList.remove(HIDDEN_CLASS));
const pill = table.querySelector('.' + PILL_CLASS);
if (pill) pill.remove();
}
function colIndexCells(table, idx) {
// Return the <td> at column index `idx` for every body row.
const out = [];
const rows = table.querySelectorAll('tbody > tr');
rows.forEach(r => {
// colSpan-aware mapping: walk cells, accumulate colspans.
let i = 0;
for (const cell of r.children) {
const span = cell.colSpan || 1;
if (i <= idx && idx < i + span) { out.push(cell); break; }
i += span;
}
});
return out;
}
function apply(table) {
if (!table || !table.isConnected) return;
if (table[REVEAL_FLAG]) {
// user explicitly requested reveal — clear hidden state and skip
clearHidden(table);
return;
}
clearHidden(table);
const ths = thsOf(table);
if (ths.length === 0) return;
// Viewport-breakpoint hiding (per issue #1056 acceptance criteria):
// data-priority on each <th>:
// 1 → always visible
// 2 → hide when viewport ≤ 1280
// 3 → hide when viewport ≤ 1024 (per AC #1 wording)
// 4 → hide when viewport ≤ 900
// 5 → hide when viewport ≤ 768
// Higher priority numbers drop FIRST (least important).
// Drop direction: a column is hidden if its breakpoint ≥ current viewport.
const BP = { 2: 1280, 3: 1024, 4: 900, 5: 768 };
const vw = window.innerWidth || document.documentElement.clientWidth;
const candidates = ths
.map((th, i) => ({ th, i, prio: parseInt(th.getAttribute('data-priority') || '1', 10) }))
.filter(c => c.prio > 1 && BP[c.prio] !== undefined && vw <= BP[c.prio])
// hide highest priority numbers first (drop-first), then right-to-left ties
.sort((a, b) => b.prio - a.prio || b.i - a.i);
let hidden = 0;
for (const c of candidates) {
c.th.classList.add(HIDDEN_CLASS);
colIndexCells(table, c.i).forEach(td => td.classList.add(HIDDEN_CLASS));
hidden++;
}
if (hidden > 0) {
const visible = ths.filter(th => !th.classList.contains(HIDDEN_CLASS));
const host = visible[visible.length - 1] || ths[0];
const pill = document.createElement('button');
pill.type = 'button';
pill.className = PILL_CLASS;
pill.textContent = '+' + hidden + ' hidden';
pill.title = 'Click to reveal hidden columns';
pill.setAttribute('aria-label', hidden + ' columns hidden — click to reveal');
pill.addEventListener('click', function (ev) {
ev.stopPropagation();
ev.preventDefault();
table[REVEAL_FLAG] = true;
clearHidden(table);
// Add a small "hide again" affordance after reveal so the user isn't stuck.
const rehide = document.createElement('button');
rehide.type = 'button';
rehide.className = PILL_CLASS + ' col-rehide-pill';
rehide.textContent = 'hide';
rehide.title = 'Re-hide collapsed columns';
rehide.setAttribute('aria-label', 'Re-hide previously collapsed columns');
rehide.addEventListener('click', function (ev2) {
ev2.stopPropagation();
ev2.preventDefault();
table[REVEAL_FLAG] = false;
apply(table);
});
rehide.addEventListener('keydown', function (ev2) {
// Prevent Enter/Space from bubbling up to TableSort handler on the <th>.
if (ev2.key === 'Enter' || ev2.key === ' ') ev2.stopPropagation();
});
host.appendChild(rehide);
});
// MAJOR-3: prevent Enter/Space keydown on the pill from bubbling to the
// <th>'s TableSort keydown handler (which would also trigger a sort).
pill.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') ev.stopPropagation();
});
host.appendChild(pill);
}
}
// Track tables we've wired up so resize triggers re-apply.
const wired = new Set();
// Track last-seen wrap width per table so we only treat ACTUAL container
// resizes as a reason to drop the user's reveal state. Hiding/showing
// columns and removing the pill mutate layout and re-trigger ResizeObserver,
// which would otherwise immediately stomp on the reveal the user just asked for.
const lastWrapW = new WeakMap();
function register(table) {
if (!table || wired.has(table)) { apply(table); return; }
wired.add(table);
if (typeof ResizeObserver !== 'undefined') {
const wrap = table.closest('.table-fluid-wrap, .obs-table-scroll, .table-scroll-wrap') || table.parentElement;
if (wrap) {
lastWrapW.set(table, wrap.clientWidth || 0);
const ro = new ResizeObserver(() => {
const prev = lastWrapW.get(table) || 0;
const cur = wrap.clientWidth || 0;
// Ignore self-induced layout reflows from apply()/clearHidden() —
// they don't change the wrap width. Only real viewport/container
// changes (>2px) clear the reveal flag.
if (Math.abs(cur - prev) <= 2) return;
lastWrapW.set(table, cur);
table[REVEAL_FLAG] = false;
apply(table);
});
ro.observe(wrap);
}
}
apply(table);
}
let _winTimer = null;
window.addEventListener('resize', function () {
clearTimeout(_winTimer);
_winTimer = setTimeout(() => {
wired.forEach(t => {
if (!t.isConnected) { wired.delete(t); return; }
t[REVEAL_FLAG] = false;
apply(t);
});
}, 120);
});
window.TableResponsive = { apply, register };
})();
/* === #1056 AC#4: SlideOver narrow-viewport row-detail overlay ============
* Singleton backdrop + right-anchored panel injected into <body>. Used by
* packets/nodes/observers when window.innerWidth <= SLIDE_OVER_BP (1023,
* matching the data-priority="3" breakpoint reused by TableResponsive).
*
* SlideOver.shouldUse() boolean (current viewport <= breakpoint)
* SlideOver.open(opts) returns the inner content element. opts:
* { title?: string, onClose?: function, restoreFocus?: () => Element|null }
* `restoreFocus` (optional) overrides the auto-captured
* `document.activeElement` and is invoked at close time to look up the
* element to focus. Use this when the caller re-renders the originating
* row before/after opening (which would otherwise detach the focused
* row from the DOM and leave nothing for auto-restore to find).
* SlideOver.close() close + dispatch onClose
* SlideOver.isOpen() boolean
*
* Close affordances: X button (.slide-over-close), backdrop click, Escape.
* Reuses `slideInRight` keyframe in style.css.
*/
(function () {
if (window.SlideOver) return;
// #1168 Munger #3: shared, ref-counted scroll-lock helper. Multiple
// modal surfaces (SlideOver, ChannelColorPicker, future modals) call
// acquire()/release() with their own token; the body keeps the
// `scroll-locked` class (CSS supplies overflow:hidden in style.css)
// for as long as the count > 0. Last release removes the class.
// This replaces the previous capture-and-restore-string approach
// which corrupted body.style.overflow under last-writer-wins races.
if (!window.__scrollLock) {
let count = 0;
let next = 1;
const live = new Set();
function acquire() {
const token = next++;
live.add(token);
count++;
if (count === 1) document.body.classList.add('scroll-locked');
return token;
}
function release(token) {
if (token == null || !live.has(token)) return;
live.delete(token);
count--;
if (count <= 0) {
count = 0;
document.body.classList.remove('scroll-locked');
}
}
window.__scrollLock = { acquire: acquire, release: release };
}
const BP = 1023;
let backdrop = null, panel = null, content = null, closeCb = null;
let prevFocus = null, prevFocusResolver = null;
// #1168 Munger #1: openSeq counter so a stale rAF from close() can
// detect a newer open() happened in between and skip its focus call.
let openSeq = 0;
// #1168 Munger #3: ref-counted scroll-lock token held by THIS surface
// (multiple SlideOver opens reuse the same token; only paired with a
// matching release on close).
let scrollLockToken = null;
function ensureNodes() {
if (panel && backdrop) return;
backdrop = document.createElement('div');
backdrop.className = 'slide-over-backdrop';
backdrop.hidden = true;
// Backdrop is decorative — assistive tech should not announce it.
backdrop.setAttribute('aria-hidden', 'true');
backdrop.addEventListener('click', function () { close(); });
panel = document.createElement('aside');
panel.className = 'slide-over-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
// #1168 must-fix #4: a static aria-label="Detail" would override the
// meaningful <h3 id="slideOverTitle"> (e.g. "Packet ab12cd…", node name)
// for screen-reader users. Use aria-labelledby so the announced name
// is the actual title rendered into the panel.
panel.setAttribute('aria-labelledby', 'slideOverTitle');
panel.hidden = true;
panel.tabIndex = -1;
panel.innerHTML =
'<div class="slide-over-header">' +
'<h3 class="slide-over-title" id="slideOverTitle"></h3>' +
'<button type="button" class="slide-over-close" aria-label="Close detail (Esc)" title="Close">✕</button>' +
'</div>' +
'<div class="slide-over-content"></div>';
panel.querySelector('.slide-over-close').addEventListener('mousedown', function (e) {
// Prevent the X from stealing focus on pointer-press. Without this,
// Chromium focuses the button on mousedown → close() runs while X has
// focus → hiding the panel triggers an implicit blur to <body> that
// races with (and clobbers) our row-focus-restore. With this guard,
// the originating row keeps focus throughout the click → the post-
// close rAF restore runs unopposed.
e.preventDefault();
});
panel.querySelector('.slide-over-close').addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
close();
});
// Focus trap: keep Tab cycling inside the panel while open.
panel.addEventListener('keydown', function (e) {
if (e.key !== 'Tab' || !isOpen()) return;
const focusables = panel.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (!focusables.length) return;
const first = focusables[0], last = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === panel)) {
e.preventDefault();
try { last.focus(); } catch {}
} else if (!e.shiftKey && active === last) {
e.preventDefault();
try { first.focus(); } catch {}
}
});
document.body.appendChild(backdrop);
document.body.appendChild(panel);
// Single Escape handler shared across all uses.
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) {
e.stopPropagation();
close();
}
});
// #1168 Munger #2: hashchange cleanup. Without this, navigating from
// /#/packets to /#/nodes via location.hash leaves panel + backdrop +
// scroll-lock dangling across pages. Registered once with the other
// singleton listeners.
//
// Scope: only close on PAGE-route changes (first hash segment), not
// on within-page detail navigation. Observers (and others) write
// /#/observers/<id> when opening a row; that hashchange must NOT
// close the slide-over we just opened.
window.addEventListener('hashchange', function (e) {
if (!isOpen()) return;
function pageOf(hash) {
var m = String(hash || '').match(/^#?\/?([^\/?#]+)/);
return m ? m[1] : '';
}
var oldPage = pageOf(e && e.oldURL ? e.oldURL.split('#')[1] || '' : '');
var newPage = pageOf(e && e.newURL ? e.newURL.split('#')[1] || '' : location.hash);
if (oldPage !== newPage) close();
});
}
function shouldUse() {
return (window.innerWidth || document.documentElement.clientWidth) <= BP;
}
function isOpen() {
return !!(panel && !panel.hidden);
}
function open(opts) {
// If already open, properly close the prior caller first so its onClose
// (which clears `selectedKey`/hash state) fires before we replace it.
if (isOpen()) close();
ensureNodes();
opts = opts || {};
// #1168 Munger #1: bump open sequence so any pending rAF from a
// prior close() can detect that a newer open has happened and skip
// its stale focus-restore.
openSeq++;
closeCb = typeof opts.onClose === 'function' ? opts.onClose : null;
// If the caller passes restoreFocus(), it owns lookup at close-time —
// useful when the caller re-renders the row table (which would detach
// any auto-captured prevFocus DOM node).
prevFocusResolver = typeof opts.restoreFocus === 'function' ? opts.restoreFocus : null;
// Remember what was focused so we can restore on close.
prevFocus = (document.activeElement && document.activeElement !== document.body)
? document.activeElement : null;
// #1168 Munger #3: ref-counted scroll-lock — class-based, not value-restore.
// Survives interleaved lockers (other modals can also acquire/release).
if (scrollLockToken == null) {
scrollLockToken = window.__scrollLock.acquire();
}
const title = panel.querySelector('.slide-over-title');
title.textContent = opts.title || 'Detail';
content = panel.querySelector('.slide-over-content');
content.innerHTML = '';
backdrop.hidden = false;
panel.hidden = false;
// Focus the close button so Esc/Enter works without an extra tab.
const x = panel.querySelector('.slide-over-close');
if (x) try { x.focus(); } catch {}
return content;
}
function close() {
if (!panel || panel.hidden) return;
panel.hidden = true;
if (backdrop) backdrop.hidden = true;
// #1168 Munger #3: release the ref-counted scroll-lock token.
if (scrollLockToken != null) {
window.__scrollLock.release(scrollLockToken);
scrollLockToken = null;
}
const cb = closeCb;
closeCb = null;
if (content) content.innerHTML = '';
// Restore focus to whatever opened us (typically the table row), so
// keyboard users don't get dumped at the top of the document.
let toFocus = prevFocus;
const resolver = prevFocusResolver;
prevFocus = null;
prevFocusResolver = null;
// #1168 Munger #1: capture the open-sequence at close-time. If a NEW
// open() happens before our deferred rAF fires, openSeq will have
// advanced past this value and the stale rAF must no-op (otherwise
// it would steal focus back to row A's originating row AFTER row B
// is open — clobbering B's focus).
const seqAtClose = openSeq;
if (cb) try { cb(); } catch {}
// Resolver runs AFTER cb (cb may re-render the table and reattach the row).
if (resolver) {
try {
const resolved = resolver();
if (resolved) toFocus = resolved;
} catch {}
}
if (toFocus && typeof toFocus.focus === 'function' && document.body.contains(toFocus)) {
// Defer to next microtask + rAF so the focus call lands AFTER any
// event-handler bookkeeping (e.g. an Escape keydown chain that would
// otherwise see focus snap back to <body> as the key event unwinds).
const target = toFocus;
const tryFocus = function () {
// Munger #1: bail if a newer open() has happened since close-time.
if (openSeq !== seqAtClose) return;
if (document.body.contains(target)) {
try { target.focus(); } catch {}
}
};
tryFocus();
requestAnimationFrame(tryFocus);
}
}
// If the viewport grows past the breakpoint while open, close the slide-over
// so callers can re-route into the wide-viewport side panel.
let _resizeT = null;
window.addEventListener('resize', function () {
if (!isOpen()) return;
clearTimeout(_resizeT);
_resizeT = setTimeout(function () {
if (isOpen() && !shouldUse()) close();
}, 120);
});
window.SlideOver = { open: open, close: close, isOpen: isOpen, shouldUse: shouldUse, BP: BP };
})();
(function () {
let packets = [];
let hashIndex = new Map(); // hash → packet group for O(1) dedup
@@ -1246,25 +815,33 @@
</div>
<div class="filter-bar" id="pktFilters">
<button class="btn filter-toggle-btn" id="filterToggleBtn">Filters </button>
<!-- #1124 (MAJOR-3) Group 1: Filter input + Clear -->
<div class="filter-group filter-group-clear">
<button class="btn btn-clear-filters" id="clearFiltersBtn" title="Clear all filters" style="display:none;font-size:12px;padding:2px 8px;color:var(--text-muted);border:1px solid var(--border);border-radius:4px;background:transparent;cursor:pointer"> Clear</button>
</div>
<!-- Group 2: Quick filters (hash, node name) -->
<div class="filter-group filter-group-quick">
<button class="btn btn-clear-filters" id="clearFiltersBtn" title="Clear all filters" style="display:none;font-size:12px;padding:2px 8px;color:var(--text-muted);border:1px solid var(--border);border-radius:4px;background:transparent;cursor:pointer"> Clear</button>
<div class="filter-group">
<input type="text" placeholder="Packet hash…" id="fHash" aria-label="Filter by packet hash" title="Filter packets by hex hash prefix">
<div class="node-filter-wrap" style="position:relative">
<input type="text" placeholder="Node name…" id="fNode" autocomplete="off" role="combobox" aria-expanded="false" aria-owns="fNodeDropdown" aria-activedescendant="" aria-autocomplete="list" title="Filter packets involving this node (sender or path)">
<div class="node-filter-dropdown hidden" id="fNodeDropdown" role="listbox"></div>
</div>
<div class="multi-select-wrap" id="observerFilterWrap">
<button class="multi-select-trigger" id="observerTrigger" title="Show only packets seen by selected observer stations">All Observers </button>
<div class="multi-select-menu" id="observerMenu"></div>
</div>
<div id="packetsRegionFilter" class="region-filter-container" style="display:inline-block;vertical-align:middle"></div>
<div class="multi-select-wrap" id="typeFilterWrap">
<button class="multi-select-trigger" id="typeTrigger" title="Filter by packet type">All Types </button>
<div class="multi-select-menu" id="typeMenu"></div>
</div>
<div class="filter-group" style="display:inline-flex;align-items:center;gap:4px">
<select id="fChannel" class="filter-select" aria-label="Filter by channel" title="Filter Channel Messages (GRP_TXT) by channel">
<option value="">All Channels</option>
</select>
</div>
</div>
<!-- Group 3: Quick toggles (time range, Group by Hash, My Nodes)
#1128 Bug 5: placed BEFORE the Observer/Region/Type/Channel
dropdowns so the most-frequently-used controls sit next to
the search input where the eye lands first. -->
<div class="filter-group filter-group-toggles">
<div class="filter-group">
<button class="btn ${groupByHash ? 'active' : ''}" id="fGroup" title="Collapse duplicate observations of the same packet into expandable groups">Group by Hash</button>
<button class="btn" id="fMyNodes" title="Show only packets from your favorited/claimed nodes"> My Nodes</button>
</div>
<div class="filter-group">
<select id="fTimeWindow" class="filter-select" aria-label="Time window filter">
<option value="15">Last 15 min</option>
<option value="30">Last 30 min</option>
@@ -1276,23 +853,7 @@
${isMobile ? '' : '<option value="0">All time</option>'}
</select>
</div>
<!-- Group 4: Dropdowns (observers, regions, types, channels) -->
<div class="filter-group filter-group-dropdowns">
<div class="multi-select-wrap" id="observerFilterWrap">
<button class="multi-select-trigger" id="observerTrigger" title="Show only packets seen by selected observer stations">All Observers </button>
<div class="multi-select-menu" id="observerMenu"></div>
</div>
<div id="packetsRegionFilter" class="region-filter-container" style="display:inline-block;vertical-align:middle"></div>
<div class="multi-select-wrap" id="typeFilterWrap">
<button class="multi-select-trigger" id="typeTrigger" title="Filter by packet type">All Types </button>
<div class="multi-select-menu" id="typeMenu"></div>
</div>
<select id="fChannel" class="filter-select" aria-label="Filter by channel" title="Filter Channel Messages (GRP_TXT) by channel">
<option value="">All Channels</option>
</select>
</div>
<!-- Group 5: Sort + Columns -->
<div class="filter-group filter-group-sort">
<div class="filter-group">
<select id="fObsSort" aria-label="Observation sort order" title="Controls how observations are ordered within packet groups and which observation appears in the header row. Observer: Groups by observer station, earliest first. Path: Orders by hop count. Time: Orders by observation timestamp.">
<option value="observer">Sort: Observer</option>
<option value="path-asc">Sort: Path (shortest)</option>
@@ -1301,6 +862,8 @@
<option value="chrono-desc">Sort: Time (latest)</option>
</select>
<span class="sort-help" id="sortHelpIcon" tabindex="0" role="button" aria-label="Sort help"></span>
</div>
<div class="filter-group">
<div class="col-toggle-wrap">
<button class="col-toggle-btn" id="colToggleBtn" title="Show/hide table columns">Columns </button>
<div class="col-toggle-menu" id="colToggleMenu"></div>
@@ -1308,14 +871,14 @@
<button class="btn btn-icon${showHexHashes ? ' active' : ''}" id="hexHashToggle" title="Show raw hex hash prefixes instead of resolved node names in the path column">Hex Paths</button>
</div>
</div>
<div class="table-fluid-wrap"><table class="data-table" id="pktTable">
<table class="data-table" id="pktTable">
<thead><tr>
<th scope="col" data-priority="1"></th><th scope="col" class="col-region" data-sort-key="region" data-priority="3">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date" data-priority="1">Time</th><th scope="col" class="col-hash" data-sort-key="hash" data-priority="1">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric" data-priority="4">Size</th>
<th scope="col" class="col-hashsize" data-sort-key="hb" data-type="numeric" data-priority="5">HB</th>
<th scope="col" class="col-type" data-sort-key="type" data-priority="1">Type</th><th scope="col" class="col-observer" data-sort-key="observer" data-priority="3">Observer</th><th scope="col" class="col-path" data-sort-key="path" data-priority="2">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric" data-priority="4">Rpt</th><th scope="col" class="col-details" data-priority="2">Details</th>
<th scope="col"></th><th scope="col" class="col-region" data-sort-key="region">Region</th><th scope="col" class="col-time" data-sort-key="time" data-type="date">Time</th><th scope="col" class="col-hash" data-sort-key="hash">Hash</th><th scope="col" class="col-size" data-sort-key="size" data-type="numeric">Size</th>
<th scope="col" class="col-hashsize" data-sort-key="hb" data-type="numeric">HB</th>
<th scope="col" class="col-type" data-sort-key="type">Type</th><th scope="col" class="col-observer" data-sort-key="observer">Observer</th><th scope="col" class="col-path" data-sort-key="path">Path</th><th scope="col" class="col-rpt" data-sort-key="rpt" data-type="numeric">Rpt</th><th scope="col" class="col-details">Details</th>
</tr></thead>
<tbody id="pktBody"></tbody>
</table></div>
</table>
`;
// Init shared RegionFilter component
@@ -1382,8 +945,6 @@
if (window.FilterUX && typeof window.FilterUX.init === 'function') {
window.FilterUX.init();
}
// #1124 (MAJOR-1): wire the path overflow popover (delegated; idempotent).
_wirePathOverflowPopover();
// --- Observer multi-select ---
const obsMenu = document.getElementById('observerMenu');
@@ -1443,20 +1004,13 @@
}
function updateTypeTrigger() {
const total = Object.keys(typeMap).length;
// #1128 (Bug 3): trigger has bounded max-width so long selections like
// "TRACE,MULTIPART,GRP_TXT" get ellipsised. Always set the full label
// as the `title` attribute so the user can recover it via tooltip.
const fullList = [...selectedTypes].map(k => typeMap[k] || k).join(', ');
if (selectedTypes.size === 0 || selectedTypes.size === total) {
typeTrigger.textContent = 'All Types ▾';
typeTrigger.title = 'Filter by packet type';
} else if (selectedTypes.size === 1) {
const k = [...selectedTypes][0];
typeTrigger.textContent = (typeMap[k] || k) + ' ▾';
typeTrigger.title = 'Selected: ' + fullList;
} else {
typeTrigger.textContent = selectedTypes.size + ' Types ▾';
typeTrigger.title = 'Selected: ' + fullList;
}
}
buildTypeMenu();
@@ -1853,12 +1407,6 @@
renderTableRows();
makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths');
// #1056: register fluid-column responsive behavior (drops priority>1 cols
// when narrow, shows "+N hidden" pill, reveals on click). Idempotent.
if (window.TableResponsive) {
var _pktTbl = document.getElementById('pktTable');
if (_pktTbl) window.TableResponsive.register(_pktTbl);
}
// Initialize table sorting (virtual scroll — sort data array, not DOM)
if (window.TableSort) {
@@ -2057,17 +1605,11 @@
if (!topSpacer) {
topSpacer = document.createElement('tr');
topSpacer.id = 'vscroll-top';
// aria-hidden + visibility:hidden so Playwright/AT treat the sentinel as invisible
// while preserving its layout role (the inner <td> height drives virtual-scroll padding).
topSpacer.setAttribute('aria-hidden', 'true');
topSpacer.style.visibility = 'hidden';
topSpacer.innerHTML = '<td colspan="' + colCount + '" style="padding:0;border:0"></td>';
}
if (!bottomSpacer) {
bottomSpacer = document.createElement('tr');
bottomSpacer.id = 'vscroll-bottom';
bottomSpacer.setAttribute('aria-hidden', 'true');
bottomSpacer.style.visibility = 'hidden';
bottomSpacer.innerHTML = '<td colspan="' + colCount + '" style="padding:0;border:0"></td>';
}
@@ -2121,13 +1663,6 @@
}
}
if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: full rebuild %d entries, %.2fms', endIdx - startIdx, performance.now() - _rvr_t0);
_finalizePathOverflow(tbody);
// #1128 (Bug 1): hop-resolver mutates chip text from hex prefix to a
// longer node name AFTER the initial finalize pass — chips that fit at
// first measurement overflow once names resolve, but no `+N` pill gets
// appended. Cheapest correct fix: re-measure on a delayed pass, after
// clearing the per-host `overflowChecked` guard so the recheck runs.
_scheduleReFinalizePathOverflow(tbody);
return;
}
@@ -2161,150 +1696,6 @@
bottomSpacer.insertAdjacentHTML('beforebegin', html);
}
if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: incremental head=%d tail=%d, %.2fms', headRowCount, tailRowCount, performance.now() - _rvr_t0);
_finalizePathOverflow(tbody);
_scheduleReFinalizePathOverflow(tbody);
}
// #1124 (MAJOR-1): when path chips overflow `.path-hops` (capped at 22px /
// overflow:hidden in CSS), append a `<span class="path-overflow-pill">+N</span>`
// showing how many hops are hidden. Click opens a popover listing all hops.
function _finalizePathOverflow(tbody) {
if (!tbody) return;
var hosts = tbody.querySelectorAll('.path-hops');
for (var i = 0; i < hosts.length; i++) {
var host = hosts[i];
// Skip if already finalized for this content
if (host.dataset.overflowChecked === '1') continue;
var children = Array.prototype.slice.call(host.children);
// Strip any leftover pill before measuring
var existingPill = host.querySelector('.path-overflow-pill');
if (existingPill) existingPill.remove();
var hostRight = host.getBoundingClientRect().right;
if (!hostRight) continue;
var hidden = 0;
// Walk pairs of chip + arrow; count chips (not arrows) whose right edge
// is past the host's right edge.
for (var j = 0; j < children.length; j++) {
var ch = children[j];
if (ch.classList.contains('arrow')) continue;
var r = ch.getBoundingClientRect();
if (r.left >= hostRight || r.right > hostRight + 0.5) hidden++;
}
if (hidden > 0) {
var pill = document.createElement('span');
pill.className = 'path-overflow-pill';
pill.textContent = '+' + hidden;
pill.title = hidden + ' more hop' + (hidden === 1 ? '' : 's') + ' — click to view';
pill.setAttribute('role', 'button');
pill.setAttribute('tabindex', '0');
pill.setAttribute('aria-label', hidden + ' more hops');
host.appendChild(pill);
}
host.dataset.overflowChecked = '1';
}
}
// #1128 (Bug 1): re-run overflow finalize after hop-resolver async pass has
// had a chance to mutate chip text. Per-tbody so concurrent renders in
// different tbodies don't cancel each other (#1131 BLOCKER-2). Uses a
// MutationObserver bonded to the tbody to detect when hop-resolver finishes
// mutating .path-hops chip text, then runs finalize once mutations settle
// for 50ms — replaces the previous 120ms blind timeout, which regressed on
// slow networks where the resolver took longer than 120ms (#1131 MAJOR-1).
function _scheduleReFinalizePathOverflow(tbody) {
if (!tbody) return;
// If a quiesce timer is already armed for this tbody, leave it; new
// mutations will keep extending it. If an observer is already wired,
// we're done — it'll fire again on the next mutation.
if (tbody._rePathOverflowObserver) return;
var quiesceTimer = null;
var stopTimer = null;
function finalize() {
if (tbody._rePathOverflowObserver) {
try { tbody._rePathOverflowObserver.disconnect(); } catch (_e) {}
tbody._rePathOverflowObserver = null;
}
if (stopTimer) { clearTimeout(stopTimer); stopTimer = null; }
var hosts = tbody.querySelectorAll('.path-hops');
for (var i = 0; i < hosts.length; i++) hosts[i].dataset.overflowChecked = '';
_finalizePathOverflow(tbody);
}
if (typeof MutationObserver === 'function') {
var obs = new MutationObserver(function () {
if (quiesceTimer) clearTimeout(quiesceTimer);
quiesceTimer = setTimeout(finalize, 50);
});
obs.observe(tbody, { subtree: true, childList: true, characterData: true });
tbody._rePathOverflowObserver = obs;
// Hard upper bound — if hop-resolver never mutates (e.g. all chips
// already final), still run finalize once after a short delay so the
// overflow pill appears.
stopTimer = setTimeout(finalize, 1000);
} else {
// Fallback for environments without MutationObserver.
setTimeout(finalize, 120);
}
}
// Delegated click for path overflow pills — show popover of full path.
function _wirePathOverflowPopover() {
if (window.__pathOverflowWired) return;
window.__pathOverflowWired = true;
var existing = null;
function dismiss() {
if (existing) { existing.remove(); existing = null; }
document.removeEventListener('mousedown', onDoc, true);
document.removeEventListener('keydown', onKey, true);
}
function onDoc(ev) {
if (existing && !existing.contains(ev.target) && !ev.target.classList.contains('path-overflow-pill')) dismiss();
}
function onKey(ev) { if (ev.key === 'Escape') dismiss(); }
document.addEventListener('click', function(ev) {
var pill = ev.target.closest && ev.target.closest('.path-overflow-pill');
if (!pill) return;
ev.stopPropagation();
var host = pill.closest('.path-hops');
if (!host) return;
dismiss();
var pop = document.createElement('div');
pop.className = 'path-popover';
// Clone all children except the pill, preserving rendered chips/arrows.
var inner = '<div class="path-popover-title">Full path (' + (host.children.length) + ' items)</div><div>';
var kids = Array.prototype.slice.call(host.children);
for (var i = 0; i < kids.length; i++) {
if (kids[i].classList.contains('path-overflow-pill')) continue;
inner += kids[i].outerHTML;
}
inner += '</div>';
pop.innerHTML = inner;
document.body.appendChild(pop);
var r = pill.getBoundingClientRect();
// #1128 (Bug 2): position below by default, but flip ABOVE when there
// isn't enough room — keeps the popover anchored to the pill instead of
// hanging arbitrarily over adjacent rows / off-screen.
var pr0 = pop.getBoundingClientRect();
var popH = pr0.height;
var roomBelow = window.innerHeight - r.bottom;
var top;
if (roomBelow < popH + 12 && r.top > popH + 12) {
top = window.scrollY + r.top - popH - 4;
} else {
top = window.scrollY + r.bottom + 4;
}
var left = window.scrollX + r.left;
pop.style.top = top + 'px';
pop.style.left = left + 'px';
var pr = pop.getBoundingClientRect();
if (pr.right > window.innerWidth - 8) {
pop.style.left = Math.max(8, window.scrollX + window.innerWidth - pr.width - 8) + 'px';
}
existing = pop;
setTimeout(function() {
document.addEventListener('mousedown', onDoc, true);
document.addEventListener('keydown', onKey, true);
}, 0);
});
}
// Attach/detach scroll listener for virtual scrolling
@@ -2527,42 +1918,8 @@
}
renderTableRows();
const isMobileNow = window.innerWidth <= 640;
// #1168 review note: this branch is intentionally narrower than nodes.js /
// observers.js. On packets, ≤640 falls through to the legacy mobile bottom
// sheet (`isMobileNow` short-circuits before SlideOver), and SlideOver is
// used only for the 6411023 range. nodes.js and observers.js route into
// SlideOver across the full ≤1023 range. Both satisfy AC#4 ("not a
// separate page"); the per-page split is deliberate — the packets table
// has heavier per-row affordances (hex breakdown, observations grid)
// that the bottom sheet handles better at very narrow widths than a
// side-anchored slide-over. Do NOT "fix" the inconsistency without
// discussing with the issue author.
const useSlideOver = !isMobileNow && window.SlideOver && window.SlideOver.shouldUse();
let panel;
if (useSlideOver) {
// #1056 AC#4: narrow viewports (6411023) — open detail in slide-over
// overlay rather than the side panel.
panel = window.SlideOver.open({
title: hash ? ('Packet ' + String(hash).slice(0, 12)) : 'Packet detail',
// After close, the rows are re-rendered (see onClose). Use a resolver
// to look up the originating row in the post-render DOM by data-hash
// / data-id, so keyboard focus restores to the actual table row.
restoreFocus: function () {
const lookup = hash || id;
if (!lookup) return null;
const esc = (window.CSS && CSS.escape) ? CSS.escape(String(lookup)) : String(lookup);
return document.querySelector('#pktTable tbody tr[data-hash="' + esc + '"]')
|| document.querySelector('#pktTable tbody tr[data-id="' + esc + '"]');
},
onClose: function () {
selectedId = null;
selectedObservationId = null;
history.replaceState(null, '', '#/packets');
renderTableRows();
}
});
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
} else if (isMobileNow) {
if (isMobileNow) {
// Use mobile bottom sheet
let sheet = document.getElementById('mobileDetailSheet');
if (!sheet) {
@@ -2599,11 +1956,11 @@
const newHops = hops.filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
} catch {}
panel.innerHTML = isMobileNow ? '' : (useSlideOver ? '' : ('<div class="panel-resize-handle" id="pktResizeHandle"></div>' + PANEL_CLOSE_HTML));
panel.innerHTML = isMobileNow ? '' : '<div class="panel-resize-handle" id="pktResizeHandle"></div>' + PANEL_CLOSE_HTML;
const content = document.createElement('div');
panel.appendChild(content);
await renderDetail(content, data, selectedObservationId);
if (!isMobileNow && !useSlideOver) initPanelResize();
if (!isMobileNow) initPanelResize();
} catch (e) {
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
}
+2 -110
View File
@@ -13,12 +13,9 @@
const el = document.getElementById('perfContent');
if (!el) return;
try {
const [server, client, ioStats, sqliteStats, writeSources] = await Promise.all([
const [server, client] = await Promise.all([
fetch('/api/perf').then(r => r.json()),
Promise.resolve(window.apiPerf ? window.apiPerf() : null),
fetch('/api/perf/io').then(r => r.json()).catch(() => null),
fetch('/api/perf/sqlite').then(r => r.json()).catch(() => null),
fetch('/api/perf/write-sources').then(r => r.json()).catch(() => null)
Promise.resolve(window.apiPerf ? window.apiPerf() : null)
]);
// Also fetch health telemetry
@@ -67,111 +64,6 @@
}
}
// Disk I/O (#1120)
if (ioStats) {
const fmtRate = (bps) => {
if (bps >= 1048576) return (bps / 1048576).toFixed(1) + ' MB/s';
if (bps >= 1024) return (bps / 1024).toFixed(1) + ' KB/s';
return Math.round(bps) + ' B/s';
};
const writeWarn = ioStats.writeBytesPerSec > 10 * 1048576 ? ' ⚠️' : '';
const cancelled = ioStats.cancelledWriteBytesPerSec || 0;
// Cancelled writes warn at >1 MB/s — sustained cancellation usually
// means truncate/unlink racing with active writers (#1119-shaped bug).
const cancelledWarn = cancelled > 1048576 ? ' ⚠️' : '';
html += `<h3>Disk I/O (server process)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${fmtRate(ioStats.readBytesPerSec || 0)}</div><div class="perf-label">Read</div></div>
<div class="perf-card"><div class="perf-num">${fmtRate(ioStats.writeBytesPerSec || 0)}${writeWarn}</div><div class="perf-label">Write</div></div>
<div class="perf-card"><div class="perf-num">${fmtRate(cancelled)}${cancelledWarn}</div><div class="perf-label">Cancelled Write</div></div>
<div class="perf-card"><div class="perf-num">${Math.round(ioStats.syscallsRead || 0)}/s</div><div class="perf-label">Syscalls Read</div></div>
<div class="perf-card"><div class="perf-num">${Math.round(ioStats.syscallsWrite || 0)}/s</div><div class="perf-label">Syscalls Write</div></div>
</div>`;
// Ingestor row — sourced from ingestor's own /proc/self/io snapshot
// surfaced via the stats file (#1120: "Both ingestor and server").
if (ioStats.ingestor) {
const ing = ioStats.ingestor;
const ingWriteWarn = (ing.writeBytesPerSec || 0) > 10 * 1048576 ? ' ⚠️' : '';
const ingCancelled = ing.cancelledWriteBytesPerSec || 0;
const ingCancelledWarn = ingCancelled > 1048576 ? ' ⚠️' : '';
html += `<h3>Disk I/O (Ingestor process)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${fmtRate(ing.readBytesPerSec || 0)}</div><div class="perf-label">Read</div></div>
<div class="perf-card"><div class="perf-num">${fmtRate(ing.writeBytesPerSec || 0)}${ingWriteWarn}</div><div class="perf-label">Write</div></div>
<div class="perf-card"><div class="perf-num">${fmtRate(ingCancelled)}${ingCancelledWarn}</div><div class="perf-label">Cancelled Write</div></div>
<div class="perf-card"><div class="perf-num">${Math.round(ing.syscallsRead || 0)}/s</div><div class="perf-label">Syscalls Read</div></div>
<div class="perf-card"><div class="perf-num">${Math.round(ing.syscallsWrite || 0)}/s</div><div class="perf-label">Syscalls Write</div></div>
</div>`;
}
}
// Write Sources (#1120) — per-component counters from ingestor
if (writeSources && writeSources.sources) {
const src = writeSources.sources;
const keys = Object.keys(src).sort((a, b) => (src[b] || 0) - (src[a] || 0));
html += '<h3>Write Sources</h3>';
if (keys.length === 0) {
html += '<p style="color:var(--text-muted)">No ingestor stats yet (waiting for /tmp/corescope-ingestor-stats.json)</p>';
} else {
// Anomaly detection (#1123 polish):
// Compare PER-SECOND DELTA RATES, not cumulative counts.
// Cumulative-vs-cumulative was a tautology that fired ⚠️ at startup
// (any backfill_* > 10 when tx_inserted=0 → baseline collapses to 1)
// and false-cleared once tx grew past a one-shot backfill burst.
// Now we cache the previous snapshot + sampleAt and only fire when:
// 1) we have a real interval (≥ 0.5s) to compute deltas against
// 2) tx_inserted has crossed MIN_SAMPLE so the baseline is meaningful
// 3) the per-second backfill rate exceeds 10× the per-second tx rate
const MIN_SAMPLE = 100;
const prev = window._perfWriteSourcesPrev;
let prevSrc = null, dtSec = 0;
if (prev && prev.sampleAt && writeSources.sampleAt) {
dtSec = (Date.parse(writeSources.sampleAt) - Date.parse(prev.sampleAt)) / 1000;
if (dtSec >= 0.5) prevSrc = prev.sources;
}
const txTotal = src.tx_inserted || 0;
const txDelta = prevSrc ? (txTotal - (prevSrc.tx_inserted || 0)) : 0;
const txRate = (prevSrc && dtSec > 0) ? (txDelta / dtSec) : 0;
html += '<div style="overflow-x:auto"><table class="perf-table"><thead><tr><th scope="col">Source</th><th scope="col">Total</th><th scope="col">Rate/s</th><th scope="col">Anomaly</th></tr></thead><tbody>';
for (const k of keys) {
const v = src[k] || 0;
const isBackfill = k.startsWith('backfill_');
let rate = 0;
let flag = '';
if (prevSrc && dtSec > 0) {
const delta = v - (prevSrc[k] || 0);
rate = delta / dtSec;
// Only flag when tx baseline is statistically meaningful AND
// backfill is actively running faster than 10× the live tx rate.
if (isBackfill && txTotal >= MIN_SAMPLE && rate > 10 * Math.max(txRate, 1)) {
flag = ' ⚠️';
}
}
const rateStr = (prevSrc && dtSec > 0) ? rate.toFixed(1) : '—';
html += `<tr><td><code>${k}</code></td><td>${v.toLocaleString()}</td><td>${rateStr}</td><td>${flag}</td></tr>`;
}
html += '</tbody></table></div>';
// Stash for next tick's delta computation.
window._perfWriteSourcesPrev = { sources: { ...src }, sampleAt: writeSources.sampleAt };
if (writeSources.sampleAt) {
html += `<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Sampled: ${writeSources.sampleAt}</div>`;
}
}
}
// SQLite perf (separate from existing SQLite block — focused on WAL + cache hit) (#1120)
if (sqliteStats) {
const walMB = sqliteStats.walSizeMB || 0;
const walFlag = walMB > 100 ? ' ⚠️' : '';
const hitRate = (sqliteStats.cacheHitRate || 0) * 100;
const hitFlag = hitRate > 0 && hitRate < 90 ? ' ⚠️' : '';
html += `<h3>SQLite (WAL + Cache Hit)</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${walMB.toFixed(1)}MB${walFlag}</div><div class="perf-label">WAL Size</div></div>
<div class="perf-card"><div class="perf-num">${(sqliteStats.pageCount || 0).toLocaleString()}</div><div class="perf-label">Page Count</div></div>
<div class="perf-card"><div class="perf-num">${sqliteStats.pageSize || 0}</div><div class="perf-label">Page Size</div></div>
<div class="perf-card"><div class="perf-num">${hitRate.toFixed(1)}%${hitFlag}</div><div class="perf-label">Cache Hit Rate</div></div>
</div>`;
}
// Cache stats
if (server.cache) {
const c = server.cache;
+119
View File
@@ -0,0 +1,119 @@
/* === CoreScope — roles-page.js === */
'use strict';
(function () {
let refreshTimer = null;
function init(app) {
app.innerHTML =
'<div class="roles-page" data-page="roles">' +
' <div class="page-header">' +
' <h2>Roles</h2>' +
' <button class="btn-icon" data-action="roles-refresh" title="Refresh" aria-label="Refresh roles">🔄</button>' +
' </div>' +
' <p class="text-muted" style="margin:0 0 12px 0">Distribution of node roles across the mesh, with per-role clock-skew posture.</p>' +
' <div id="rolesContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>' +
'</div>';
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action="roles-refresh"]');
if (btn) load();
});
load();
refreshTimer = setInterval(load, 60000);
}
function destroy() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
}
async function load() {
var container = document.getElementById('rolesContent');
if (!container) return;
try {
var resp = await fetch('/api/analytics/roles');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
render(container, data);
} catch (err) {
container.innerHTML = '<div class="text-center" style="padding:40px;color:var(--color-error,#c00)">Failed to load roles: ' + escapeHtml(String(err.message || err)) + '</div>';
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function fmtSec(v) {
if (!v && v !== 0) return '—';
var abs = Math.abs(v);
if (abs < 1) return v.toFixed(2) + 's';
if (abs < 60) return v.toFixed(1) + 's';
if (abs < 3600) return (v / 60).toFixed(1) + 'm';
if (abs < 86400) return (v / 3600).toFixed(1) + 'h';
return (v / 86400).toFixed(1) + 'd';
}
function roleEmoji(role) {
if (window.ROLE_EMOJI && window.ROLE_EMOJI[role]) return window.ROLE_EMOJI[role];
return '•';
}
function render(container, data) {
var roles = (data && data.roles) || [];
var total = (data && data.totalNodes) || 0;
if (roles.length === 0) {
container.innerHTML = '<div class="text-center text-muted" style="padding:40px">No roles to show.</div>';
return;
}
var maxCount = roles.reduce(function (m, r) { return Math.max(m, r.nodeCount || 0); }, 0) || 1;
var rows = roles.map(function (r) {
var pct = total > 0 ? ((r.nodeCount / total) * 100).toFixed(1) : '0.0';
var barW = Math.round((r.nodeCount / maxCount) * 100);
var sevCells =
'<span title="OK (skew &lt; 5min)" style="color:var(--color-success,#0a0)">' + (r.okCount || 0) + '</span> / ' +
'<span title="Warning (5min 1h)" style="color:var(--color-warning,#e80)">' + (r.warningCount || 0) + '</span> / ' +
'<span title="Critical (1h 30d)" style="color:var(--color-error,#c00)">' + (r.criticalCount || 0) + '</span> / ' +
'<span title="Absurd (&gt; 30d)" style="color:#a0a">' + (r.absurdCount || 0) + '</span> / ' +
'<span title="No clock (&gt; 365d)" style="color:#888">' + (r.noClockCount || 0) + '</span>';
return '' +
'<tr data-role="' + escapeHtml(r.role) + '">' +
'<td>' + roleEmoji(r.role) + ' <strong>' + escapeHtml(r.role) + '</strong></td>' +
'<td style="text-align:right">' + r.nodeCount + '</td>' +
'<td style="text-align:right">' + pct + '%</td>' +
'<td style="min-width:140px">' +
'<div style="background:var(--color-surface-2,#eee);height:10px;border-radius:5px;overflow:hidden">' +
'<div style="background:var(--color-accent,#06c);width:' + barW + '%;height:100%"></div>' +
'</div>' +
'</td>' +
'<td style="text-align:right">' + (r.withSkew || 0) + '</td>' +
'<td style="text-align:right">' + fmtSec(r.medianAbsSkewSec || 0) + '</td>' +
'<td style="text-align:right">' + fmtSec(r.meanAbsSkewSec || 0) + '</td>' +
'<td style="white-space:nowrap">' + sevCells + '</td>' +
'</tr>';
}).join('');
container.innerHTML =
'<div class="roles-summary" style="margin-bottom:12px;color:var(--color-text-muted,#666)">' +
'<strong>' + total + '</strong> nodes across <strong>' + roles.length + '</strong> roles' +
'</div>' +
'<table id="rolesTable" class="data-table" style="width:100%">' +
'<thead><tr>' +
'<th>Role</th>' +
'<th style="text-align:right">Count</th>' +
'<th style="text-align:right">Share</th>' +
'<th>Distribution</th>' +
'<th style="text-align:right" title="Nodes with clock-skew samples">w/ Skew</th>' +
'<th style="text-align:right" title="Median absolute skew">Median |skew|</th>' +
'<th style="text-align:right" title="Mean absolute skew">Mean |skew|</th>' +
'<th title="OK / Warning / Critical / Absurd / No-clock">Severity</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
registerPage('roles', { init: init, destroy: destroy });
})();
+73 -893
View File
File diff suppressed because it is too large Load Diff
-455
View File
@@ -1,455 +0,0 @@
/* public/touch-gestures.js Gesture system for #1062.
*
* Three gestures for narrow viewports (768px):
* 1. Swipe-LEFT on a packets/nodes/observers row reveal row-action overlay.
* 2. Horizontal swipe on the bottom-nav strip advance tabs in TAB order.
* 3. Swipe-DOWN on a slide-over panel close it.
*
* Hard rules (per #1062 brief):
* - Pointer Events ONLY (no touchstart/touchend mixing). setPointerCapture.
* - Axis-lock: commit to one axis in the first 812px; vertical scroll never
* blocked unless we explicitly committed to a horizontal swipe.
* - Leaflet exclusion: bail if e.target.closest('.leaflet-container').
* - Threshold: row-action triggers only at 24% of row width OR 80px swiped.
* - touch-action: body { touch-action: pan-y } so browser owns vertical
* scroll natively. [data-bottom-nav] gets touch-action: none.
* - Singleton + cleanup: module-scoped guard, document-level listeners
* registered ONCE (mirrors the #1180 MQL leak fix class).
* - prefers-reduced-motion: animations disabled (CSS handles this), gesture
* still works.
*/
(function () {
'use strict';
if (typeof window === 'undefined' || typeof document === 'undefined') return;
// ── Singleton guard (matches #1180 pattern) ──
if (typeof window.__touchGestures1062InitCount !== 'number') {
window.__touchGestures1062InitCount = 0;
}
if (window.__touchGestures1062InitCount > 0) {
// Already initialized — never re-register document listeners.
return;
}
window.__touchGestures1062InitCount += 1;
// ── Tunables ──
var AXIS_LOCK_DISTANCE = 10; // px before we commit to an axis (812 range)
var ROW_ACTION_PX = 80; // absolute px threshold
var ROW_ACTION_PCT = 0.24; // OR 24% of row width
var SLIDE_OVER_DISMISS_PX = 100; // downward swipe to dismiss slide-over
var TAB_SWIPE_PX = 60; // horizontal swipe on bottom-nav strip
var NARROW_BP = 768; // gestures only matter on phones
// ── Module state ──
var pointerActive = false;
var pointerId = null;
var startX = 0, startY = 0;
var lastX = 0, lastY = 0;
var axis = null; // 'h' | 'v' | null
var startTarget = null;
var gestureContext = null; // 'row' | 'bottom-nav' | 'slide-over' | null
var activeRow = null;
var rowOverlay = null;
var capturedEl = null;
// PR #1185 mesh-op review: scroll-discriminator for slide-over.
// Captured at pointerdown when the slide-over context is selected; if the
// panel content is mid-scroll (scrollTop > 0) at gesture start, the gesture
// is a normal scroll, NOT a dismiss — we must not close the panel.
var slideOverScroller = null;
var slideOverStartScrollTop = 0;
function isNarrow() {
return window.innerWidth <= NARROW_BP;
}
function inLeaflet(target) {
return !!(target && target.closest && target.closest('.leaflet-container'));
}
function findRow(target) {
if (!target || !target.closest) return null;
// Packets/nodes/observers tables — generic: any tr inside a tbody whose
// table is inside one of the relevant pages.
var tr = target.closest('tr[data-hash], tr[data-id]');
if (!tr) return null;
var tbody = tr.closest('tbody');
if (!tbody) return null;
// Restrict to the three target tables. id="pktBody" for packets,
// and we treat any tbody inside .nodes-table / .observers-table as eligible.
if (tbody.id === 'pktBody') return tr;
var table = tbody.closest('table');
if (table && (table.id === 'nodesTable' || table.id === 'observersTable' ||
table.classList.contains('nodes-table') ||
table.classList.contains('observers-table'))) {
return tr;
}
return tr; // permissive — still skip leaflet via inLeaflet().
}
function findBottomNav(target) {
if (!target || !target.closest) return null;
return target.closest('[data-bottom-nav]');
}
function findSlideOver(target) {
if (!target || !target.closest) return null;
return target.closest('.slide-over-panel');
}
// Locate the open slide-over panel by querying the DOM (not via target
// ancestry). Used as a fallback when the pointerdown's hit-test target
// is something outside the panel subtree (e.g. a focused button whose
// event was retargeted, or a panel mid-animation where elementFromPoint
// returned an unrelated element). Pairs the lookup with a coordinate
// check so we don't claim slide-over context for taps elsewhere.
function findOpenSlideOverAt(x, y) {
if (!window.SlideOver || typeof window.SlideOver.isOpen !== 'function') return null;
if (!window.SlideOver.isOpen()) return null;
var panel = document.querySelector('.slide-over-panel');
if (!panel || panel.hidden) return null;
var r = panel.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return null;
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return panel;
return null;
}
// ── Bottom-nav: read TAB order from bottom-nav.js ──
// The TAB list there is module-private; we re-derive order from the rendered
// DOM (which IS the source of truth for what the user sees) — primary tabs only,
// i.e. excluding "more".
function getNavTabsInOrder() {
var nodes = document.querySelectorAll('[data-bottom-nav] [data-bottom-nav-tab]');
var out = [];
for (var i = 0; i < nodes.length; i++) {
var r = nodes[i].getAttribute('data-bottom-nav-tab');
if (r && r !== 'more') out.push(r);
}
return out;
}
function currentRouteShort() {
var h = (location.hash || '').replace(/^#\//, '');
if (!h) return 'packets';
var slash = h.indexOf('/');
if (slash >= 0) h = h.substring(0, slash);
var q = h.indexOf('?');
if (q >= 0) h = h.substring(0, q);
return h || 'packets';
}
function navigateRelative(delta) {
var tabs = getNavTabsInOrder();
if (!tabs.length) return;
var cur = currentRouteShort();
var idx = tabs.indexOf(cur);
if (idx < 0) return; // current route isn't a primary tab
var next = idx + delta;
if (next < 0 || next >= tabs.length) return;
location.hash = '#/' + tabs[next];
}
// ── Row-action overlay ──
function ensureRowOverlay(row) {
if (rowOverlay && rowOverlay.parentNode) return rowOverlay;
var o = document.createElement('div');
o.className = 'row-action-overlay';
o.setAttribute('role', 'group');
o.setAttribute('aria-label', 'Row actions');
var hash = row.getAttribute('data-hash') || row.getAttribute('data-id') || '';
o.innerHTML =
'<button type="button" class="row-action-btn" data-row-action="trace">Trace</button>' +
'<button type="button" class="row-action-btn" data-row-action="filter">Filter</button>' +
'<button type="button" class="row-action-btn" data-row-action="copy" data-hash="' +
String(hash).replace(/"/g, '&quot;') + '">Copy hash</button>';
document.body.appendChild(o);
rowOverlay = o;
return o;
}
function showRowOverlay(row) {
var o = ensureRowOverlay(row);
var rect = row.getBoundingClientRect();
o.style.position = 'fixed';
o.style.top = rect.top + 'px';
o.style.left = (rect.right - 240) + 'px';
o.style.height = rect.height + 'px';
o.style.width = '240px';
o.classList.add('row-action-overlay-open');
o.hidden = false;
}
function dismissRowAction() {
if (rowOverlay) {
rowOverlay.classList.remove('row-action-overlay-open');
// Remove from DOM after animation; CSS handles instant under reduce.
var el = rowOverlay;
rowOverlay = null;
try {
if (el.parentNode) el.parentNode.removeChild(el);
} catch (_) {}
}
if (activeRow) {
activeRow.style.transform = '';
activeRow.classList.remove('row-swiping');
activeRow = null;
}
}
// ── Pointer handlers ──
function onPointerDown(e) {
if (e.pointerType !== 'touch') return;
if (pointerActive) return;
var t = e.target;
if (inLeaflet(t)) return;
if (!isNarrow()) return;
var row = findRow(t);
var nav = findBottomNav(t);
var so = findSlideOver(t) || findOpenSlideOverAt(e.clientX, e.clientY);
if (so) gestureContext = 'slide-over';
else if (nav) gestureContext = 'bottom-nav';
else if (row) gestureContext = 'row';
else gestureContext = null;
if (!gestureContext) return;
pointerActive = true;
pointerId = e.pointerId;
startX = lastX = e.clientX;
startY = lastY = e.clientY;
axis = null;
startTarget = t;
activeRow = (gestureContext === 'row') ? row : null;
// Slide-over scroll-discriminator (PR #1185): record where the user is
// reading from. The slide-over panel itself is the scroller (CSS sets
// `.slide-over-panel { overflow-y: auto; }`) — `.slide-over-content` is a
// flex child without its own overflow-y, so its scrollTop is always 0.
// To be robust against markup/CSS drift, walk every candidate (panel +
// any inner `.slide-over-content`) and take the MAX scrollTop. Whichever
// element actually scrolls becomes the discriminator source — this
// guarantees production reads from the same element a test (or a future
// refactor) writes to.
if (gestureContext === 'slide-over') {
var candidates = [];
if (so) candidates.push(so);
var inner = so && so.querySelector && so.querySelector('.slide-over-content');
if (inner) candidates.push(inner);
slideOverScroller = so || null;
slideOverStartScrollTop = 0;
for (var i = 0; i < candidates.length; i++) {
var st = (candidates[i] && typeof candidates[i].scrollTop === 'number')
? candidates[i].scrollTop : 0;
if (st > slideOverStartScrollTop) {
slideOverStartScrollTop = st;
slideOverScroller = candidates[i];
}
}
} else {
slideOverScroller = null;
slideOverStartScrollTop = 0;
}
// Capture so subsequent move events flow to us regardless of element.
try {
var capTarget = (gestureContext === 'bottom-nav') ? nav :
(gestureContext === 'slide-over') ? so :
row || t;
if (capTarget && typeof capTarget.setPointerCapture === 'function') {
capTarget.setPointerCapture(pointerId);
capturedEl = capTarget;
}
} catch (_) { capturedEl = null; }
}
function onPointerMove(e) {
if (!pointerActive || e.pointerId !== pointerId) return;
var dx = e.clientX - startX;
var dy = e.clientY - startY;
lastX = e.clientX;
lastY = e.clientY;
if (axis === null) {
var adx = Math.abs(dx), ady = Math.abs(dy);
if (adx < AXIS_LOCK_DISTANCE && ady < AXIS_LOCK_DISTANCE) return;
// For slide-over, dismiss on vertical down swipe; commit accordingly.
if (gestureContext === 'slide-over') {
axis = (ady > adx) ? 'v' : 'h';
if (axis !== 'v') {
// Horizontal on slide-over — release, do nothing.
releasePointer();
return;
}
// Scroll-discriminator (PR #1185): if user started mid-scroll, this
// gesture belongs to the browser's native scroll. Release immediately
// so we never preventDefault / drag the panel / dismiss.
if (slideOverStartScrollTop > 0) {
releasePointer();
return;
}
} else if (gestureContext === 'bottom-nav') {
axis = (adx > ady) ? 'h' : 'v';
if (axis !== 'h') { releasePointer(); return; }
} else if (gestureContext === 'row') {
axis = (adx > ady) ? 'h' : 'v';
if (axis !== 'h') {
// Vertical → release; let browser handle scroll.
releasePointer();
return;
}
}
}
// Apply visual feedback only after axis commit.
if (gestureContext === 'row' && axis === 'h' && activeRow) {
// Only show the peek for left-swipes (reveal action panel on right side).
if (dx < 0) {
activeRow.classList.add('row-swiping');
activeRow.style.transform = 'translateX(' + Math.max(dx, -240) + 'px)';
} else {
activeRow.style.transform = '';
}
// Prevent default so the browser doesn't start a text-selection drag.
if (e.cancelable) { try { e.preventDefault(); } catch (_) {} }
} else if (gestureContext === 'bottom-nav' && axis === 'h') {
if (e.cancelable) { try { e.preventDefault(); } catch (_) {} }
} else if (gestureContext === 'slide-over' && axis === 'v') {
if (dy > 0) {
// Drag panel down with the finger.
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
if (so) {
so.style.transform = 'translateY(' + dy + 'px)';
}
}
if (e.cancelable) { try { e.preventDefault(); } catch (_) {} }
}
}
function onPointerUp(e) {
if (!pointerActive || e.pointerId !== pointerId) return;
var dx = e.clientX - startX;
var dy = e.clientY - startY;
try {
if (gestureContext === 'row' && axis === 'h' && activeRow) {
var rowRect = activeRow.getBoundingClientRect();
var threshold = Math.min(ROW_ACTION_PX, rowRect.width * ROW_ACTION_PCT);
if (dx < 0 && Math.abs(dx) >= threshold) {
// Commit — show overlay, snap row back.
activeRow.style.transform = '';
activeRow.classList.remove('row-swiping');
showRowOverlay(activeRow);
activeRow = null; // overlay owns lifecycle now
} else {
// Snap back.
activeRow.style.transform = '';
activeRow.classList.remove('row-swiping');
activeRow = null;
}
} else if (gestureContext === 'bottom-nav' && axis === 'h') {
if (dx <= -TAB_SWIPE_PX) {
// Drag content leftward → next tab.
navigateRelative(+1);
} else if (dx >= TAB_SWIPE_PX) {
navigateRelative(-1);
}
} else if (gestureContext === 'slide-over' && axis === 'v') {
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
if (so) so.style.transform = '';
// Scroll-discriminator (PR #1185): if the user started mid-scroll,
// never dismiss — onPointerMove should already have released, this
// is a defense-in-depth guard.
if (slideOverStartScrollTop > 0) {
// no-op
} else if (dy >= SLIDE_OVER_DISMISS_PX && window.SlideOver && typeof window.SlideOver.close === 'function') {
try { window.SlideOver.close(); } catch (_) {}
}
}
} finally {
releasePointer();
}
}
function onPointerCancel(e) {
if (!pointerActive || e.pointerId !== pointerId) return;
if (activeRow) {
activeRow.style.transform = '';
activeRow.classList.remove('row-swiping');
activeRow = null;
}
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
if (so) so.style.transform = '';
releasePointer();
}
// Browser may steal pointer capture (e.g. orientation change, parent
// scroll start, focus change). When that happens neither pointerup nor
// pointercancel are guaranteed — we'd leak state and visuals. Treat
// lost-capture identically to cancel.
function onPointerLostCapture(e) {
if (!pointerActive || e.pointerId !== pointerId) return;
if (activeRow) {
activeRow.style.transform = '';
activeRow.classList.remove('row-swiping');
activeRow = null;
}
var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel');
if (so) so.style.transform = '';
releasePointer();
}
function releasePointer() {
try {
if (capturedEl && pointerId != null && typeof capturedEl.releasePointerCapture === 'function') {
capturedEl.releasePointerCapture(pointerId);
}
} catch (_) {}
pointerActive = false;
pointerId = null;
axis = null;
startTarget = null;
capturedEl = null;
gestureContext = null;
slideOverScroller = null;
slideOverStartScrollTop = 0;
}
// ── Row-overlay click delegation ──
function onClickAction(e) {
var btn = e.target && e.target.closest && e.target.closest('.row-action-btn');
if (!btn) {
// Click outside overlay dismisses it.
if (rowOverlay && !(e.target.closest && e.target.closest('.row-action-overlay'))) {
dismissRowAction();
}
return;
}
var action = btn.getAttribute('data-row-action');
var hash = btn.getAttribute('data-hash') || '';
if (action === 'copy' && hash && navigator.clipboard) {
try { navigator.clipboard.writeText(hash); } catch (_) {}
} else if (action === 'filter' && hash) {
location.hash = '#/packets?hash=' + encodeURIComponent(hash);
} else if (action === 'trace' && hash) {
location.hash = '#/packets/' + encodeURIComponent(hash);
}
dismissRowAction();
}
// ── Register listeners ONCE at document level ──
// passive:false on move/up so we can preventDefault when we own the axis.
document.addEventListener('pointerdown', onPointerDown, { passive: true });
document.addEventListener('pointermove', onPointerMove, { passive: false });
document.addEventListener('pointerup', onPointerUp, { passive: true });
document.addEventListener('pointercancel', onPointerCancel, { passive: true });
document.addEventListener('lostpointercapture', onPointerLostCapture, { passive: true });
document.addEventListener('click', onClickAction, true);
// Public API used by tests / future callers.
window.TouchGestures = {
dismissRowAction: dismissRowAction,
_navigateRelative: navigateRelative,
};
})();
-131
View File
@@ -1,131 +0,0 @@
#!/usr/bin/env node
/*
* scripts/check-css-vars.js issue #1128 (audit Section 5 #1)
*
* Walks every public/*.css file (definitions + uses) AND every
* public/**\/*.{js,html} file (uses only) and asserts that every
* `var(--name)` reference WITHOUT a fallback resolves to a `--name:`
* definition in SOME public/*.css file.
*
* Why JS/HTML are scanned: the original Bug 4 in #1128 came from
* filter-ux.js shipping `style="background: var(--surface)"` while
* --surface was undefined. CSS-only scanning misses inline styles
* embedded in JS template literals and HTML attributes.
*
* References WITH a fallback like `var(--maybe, var(--always))` are
* tolerated; the fallback chain keeps them safe. Definitions still
* only come from CSS files (JS/HTML cannot define custom properties
* without runtime parsing we do not attempt).
*
* Exit code: 0 = clean, 1 = one or more undefined vars (with locations).
*
* Usage:
* node scripts/check-css-vars.js # default (lints public/)
* node scripts/check-css-vars.js --dir x # lint a different directory
*/
'use strict';
const fs = require('fs');
const path = require('path');
let dir = 'public';
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i] === '--dir' && process.argv[i + 1]) { dir = process.argv[++i]; }
}
if (!fs.existsSync(dir)) {
console.error('check-css-vars: directory not found: ' + dir);
process.exit(2);
}
// Recursively walk dir, returning files matching one of the given extensions.
// Skips node_modules and any vendor/ directory by name to keep the lint fast
// and focused on first-party code.
const SKIP_DIRS = new Set(['node_modules', 'vendor', '.git']);
function walk(root, exts) {
const out = [];
const stack = [root];
while (stack.length) {
const cur = stack.pop();
let entries;
try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
catch (e) { continue; }
for (const ent of entries) {
const full = path.join(cur, ent.name);
if (ent.isDirectory()) {
if (SKIP_DIRS.has(ent.name)) continue;
stack.push(full);
} else if (ent.isFile()) {
const ext = path.extname(ent.name).toLowerCase();
if (exts.includes(ext)) out.push(full);
}
}
}
return out;
}
const cssFiles = walk(dir, ['.css']);
const codeFiles = walk(dir, ['.js', '.html', '.htm']);
if (!cssFiles.length) {
console.error('check-css-vars: no .css files found in ' + dir);
process.exit(2);
}
const defined = new Set();
const uses = []; // { file, line, name }
const defRe = /(?:^|[^a-zA-Z0-9_-])(--[a-zA-Z0-9_-]+)\s*:/g;
// Match var(--name) ONLY when the closing ')' immediately follows the name
// (optional whitespace). Anything else (a comma → fallback) is exempt.
const useRe = /var\(\s*(--[a-zA-Z0-9_-]+)\s*\)/g;
function scanCss(f) {
// Strip /* ... */ comments before scanning so doc-blocks that mention
// var(--name) as prose don't trigger false positives. Replace each
// comment span with whitespace to keep line numbers stable.
const raw = fs.readFileSync(f, 'utf8');
const stripped = raw.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
const lines = stripped.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let m;
defRe.lastIndex = 0;
while ((m = defRe.exec(line)) !== null) defined.add(m[1]);
useRe.lastIndex = 0;
while ((m = useRe.exec(line)) !== null) uses.push({ file: f, line: i + 1, name: m[1] });
}
}
function scanCode(f) {
// For JS / HTML we collect USES only. We also strip /* */ and //
// line comments (JS) and <!-- --> (HTML) so doc prose mentioning
// var(--name) doesn't false-positive. Definitions in JS/HTML are
// not supported (would require runtime parsing).
let raw = fs.readFileSync(f, 'utf8');
raw = raw.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
raw = raw.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
// Strip // line comments (best-effort; harmless if it nicks a URL since
// we only care about var(--…) tokens that follow on the same line).
raw = raw.replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + ' '.repeat(m.length - p1.length));
const lines = raw.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let m;
useRe.lastIndex = 0;
while ((m = useRe.exec(line)) !== null) uses.push({ file: f, line: i + 1, name: m[1] });
}
}
for (const f of cssFiles) scanCss(f);
for (const f of codeFiles) scanCode(f);
const undef = uses.filter(u => !defined.has(u.name));
if (undef.length) {
console.error('check-css-vars: ' + undef.length + ' undefined CSS variable reference(s):');
for (const u of undef) console.error(' ' + u.file + ':' + u.line + ' var(' + u.name + ')');
console.error('Fix: define the variable in :root, or use var(' + undef[0].name + ', <fallback>).');
process.exit(1);
}
console.log('check-css-vars: OK — ' + uses.length + ' var() refs across ' +
(cssFiles.length + codeFiles.length) + ' files (' + cssFiles.length + ' css, ' +
codeFiles.length + ' js/html), ' + defined.size + ' definitions, 0 undefined.');
-27
View File
@@ -7,31 +7,4 @@ cp public/*.css public-instrumented/ 2>/dev/null
cp public/*.html public-instrumented/ 2>/dev/null
cp public/*.svg public-instrumented/ 2>/dev/null
cp public/*.png public-instrumented/ 2>/dev/null
# Copy nested asset directories (e.g. public/img/*.svg used by the new
# CoreScope logo + hero). nyc instrument skips non-JS subdirs entirely,
# so without this the SPA fallback would serve index.html for
# `/img/corescope-logo.svg`, breaking the navbar logo + the
# logo-rebrand E2E (the content-type assertion catches this cleanly).
if [ -d public/img ]; then
mkdir -p public-instrumented/img
cp -r public/img/. public-instrumented/img/
fi
# Copy webfonts (e.g. public/fonts/aldrich-regular.woff2 used by the
# navbar logo SVG @font-face, #1137 follow-up). Same SPA-fallback gotcha
# as /img — without this, GET /fonts/aldrich-regular.woff2 returns
# index.html and the @font-face download fails silently, so the logo
# falls back to monospace and the Aldrich E2E assertion fails.
if [ -d public/fonts ]; then
mkdir -p public-instrumented/fonts
cp -r public/fonts/. public-instrumented/fonts/
fi
# Copy vendored libraries unmodified — `nyc instrument` skips subdirectories
# without a package.json, so vendor/qrcode.js, vendor/jsqr.min.js, etc. are
# never emitted into public-instrumented/. Without them the SPA fallback
# returns index.html for `<script src="vendor/qrcode.js">`, producing
# "Unexpected token '<'" pageerrors and a missing `qrcode` global —
# which makes the QR Generate path hit the "[QR library not loaded]"
# fallback in channel-qr.js (issue #1087 bug 1 manifests in CI only).
mkdir -p public-instrumented/vendor
cp public/vendor/* public-instrumented/vendor/ 2>/dev/null
echo "Frontend instrumented successfully"
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env node
/*
* scripts/test-check-css-vars.js gates scripts/check-css-vars.js
*
* Confirms the JS/HTML extension (#1128 followup M3) catches an
* undefined CSS variable reference embedded in a JS template literal.
*
* Strategy: write a tmp file under a tmp dir alongside one CSS file
* (so the lint has a defined-vars source set) plus one JS fixture
* containing `var(--definitely-undefined-xyz)`. Spawn the lint, assert
* exit code 1, assert the offending var is named in stderr.
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'check-css-vars-test-'));
const lintPath = path.join(__dirname, 'check-css-vars.js');
let pass = 0, fail = 0;
function check(name, cond, info) {
if (cond) { console.log('PASS', name); pass++; }
else { console.error('FAIL', name, info || ''); fail++; }
}
try {
// 1. Baseline: a CSS file that defines --foo, a JS file that uses it.
fs.writeFileSync(path.join(tmp, 'site.css'), ':root { --foo: red; }\n.x { color: var(--foo); }\n');
fs.writeFileSync(path.join(tmp, 'app.js'), 'const s = `<div style="color:var(--foo)">x</div>`;\n');
let r = spawnSync('node', [lintPath, '--dir', tmp], { encoding: 'utf8' });
check('clean tree exits 0', r.status === 0, r.stderr || r.stdout);
// 2. Add a JS file with an undefined var; lint MUST exit 1 and name it.
fs.writeFileSync(path.join(tmp, 'broken.js'),
'const s = `<span style="background: var(--definitely-undefined-xyz)">x</span>`;\n');
r = spawnSync('node', [lintPath, '--dir', tmp], { encoding: 'utf8' });
check('JS-side undefined var → exit 1', r.status === 1, 'got status=' + r.status);
check('error names the offending var',
/--definitely-undefined-xyz/.test(r.stderr),
'stderr=' + r.stderr);
check('error names the offending file',
/broken\.js/.test(r.stderr),
'stderr=' + r.stderr);
// 3. HTML inline style is also caught.
fs.unlinkSync(path.join(tmp, 'broken.js'));
fs.writeFileSync(path.join(tmp, 'page.html'),
'<!doctype html><div style="color: var(--also-undefined-html)">x</div>\n');
r = spawnSync('node', [lintPath, '--dir', tmp], { encoding: 'utf8' });
check('HTML-side undefined var → exit 1', r.status === 1, 'got status=' + r.status);
check('html error names offending var',
/--also-undefined-html/.test(r.stderr), r.stderr);
// 4. Fallback form is tolerated.
fs.unlinkSync(path.join(tmp, 'page.html'));
fs.writeFileSync(path.join(tmp, 'safe.js'),
'const s = `<div style="color:var(--maybe-undef, red)">x</div>`;\n');
r = spawnSync('node', [lintPath, '--dir', tmp], { encoding: 'utf8' });
check('fallback var() form → exit 0', r.status === 0, r.stderr || r.stdout);
} finally {
// Cleanup tmp dir.
for (const f of fs.readdirSync(tmp)) fs.unlinkSync(path.join(tmp, f));
fs.rmdirSync(tmp);
}
console.log('\n=== test-check-css-vars: ' + pass + ' pass, ' + fail + ' fail ===');
process.exit(fail > 0 ? 1 : 0);
-109
View File
@@ -1,109 +0,0 @@
/**
* Issue #1110 E2E Live page node filter (autocomplete + theming).
* Standalone runner so it can be exercised independently of the
* full test-e2e-playwright.js suite. Mirrors the same assertions
* that have been added to test-e2e-playwright.js.
*
* Usage:
* BASE_URL=http://localhost:13581 node test-1110-live-filter.js
*/
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let pass = 0, fail = 0, failed = [];
function assert(cond, msg) { if (!cond) throw new Error(msg || 'assert'); }
async function test(name, fn) {
try {
await fn();
console.log(`${name}`);
pass++;
} catch (e) {
console.log(`${name}: ${e.message}`);
failed.push({ name, err: e.message });
fail++;
}
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
console.log(`#1110 Live filter E2E against ${BASE}`);
await test('input matches toolbar styling (theme-aware bg, comparable height)', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const bg = await page.$eval('#liveNodeFilterInput', el => getComputedStyle(el).backgroundColor);
assert(bg !== 'rgb(255, 255, 255)' && bg !== '#ffffff' && bg !== 'white',
`bg should not be hardcoded white in dark mode, got ${bg}`);
const inputH = await page.$eval('#liveNodeFilterInput', el => el.getBoundingClientRect().height);
// Compare against an adjacent toolbar control rather than bare checkbox
// labels (the global a11y rule enforces 48px min-height on text inputs).
// The `#liveFavoritesToggle` checkbox lives in the same .live-toggles row
// and its parent <label> is a reasonable proxy for the toolbar's row
// height once the input respects toolbar styling. We allow up to 40px of
// slop so this test focuses on "not absurdly large" rather than pixel-perfect.
const labelH = await page.$eval('.live-toggles label', el => el.getBoundingClientRect().height);
assert(inputH > 0 && labelH > 0, `expected non-zero heights (input=${inputH}, label=${labelH})`);
assert(inputH <= Math.max(labelH + 40, 56),
`input height (${inputH}) should not be vastly larger than toolbar label (${labelH})`);
});
await test('typing shows autocomplete dropdown', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => { try { localStorage.removeItem('live-node-filter'); } catch (_) {} });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
await page.fill('#liveNodeFilterInput', '');
await page.type('#liveNodeFilterInput', 'te', { delay: 30 });
await page.waitForSelector('#liveNodeFilterDropdown:not(.hidden) .live-node-filter-option', { timeout: 5000 });
const n = await page.$$eval('#liveNodeFilterDropdown .live-node-filter-option', els => els.length);
assert(n >= 1, `expected >=1 suggestion, got ${n}`);
});
await test('clicking suggestion filters without reload', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => { try { localStorage.removeItem('live-node-filter'); } catch (_) {} });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
await page.evaluate(() => { window.__m = 'still-here'; });
await page.fill('#liveNodeFilterInput', '');
await page.type('#liveNodeFilterInput', 'te', { delay: 30 });
await page.waitForSelector('#liveNodeFilterDropdown:not(.hidden) .live-node-filter-option', { timeout: 5000 });
await page.click('#liveNodeFilterDropdown .live-node-filter-option');
const m = await page.evaluate(() => window.__m);
assert(m === 'still-here', 'page must not reload');
assert(page.url().includes('#/live'), `URL should still target #/live, got ${page.url()}`);
const keys = await page.evaluate(() => (window._liveGetNodeFilterKeys ? window._liveGetNodeFilterKeys() : []));
assert(Array.isArray(keys) && keys.length >= 1, `filter active after click, got ${JSON.stringify(keys)}`);
});
await test('Enter does not reload or navigate', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => { try { localStorage.removeItem('live-node-filter'); } catch (_) {} });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
await page.evaluate(() => { window.__m2 = 'still-here'; });
const urlBefore = page.url();
await page.fill('#liveNodeFilterInput', 'te');
await page.focus('#liveNodeFilterInput');
await page.keyboard.press('Enter');
await page.waitForTimeout(250);
const m = await page.evaluate(() => window.__m2);
assert(m === 'still-here', 'Enter must not reload page');
assert(page.url() === urlBefore || page.url().includes('#/live'),
`URL must not navigate away, got ${page.url()} (was ${urlBefore})`);
});
await browser.close();
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} failed` : ''}`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(2); });
-2
View File
@@ -17,12 +17,10 @@ node test-url-state.js
node test-perf-go-runtime.js
node test-channel-psk-ux.js
node test-channel-sidebar-layout.js
node test-channel-fluid-layout.js
node test-channel-modal-ux.js
node test-channel-decrypt-insecure-context.js
node test-channel-qr.js
node test-channel-qr-wiring.js
node test-channel-issue-1087.js
node test-analytics-channels-integration.js
node test-observers-headings.js
-205
View File
@@ -1,205 +0,0 @@
/**
* E2E (#1058): Analytics chart containers fluid + auto-stacking via
* container queries.
*
* Boots Chromium with a minimal HTML harness that links public/style.css
* and renders the .analytics-charts grid at 768/1080/1440 viewports.
*
* Asserts:
* - No horizontal overflow of the chart grid (scrollWidth <= clientWidth).
* - Cards STACK (single column) when the .analytics-charts container is
* narrower than 800px.
* - Cards are SIDE-BY-SIDE (2 columns) when the container is at least
* 1200px wide.
* - The .analytics-charts element opts in to container queries via
* `container-type: inline-size`.
*
* Pure file:// harness — does not require the Go server.
*
* Usage: node test-analytics-fluid-charts.js
*/
'use strict';
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const os = require('os');
const CSS_PATH = path.join(__dirname, 'public', 'style.css');
const cssHref = 'file://' + CSS_PATH;
// Minimal harness: a sized wrapper that defines the available width
// for the .analytics-charts container, plus a handful of chart cards
// matching the production markup.
function harnessHTML(wrapperWidth) {
const card = (full) =>
`<div class="analytics-chart-card${full ? ' full' : ''}">` +
`<h4>Card</h4>` +
`<div class="analytics-chart-desc">Desc</div>` +
`<svg viewBox="0 0 800 200" style="width:100%;max-height:160px"><rect width="800" height="200" fill="#888"/></svg>` +
`</div>`;
return `<!doctype html><html><head>
<meta charset="utf-8">
<link rel="stylesheet" href="${cssHref}">
<style>
/* Sized wrapper simulates the page's content column width the
.analytics-charts inside MUST stay fluid relative to this. */
#wrap { width: ${wrapperWidth}px; box-sizing: border-box; padding: 0; margin: 0; }
body { margin: 0; }
</style>
</head><body>
<div id="wrap">
<div class="analytics-charts" id="grid">
${card(false)}${card(false)}${card(false)}${card(false)}
</div>
</div>
</body></html>`;
}
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log('\n=== #1058 Analytics fluid charts E2E ===');
async function load(wrapperWidth, viewportWidth) {
await page.setViewportSize({ width: viewportWidth, height: 900 });
const tmp = path.join(os.tmpdir(),
`1058-harness-${wrapperWidth}-${viewportWidth}.html`);
fs.writeFileSync(tmp, harnessHTML(wrapperWidth));
await page.goto('file://' + tmp, { waitUntil: 'domcontentloaded' });
}
// Helper: count distinct column-x-positions of chart cards.
async function colCount() {
return page.evaluate(() => {
const cards = Array.from(document.querySelectorAll(
'.analytics-charts > .analytics-chart-card'));
const xs = new Set(cards.map(c =>
Math.round(c.getBoundingClientRect().left)));
return xs.size;
});
}
async function overflow() {
return page.evaluate(() => {
const g = document.getElementById('grid');
return { scrollW: g.scrollWidth, clientW: g.clientWidth };
});
}
// --- Container-query opt-in -------------------------------------------
await step('analytics-charts opts in to container queries', async () => {
await load(1200, 1440);
const ct = await page.evaluate(() => {
const g = document.getElementById('grid');
return getComputedStyle(g).containerType;
});
assert(/inline-size|size/.test(ct),
`expected container-type to be inline-size; got "${ct}"`);
});
// --- Viewport 1440: container ≥1200 → side-by-side --------------------
await step('viewport 1440 / wrapper 1300px → side-by-side (≥2 cols)', async () => {
await load(1300, 1440);
const cols = await colCount();
assert(cols >= 2, `expected ≥2 columns at wrapper 1300px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 1080: medium width — must not overflow ------------------
await step('viewport 1080 / wrapper 1040px → no horizontal overflow', async () => {
await load(1040, 1080);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 768: container <800 → must stack vertically -------------
await step('viewport 768 / wrapper 760px → cards stack (1 col)', async () => {
await load(760, 768);
const cols = await colCount();
assert(cols === 1, `expected 1 column at wrapper 760px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- THE bug: wide viewport + narrow container — must stack ----------
// Today's @media (max-width:768px) is keyed off viewport, not container.
// A narrow wrapper inside a wide viewport (e.g., side pane on a 1440
// screen) should still stack the charts via container queries.
await step('viewport 1440 / wrapper 600px → cards stack via container query', async () => {
await load(600, 1440);
const cols = await colCount();
assert(cols === 1,
`expected 1 column when container <800px regardless of viewport; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow at wide-viewport/narrow-container: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 1920: large desktop → side-by-side, no overflow --------
await step('viewport 1920 / wrapper 1880px → side-by-side (≥2 cols), no overflow', async () => {
await load(1880, 1920);
const cols = await colCount();
assert(cols >= 2, `expected ≥2 columns at wrapper 1880px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow at 1920: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 2560: ultra-wide → side-by-side, no overflow -----------
await step('viewport 2560 / wrapper 2520px → side-by-side (≥2 cols), no overflow', async () => {
await load(2520, 2560);
const cols = await colCount();
assert(cols >= 2, `expected ≥2 columns at wrapper 2520px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow at 2560: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- AC3: charts must redraw/relayout on viewport resize -------------
// Open at 1440 wide (side-by-side), then shrink the wrapper to 760
// (sub-800 container) and assert the layout actually flips to a
// single column. This guards against any future regression where
// the grid is computed once and stuck.
await step('AC3: layout reflows on resize (1440 side-by-side → 768 stacked)', async () => {
await load(1300, 1440);
const colsWide = await colCount();
assert(colsWide >= 2,
`precondition failed: expected ≥2 cols at 1300px; got ${colsWide}`);
// Shrink only the wrapper (no full reload) — proves the layout
// recomputes from the current container width, not a one-shot value.
await page.evaluate(() => {
document.getElementById('wrap').style.width = '760px';
});
await page.setViewportSize({ width: 768, height: 900 });
// Give the browser a frame to recompute layout.
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
const colsNarrow = await colCount();
assert(colsNarrow === 1,
`expected layout to reflow to 1 column after shrink; got ${colsNarrow}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow after resize: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})();
-449
View File
@@ -1,449 +0,0 @@
#!/usr/bin/env node
/* Issue #1061 Bottom navigation for narrow viewports.
*
* Asserts:
* (a) at 360x800, the bottom-nav container is visible AND the top-nav
* (.top-nav) is hidden (display:none / visibility:hidden / size 0).
* (b) at 1440x900, the bottom-nav is NOT visible AND the top-nav IS visible.
* (c) at 360x800, all 5 bottom-nav tabs (Home, Packets, Live, Map, Channels)
* have a tap target height >= 48px.
* (d) at 360x800, tapping the "Packets" tab navigates to #/packets via the
* in-app router i.e. URL hash changes WITHOUT a full reload (a
* window.__bottomNav1061BootstrapId sentinel set on DOMContentLoaded
* MUST persist across the navigation).
* (e) at 360x800, the active-tab indicator class is applied to the Packets
* tab when on #/packets and is NOT applied when on #/.
* (f) the bottom-nav element has a non-empty padding-bottom resolved style
* (proxy for safe-area-inset-bottom; can't directly test the inset in
* headless Chromium).
*
* Stable selectors: bottom-nav tabs MUST be selectable via
* `[data-bottom-nav-tab="<route>"]` to avoid the virtual-scroll-spacer trap
* (DOM-order ambiguous matches).
*
* CI gating: when CHROMIUM_REQUIRE=1 a missing/broken Chromium is a HARD FAIL.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const EXPECTED_TABS = ['home', 'packets', 'live', 'map', 'channels', 'more'];
// #1174: long-tail routes surfaced in the More sheet (the routes NOT in
// the 5 primary bottom-nav slots). Mirror data-route values from the
// existing top-nav.
const EXPECTED_MORE_ROUTES = ['nodes', 'tools', 'observers', 'analytics', 'perf', 'audio-lab'];
function isVisible(rect) {
return rect && rect.width > 0 && rect.height > 0;
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-bottom-nav-1061-e2e.js: FAIL — Chromium required (CHROMIUM_REQUIRE=1) but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-bottom-nav-1061-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0;
let passes = 0;
const fail = (msg) => { failures += 1; console.error(` FAIL: ${msg}`); };
const pass = (msg) => { passes += 1; console.log(` PASS: ${msg}`); };
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
// Inject a bootstrap sentinel BEFORE the page scripts run so we can
// detect a full reload. The same value must survive an in-app
// navigation; if the page reloads the sentinel is reset to a new id.
await page.addInitScript(() => {
window.__bottomNav1061BootstrapId = 'boot-' + Math.random().toString(36).slice(2);
});
// ── (a) 360x800: bottom-nav visible, top-nav hidden ──
await page.setViewportSize({ width: 360, height: 800 });
await page.goto(`${BASE}/#/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body && document.body.classList.contains('app-ready') || document.querySelector('main#app'));
const sentinelA = await page.evaluate(() => window.__bottomNav1061BootstrapId);
const stateNarrow = await page.evaluate(() => {
const bn = document.querySelector('[data-bottom-nav]');
const navLinks = document.querySelector('.top-nav .nav-links');
const navRight = document.querySelector('.top-nav .nav-right');
const navBrand = document.querySelector('.top-nav .nav-brand');
const bnRect = bn ? bn.getBoundingClientRect() : null;
const bnCs = bn ? getComputedStyle(bn) : null;
const isHiddenByCss = (el) => {
if (!el) return true;
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return cs.display === 'none' || cs.visibility === 'hidden' || (r.width === 0 && r.height === 0);
};
return {
bnPresent: !!bn,
bnRect,
bnDisplay: bnCs ? bnCs.display : null,
bnVisibility: bnCs ? bnCs.visibility : null,
bnPaddingBottom: bnCs ? bnCs.paddingBottom : null,
// #1174 fix: top-nav LINKS hidden (no duplicate nav UX), but
// .nav-brand stays visible (logo identity, not navigation).
navLinksHidden: isHiddenByCss(navLinks),
navRightHidden: isHiddenByCss(navRight),
navBrandPresent: !!navBrand,
navBrandHidden: isHiddenByCss(navBrand),
};
});
if (!stateNarrow.bnPresent) {
fail('(a) [data-bottom-nav] container missing in DOM at 360x800');
} else if (stateNarrow.bnDisplay === 'none' || stateNarrow.bnVisibility === 'hidden' || !isVisible(stateNarrow.bnRect)) {
fail(`(a) bottom-nav not visible at 360x800 (display=${stateNarrow.bnDisplay}, rect=${JSON.stringify(stateNarrow.bnRect)})`);
} else {
pass('(a) bottom-nav visible at 360x800');
}
if (stateNarrow.navLinksHidden && stateNarrow.navRightHidden) {
pass('(a) top-nav LINKS hidden at 360x800 (no duplicate nav UX)');
} else {
fail(`(a) top-nav links/right still visible at 360x800 (links=${!stateNarrow.navLinksHidden}, right=${!stateNarrow.navRightHidden}) — duplicate nav UX`);
}
if (stateNarrow.navBrandPresent && !stateNarrow.navBrandHidden) {
pass('(a) .nav-brand (logo identity) remains visible at 360x800');
} else {
fail(`(a) .nav-brand hidden at 360x800 (present=${stateNarrow.navBrandPresent}, hidden=${stateNarrow.navBrandHidden}) — should remain visible per #1137`);
}
// ── (c) 5 tabs each ≥48px tap target ──
const tabSizes = await page.evaluate((expected) => {
return expected.map((r) => {
const el = document.querySelector(`[data-bottom-nav-tab="${r}"]`);
if (!el) return { route: r, present: false };
const rect = el.getBoundingClientRect();
return { route: r, present: true, height: rect.height, width: rect.width };
});
}, EXPECTED_TABS);
for (const t of tabSizes) {
if (!t.present) { fail(`(c) tab missing: [data-bottom-nav-tab="${t.route}"]`); continue; }
if (t.height < 48) fail(`(c) tab ${t.route} height ${t.height.toFixed(1)} < 48px`);
else pass(`(c) tab ${t.route} height ${t.height.toFixed(1)}px ≥ 48`);
}
// ── (f) padding-bottom rule exists (safe-area proxy) ──
if (stateNarrow.bnPaddingBottom && stateNarrow.bnPaddingBottom !== '' && stateNarrow.bnPaddingBottom !== '0px') {
pass(`(f) bottom-nav padding-bottom = ${stateNarrow.bnPaddingBottom}`);
} else if (stateNarrow.bnPaddingBottom === '0px') {
// 0px is acceptable as long as the rule resolved (safe-area-inset is 0 in headless)
pass(`(f) bottom-nav padding-bottom resolved (0px in headless; rule exists)`);
} else {
fail(`(f) bottom-nav padding-bottom not resolved: ${stateNarrow.bnPaddingBottom}`);
}
// ── (e) on #/, Packets tab is NOT active ──
const activeOnHome = await page.evaluate(() => {
const el = document.querySelector('[data-bottom-nav-tab="packets"]');
return el ? el.classList.contains('active') : null;
});
if (activeOnHome === false) pass('(e) Packets tab not active on #/');
else fail(`(e) Packets tab incorrectly active on #/ (got ${activeOnHome})`);
// ── (d) tap "Packets" → #/packets without reload ──
await page.click('[data-bottom-nav-tab="packets"]');
await page.waitForFunction(() => location.hash === '#/packets', null, { timeout: 5000 }).catch(() => {});
const afterTap = await page.evaluate(() => ({
hash: location.hash,
sentinel: window.__bottomNav1061BootstrapId,
}));
if (afterTap.hash === '#/packets') pass('(d) tap navigated to #/packets');
else fail(`(d) tap did NOT navigate to #/packets (got ${afterTap.hash})`);
if (afterTap.sentinel === sentinelA) pass('(d) sentinel preserved — no full reload');
else fail(`(d) sentinel changed (${sentinelA}${afterTap.sentinel}) — page reloaded`);
// ── (e) on #/packets, Packets tab IS active ──
// Wait for the hashchange handler to update the active class. The
// location.hash === '#/packets' check above resolves the moment the
// browser sets the URL, but the hashchange event dispatch is still
// in-flight; reading classList immediately races the handler.
let activeOnPackets = null;
try {
await page.waitForFunction(() => {
const el = document.querySelector('[data-bottom-nav-tab="packets"]');
return el && el.classList.contains('active');
}, null, { timeout: 2000 });
activeOnPackets = true;
} catch (_) {
activeOnPackets = await page.evaluate(() => {
const el = document.querySelector('[data-bottom-nav-tab="packets"]');
return el ? el.classList.contains('active') : null;
});
}
if (activeOnPackets === true) pass('(e) Packets tab active on #/packets');
else fail(`(e) Packets tab NOT active on #/packets (got ${activeOnPackets})`);
// ── (g) #1174: More tab visible at 360x800 ──
const moreTabState = await page.evaluate(() => {
const el = document.querySelector('[data-bottom-nav-tab="more"]');
if (!el) return { present: false };
const r = el.getBoundingClientRect();
return {
present: true,
visible: r.width > 0 && r.height > 0,
ariaExpanded: el.getAttribute('aria-expanded'),
ariaControls: el.getAttribute('aria-controls'),
};
});
if (!moreTabState.present) fail('(g) [data-bottom-nav-tab="more"] missing');
else if (!moreTabState.visible) fail('(g) More tab present but not visible');
else pass('(g) More tab visible at 360x800');
if (moreTabState.present && moreTabState.ariaExpanded === 'false') {
pass('(g) More tab aria-expanded="false" before tap');
} else if (moreTabState.present) {
fail(`(g) More tab aria-expanded should be 'false' before tap, got ${moreTabState.ariaExpanded}`);
}
// ── (h) #1174: tap More opens a sheet listing 6 long-tail routes ──
await page.click('[data-bottom-nav-tab="more"]').catch(() => {});
// Wait for sheet to render.
await page.waitForSelector('[data-bottom-nav-sheet]', { timeout: 3000 }).catch(() => {});
const sheetOpen = await page.evaluate((expected) => {
const sheet = document.querySelector('[data-bottom-nav-sheet]');
if (!sheet) return { present: false };
const cs = getComputedStyle(sheet);
const r = sheet.getBoundingClientRect();
const items = Array.from(sheet.querySelectorAll('[data-bottom-nav-more-route]'))
.map(el => el.getAttribute('data-bottom-nav-more-route'));
const moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
return {
present: true,
visible: cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0,
role: sheet.getAttribute('role'),
itemRoutes: items,
missing: expected.filter(r => !items.includes(r)),
moreTabExpanded: moreTab ? moreTab.getAttribute('aria-expanded') : null,
};
}, EXPECTED_MORE_ROUTES);
if (!sheetOpen.present) fail('(h) [data-bottom-nav-sheet] missing after More tap');
else if (!sheetOpen.visible) fail('(h) sheet rendered but not visible after More tap');
else pass('(h) sheet visible after More tap');
if (sheetOpen.present && sheetOpen.role === 'menu') pass('(h) sheet role="menu"');
else if (sheetOpen.present) fail(`(h) sheet role should be 'menu', got ${sheetOpen.role}`);
if (sheetOpen.present && sheetOpen.missing.length === 0) {
pass(`(h) sheet lists all 6 long-tail routes: ${sheetOpen.itemRoutes.join(',')}`);
} else if (sheetOpen.present) {
fail(`(h) sheet missing routes: ${sheetOpen.missing.join(',')} (got ${sheetOpen.itemRoutes.join(',')})`);
}
if (sheetOpen.moreTabExpanded === 'true') pass('(h) More tab aria-expanded="true" while open');
else fail(`(h) More tab aria-expanded should be 'true' while open, got ${sheetOpen.moreTabExpanded}`);
// ── (i) #1174: tap a route navigates and closes the sheet ──
await page.click('[data-bottom-nav-more-route="tools"]').catch(() => {});
await page.waitForFunction(() => location.hash === '#/tools', null, { timeout: 3000 }).catch(() => {});
const afterRouteTap = await page.evaluate(() => {
const sheet = document.querySelector('[data-bottom-nav-sheet]');
const cs = sheet ? getComputedStyle(sheet) : null;
const r = sheet ? sheet.getBoundingClientRect() : null;
const moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
return {
hash: location.hash,
sheetVisible: !!(sheet && cs && cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0),
moreTabExpanded: moreTab ? moreTab.getAttribute('aria-expanded') : null,
};
});
if (afterRouteTap.hash === '#/tools') pass('(i) tapping Tools navigated to #/tools');
else fail(`(i) hash did not change to #/tools (got ${afterRouteTap.hash})`);
if (!afterRouteTap.sheetVisible) pass('(i) sheet closed after route tap');
else fail('(i) sheet still visible after route tap');
if (afterRouteTap.moreTabExpanded === 'false') pass('(i) More tab aria-expanded="false" after close');
else fail(`(i) More tab aria-expanded should be 'false' after close, got ${afterRouteTap.moreTabExpanded}`);
// ── (j) #1174: tap outside closes the sheet ──
// Reopen.
await page.click('[data-bottom-nav-tab="more"]').catch(() => {});
await page.waitForFunction(() => {
const s = document.querySelector('[data-bottom-nav-sheet]');
if (!s) return false;
const cs = getComputedStyle(s);
return cs.display !== 'none' && cs.visibility !== 'hidden';
}, null, { timeout: 3000 }).catch(() => {});
// Click on body somewhere outside the sheet and outside the bottom-nav.
// Use a coordinate near the top of the viewport (the page main area).
await page.mouse.click(10, 200);
// Allow the close handler to run.
await page.waitForFunction(() => {
const s = document.querySelector('[data-bottom-nav-sheet]');
if (!s) return true;
const cs = getComputedStyle(s);
return cs.display === 'none' || cs.visibility === 'hidden';
}, null, { timeout: 3000 }).catch(() => {});
const afterOutside = await page.evaluate(() => {
const s = document.querySelector('[data-bottom-nav-sheet]');
if (!s) return { closed: true };
const cs = getComputedStyle(s);
const r = s.getBoundingClientRect();
return { closed: cs.display === 'none' || cs.visibility === 'hidden' || (r.width === 0 && r.height === 0) };
});
if (afterOutside.closed) pass('(j) sheet closes on outside click');
else fail('(j) sheet still visible after outside click');
// ── (k) #1174: at 360x800, #hamburger is hidden (More tab replaces it) ──
const hamburgerHidden = await page.evaluate(() => {
const h = document.getElementById('hamburger');
if (!h) return { present: false };
const cs = getComputedStyle(h);
const r = h.getBoundingClientRect();
return {
present: true,
hidden: cs.display === 'none' || cs.visibility === 'hidden' || (r.width === 0 && r.height === 0),
display: cs.display,
};
});
if (!hamburgerHidden.present) {
pass('(k) #hamburger removed from DOM (acceptable)');
} else if (hamburgerHidden.hidden) {
pass(`(k) #hamburger hidden at 360x800 (display=${hamburgerHidden.display})`);
} else {
fail(`(k) #hamburger still visible at 360x800 (display=${hamburgerHidden.display}) — More tab should replace it`);
}
// ── (b) 1440x900: bottom-nav hidden, top-nav visible ──
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(`${BASE}/#/`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.top-nav .nav-right');
const stateWide = await page.evaluate(() => {
const bn = document.querySelector('[data-bottom-nav]');
const tn = document.querySelector('.top-nav');
const bnRect = bn ? bn.getBoundingClientRect() : null;
const tnRect = tn ? tn.getBoundingClientRect() : null;
const bnCs = bn ? getComputedStyle(bn) : null;
const tnCs = tn ? getComputedStyle(tn) : null;
return {
bnDisplay: bnCs ? bnCs.display : null,
bnVisibility: bnCs ? bnCs.visibility : null,
bnRect,
tnDisplay: tnCs ? tnCs.display : null,
tnVisibility: tnCs ? tnCs.visibility : null,
tnRect,
};
});
if (stateWide.bnDisplay === 'none' || stateWide.bnVisibility === 'hidden' || !isVisible(stateWide.bnRect)) {
pass('(b) bottom-nav hidden at 1440x900');
} else {
fail(`(b) bottom-nav still visible at 1440x900 (display=${stateWide.bnDisplay}, rect=${JSON.stringify(stateWide.bnRect)})`);
}
if (stateWide.tnDisplay !== 'none' && stateWide.tnVisibility !== 'hidden' && isVisible(stateWide.tnRect)) {
pass('(b) top-nav visible at 1440x900');
} else {
fail(`(b) top-nav not visible at 1440x900 (display=${stateWide.tnDisplay})`);
}
// ── (l) #1174 mesh-op review: .live-page bottom must NOT be covered by bottom-nav at ≤768 ──
await page.setViewportSize({ width: 360, height: 800 });
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.live-page', { timeout: 5000 }).catch(() => {});
// Allow layout to settle.
await page.waitForFunction(() => !!document.querySelector('.live-page'), null, { timeout: 3000 }).catch(() => {});
const liveLayout = await page.evaluate(() => {
const lp = document.querySelector('.live-page');
if (!lp) return { present: false };
const r = lp.getBoundingClientRect();
return {
present: true,
bottom: r.bottom,
innerHeight: window.innerHeight,
};
});
if (!liveLayout.present) {
fail('(l) .live-page missing on #/live');
} else if (liveLayout.bottom > liveLayout.innerHeight - 56 + 1) {
// +1 for sub-pixel rounding tolerance.
fail(`(l) .live-page bottom (${liveLayout.bottom.toFixed(1)}) > viewport - 56 (${(liveLayout.innerHeight - 56).toFixed(1)}) — bottom-nav covers content`);
} else {
pass(`(l) .live-page bottom ${liveLayout.bottom.toFixed(1)} ≤ viewport - 56 (${(liveLayout.innerHeight - 56).toFixed(1)})`);
}
// ── (m) #1174 mesh-op review: bottom-nav has a connectivity indicator that toggles on setConnected(false) ──
await page.goto(`${BASE}/#/`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-bottom-nav]', { timeout: 5000 });
const indicator = await page.evaluate(() => {
if (!window.__corescopeLogo || typeof window.__corescopeLogo.setConnected !== 'function') {
return { logoApiPresent: false };
}
window.__corescopeLogo.setConnected(true);
const nav = document.querySelector('[data-bottom-nav]');
const connectedCls = nav.classList.contains('disconnected');
window.__corescopeLogo.setConnected(false);
const disconnectedCls = nav.classList.contains('disconnected');
// restore
window.__corescopeLogo.setConnected(true);
return {
logoApiPresent: true,
onConnected: connectedCls,
onDisconnected: disconnectedCls,
};
});
if (!indicator.logoApiPresent) {
fail('(m) window.__corescopeLogo.setConnected not exposed');
} else if (indicator.onConnected === false && indicator.onDisconnected === true) {
pass('(m) bottom-nav .disconnected class toggles with setConnected()');
} else {
fail(`(m) bottom-nav disconnected class wiring broken (onConnected=${indicator.onConnected}, onDisconnected=${indicator.onDisconnected})`);
}
// ── (n) #1174 mesh-op review: More tab gets .active when on long-tail routes ──
await page.goto(`${BASE}/#/tools`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => location.hash === '#/tools', null, { timeout: 3000 }).catch(() => {});
let moreActiveOnTools = null;
try {
await page.waitForFunction(() => {
const el = document.querySelector('[data-bottom-nav-tab="more"]');
return el && el.classList.contains('active');
}, null, { timeout: 2000 });
moreActiveOnTools = true;
} catch (_) {
moreActiveOnTools = await page.evaluate(() => {
const el = document.querySelector('[data-bottom-nav-tab="more"]');
return el ? el.classList.contains('active') : null;
});
}
if (moreActiveOnTools === true) pass('(n) More tab .active on #/tools (long-tail route)');
else fail(`(n) More tab NOT .active on #/tools (got ${moreActiveOnTools})`);
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => location.hash === '#/packets', null, { timeout: 3000 }).catch(() => {});
let moreActiveOnPackets = null;
try {
await page.waitForFunction(() => {
const el = document.querySelector('[data-bottom-nav-tab="more"]');
return el && !el.classList.contains('active');
}, null, { timeout: 2000 });
moreActiveOnPackets = false;
} catch (_) {
moreActiveOnPackets = await page.evaluate(() => {
const el = document.querySelector('[data-bottom-nav-tab="more"]');
return el ? el.classList.contains('active') : null;
});
}
if (moreActiveOnPackets === false) pass('(n) More tab loses .active on primary route #/packets');
else fail(`(n) More tab still .active on #/packets (got ${moreActiveOnPackets})`);
await browser.close();
console.log(`\ntest-bottom-nav-1061-e2e.js: ${passes} passed, ${failures} failed`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch((err) => {
console.error('test-bottom-nav-1061-e2e.js: FAIL —', err);
process.exit(1);
});
-111
View File
@@ -1,111 +0,0 @@
/**
* Issue #1057 Channels page fluid layout E2E.
*
* For each viewport asserts:
* - No horizontal scroll on the body.
* - At 768px wide: both .ch-sidebar and .ch-main are visible AND occupy
* non-overlapping horizontal regions (true side-by-side).
* - At narrow (<700px) widths: layout stacks (sidebar above OR overlay).
*
* Usage: BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.error(`${name}: ${e.message}`); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1057 Channels fluid layout E2E against ${BASE} ===`);
async function loadChannels(w, h) {
await page.setViewportSize({ width: w, height: h });
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.ch-sidebar', { timeout: 8000 });
// Allow CSS layout/paint to settle.
await page.waitForTimeout(150);
}
async function noBodyHScroll() {
return page.evaluate(() => {
// Allow ≤1px tolerance for sub-pixel rounding.
return (document.documentElement.scrollWidth - document.documentElement.clientWidth) <= 1;
});
}
async function rectOf(sel) {
return page.evaluate((s) => {
const el = document.querySelector(s);
if (!el) return null;
const r = el.getBoundingClientRect();
const cs = window.getComputedStyle(el);
return {
x: r.x, y: r.y, w: r.width, h: r.height,
visible: r.width > 0 && r.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden',
};
}, sel);
}
// Wide viewports — true side-by-side. Includes 2560×1440 ultrawide (AC4).
for (const [w, h] of [[768, 900], [1080, 900], [1440, 900], [1920, 1080], [2560, 1440]]) {
await step(`viewport ${w}×${h}: no horizontal scroll`, async () => {
await loadChannels(w, h);
assert(await noBodyHScroll(), 'document scrollWidth > clientWidth (horizontal scroll)');
});
await step(`viewport ${w}×${h}: sidebar AND message area both visible`, async () => {
const sb = await rectOf('.ch-sidebar');
const main = await rectOf('.ch-main');
assert(sb && sb.visible, '.ch-sidebar not visible');
assert(main && main.visible, '.ch-main not visible');
// Sidebar should not consume more than ~45% of viewport width on wide screens.
assert(sb.w <= w * 0.45 + 1,
`sidebar too wide: ${sb.w}px / ${w}px viewport (>45%)`);
// Message area should occupy meaningful remaining width (≥40% of viewport).
assert(main.w >= w * 0.40,
`message area too narrow: ${main.w}px / ${w}px viewport (<40%)`);
// Side-by-side: main starts at/after sidebar's right edge (no overlap).
assert(main.x + 1 >= sb.x + sb.w,
`sidebar (x=${sb.x},w=${sb.w}) overlaps main (x=${main.x})`);
});
}
// Narrow viewport — stacking (sidebar above main, or overlay/single-pane).
await step('viewport 480×800: layout stacks (no side-by-side overflow)', async () => {
await loadChannels(480, 800);
assert(await noBodyHScroll(), 'narrow viewport caused horizontal scroll');
const sb = await rectOf('.ch-sidebar');
const main = await rectOf('.ch-main');
assert(sb, '.ch-sidebar missing');
// Either main is hidden/overlayed (single-pane mobile mode), OR
// main is stacked below the sidebar (main.y >= sb.y + sb.h - tolerance).
if (main && main.visible) {
const stacked = main.y + 1 >= sb.y + sb.h
|| sb.y + 1 >= main.y + main.h;
const overlay = Math.abs(main.x - sb.x) < 5 && Math.abs(main.w - sb.w) < 5;
assert(stacked || overlay,
`narrow layout not stacked/overlayed: sb=${JSON.stringify(sb)} main=${JSON.stringify(main)}`);
}
});
console.log(`\n${passed} passed, ${failed} failed`);
await browser.close();
process.exit(failed ? 1 : 0);
})().catch((e) => { console.error(e); process.exit(1); });
-92
View File
@@ -1,92 +0,0 @@
/**
* Issue #1057 Channels sidebar + message area fluidity (static assertions).
*
* Verifies that public/style.css makes the channels sidebar fluid rather
* than locked at fixed pixel widths, that the message area uses remaining
* space, and that narrow stacking is driven by a container query (not a
* hardcoded fixed-px breakpoint baked into the channels layout).
*
* Companion E2E (real viewport assertions): test-channel-fluid-e2e.js.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const css = fs.readFileSync(path.join(__dirname, 'public/style.css'), 'utf8');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// Helper: extract the first matching rule body for a selector (no nesting parser, simple regex).
function ruleBody(selector) {
// Match selector that's NOT preceded by a non-space CSS-name char (avoids matching .ch-sidebar-foo when looking for .ch-sidebar).
const re = new RegExp(
'(?:^|[^a-zA-Z0-9_-])' + selector.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + '\\s*\\{([^}]*)\\}'
);
const m = css.match(re);
return m ? m[1] : null;
}
console.log('\n=== #1057 Channels sidebar fluid width ===');
test('.ch-sidebar default rule uses clamp() for width (not a fixed px)', () => {
const body = ruleBody('.ch-sidebar');
assert.ok(body, '.ch-sidebar rule not found');
assert.ok(/width\s*:\s*clamp\s*\(/.test(body),
`.ch-sidebar should use width: clamp(...); got: ${body.trim()}`);
});
test('.ch-sidebar declares a sane min-width (>=200px)', () => {
const body = ruleBody('.ch-sidebar');
assert.ok(body, '.ch-sidebar rule not found');
// min-width may be a literal px or part of clamp's first arg.
const minMatch = body.match(/min-width\s*:\s*(\d+)px/);
assert.ok(minMatch, '.ch-sidebar should declare min-width');
const px = parseInt(minMatch[1], 10);
assert.ok(px >= 200 && px <= 280, `min-width should be 200..280px (got ${px}px)`);
});
console.log('\n=== #1057 Message area fills remaining width ===');
test('.ch-main keeps flex:1 (uses remaining width on wide screens)', () => {
const body = ruleBody('.ch-main');
assert.ok(body, '.ch-main rule not found');
assert.ok(/flex\s*:\s*1\b/.test(body),
'.ch-main should use flex: 1 to fill remaining width');
});
console.log('\n=== #1057 Narrow stacking via container query (not hardcoded px) ===');
test('style.css declares a container query for the channels layout', () => {
// Either container-type or container shorthand on .ch-layout.
const layout = ruleBody('.ch-layout');
assert.ok(layout, '.ch-layout rule not found');
assert.ok(/container(-type|-name|\s*:)/.test(layout),
'.ch-layout should declare container-type/container for container queries');
});
test('style.css contains an @container rule that targets the channels sidebar', () => {
// Look for "@container ... .ch-sidebar" anywhere.
const re = /@container[^{]*\{[\s\S]*?\.ch-(sidebar|layout|main)[^{]*\{/;
assert.ok(re.test(css),
'expected an @container rule scoping .ch-sidebar/.ch-layout/.ch-main');
});
test('removed legacy fixed 220px override at @media (max-width: 900px) for .ch-sidebar', () => {
// The old block: @media (max-width: 900px){ ... .ch-sidebar { width: 220px; min-width: 220px; } ... }
// After fluid migration this hardcoded sub-rule should be gone (the clamp+container query handle it).
const m = css.match(/@media[^{]*max-width:\s*900px[^{]*\{[\s\S]*?\n\}/);
if (m) {
assert.ok(!/\.ch-sidebar\s*\{[^}]*width\s*:\s*220px/.test(m[0]),
'legacy hardcoded .ch-sidebar width:220px override should be removed');
}
});
console.log('\n=== Summary ===');
console.log(`${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
-198
View File
@@ -1,198 +0,0 @@
/**
* #1087 Channel modal QR/share E2E.
*
* Boots Chromium against a CoreScope server (BASE_URL) and exercises
* the four bugs filed in #1087:
*
* 1. Generate & Show QR produces a real QR (no "library not loaded")
* 2. The QR-encoded `name=` parameter uses the user's display label
* (not `psk:<hex8>`)
* 3. Adding a PSK channel persists across page refresh
* 4. Clicking Share opens a DEDICATED share modal distinct DOM id
* and title from the Add Channel modal
*
* Usage: BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1087 E2E against ${BASE} ===`);
// Always start clean: clear localStorage so prior test runs don't
// leak channel keys into this session.
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} });
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chAddChannelBtn', { timeout: 8000 });
// ─── Bug 1 + Bug 2: Generate & Show QR works and uses display label ───
await step('Bug 1+2: Generate & Show QR renders a QR using the display label', async () => {
await page.click('#chAddChannelBtn');
await page.waitForSelector('#chAddChannelModal:not(.hidden)');
await page.fill('#chGenerateName', 'My Cool Channel');
await page.click('#chGenerateBtn');
// Wait for the QR render. The Kazuhiko Arase generator emits an
// <img> (data URL) or table inside #qr-output.
await page.waitForFunction(() => {
const out = document.getElementById('qr-output');
if (!out) return false;
// Fail clearly if the old "[QR library not loaded]" text shows up.
if (/QR library not loaded/i.test(out.textContent)) return true;
return !!(out.querySelector('img, canvas, table, svg'));
}, { timeout: 5000 });
const out = await page.textContent('#qr-output');
assert(!/QR library not loaded/i.test(out),
'Bug 1: "[QR library not loaded]" must not appear');
const hasQr = await page.evaluate(() => {
const out = document.getElementById('qr-output');
return !!(out && out.querySelector('img, canvas, table, svg'));
});
assert(hasQr, 'Bug 1: QR element (img/canvas/table/svg) must be rendered');
// Bug 2: the QR URL printed under the QR must use the display label.
const urlText = await page.evaluate(() => {
const u = document.querySelector('#qr-output .channel-qr-url');
return u ? u.textContent : '';
});
assert(urlText && /name=My(\+|%20|\s)?Cool(\+|%20|\s)?Channel/i.test(urlText),
'Bug 2: QR URL must encode the user display name, got: ' + urlText);
assert(!/name=psk(%3A|:)/i.test(urlText),
'Bug 2: QR URL must NOT encode the internal `psk:<hex8>` key, got: ' + urlText);
// Close the add modal.
await page.click('[data-action="ch-modal-close"]').catch(() => {});
});
// ─── Bug 3: PSK channel persists across page refresh ───
await step('Bug 3: PSK channel persists across refresh', async () => {
await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chAddChannelBtn');
await page.click('#chAddChannelBtn');
await page.waitForSelector('#chAddChannelModal:not(.hidden)');
// Use the PSK Add path (synchronous, no key derivation needed).
const KEY = '00112233445566778899aabbccddeeff';
await page.fill('#chPskKey', KEY);
await page.fill('#chPskName', 'PersistMe');
await page.click('#chPskAddBtn');
// Storage must contain the key SYNCHRONOUSLY after submit — not as
// a side effect of subsequent UI events.
const stored = await page.evaluate(() => {
try { return localStorage.getItem('corescope_channel_keys'); }
catch (e) { return null; }
});
assert(stored && stored.indexOf(KEY) !== -1,
'Bug 3: corescope_channel_keys must contain the new key after submit, got: ' + stored);
// Reload — the channel must still be in the sidebar.
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chList');
const stillStored = await page.evaluate(() => {
try { return localStorage.getItem('corescope_channel_keys'); }
catch (e) { return null; }
});
assert(stillStored && stillStored.indexOf(KEY) !== -1,
'Bug 3: key must survive refresh in localStorage, got: ' + stillStored);
// Sidebar must show the user-added channel (look for the label).
await page.waitForFunction(() => {
const list = document.getElementById('chList');
return !!(list && /PersistMe/.test(list.textContent));
}, { timeout: 5000 });
});
// ─── Bug 4: Share opens a DEDICATED modal ───
await step('Bug 4: Share button opens a dedicated share modal (not Add)', async () => {
// Channel from previous step is in the sidebar.
await page.waitForSelector('[data-share-channel]');
// Make sure the Add modal is closed before we click Share.
const addOpen = await page.evaluate(() => {
const m = document.getElementById('chAddChannelModal');
return !!(m && !m.classList.contains('hidden') && !m.hasAttribute('hidden'));
});
assert(!addOpen, 'precondition: Add modal must be closed before Share click');
await page.click('[data-share-channel]');
// The Share modal must exist and be visible.
await page.waitForSelector('#chShareModal:not(.hidden)', { timeout: 5000 });
// The Add modal must NOT be the one that opened.
const addStillClosed = await page.evaluate(() => {
const m = document.getElementById('chAddChannelModal');
return !!(m && (m.classList.contains('hidden') || m.hasAttribute('hidden')));
});
assert(addStillClosed, 'Bug 4: Add modal must NOT open when Share is clicked');
// Title must be share-specific.
const shareTitle = await page.evaluate(() => {
const m = document.getElementById('chShareModal');
if (!m) return '';
const t = m.querySelector('#chShareModalTitle, .ch-share-modal-title, h2, h3, h4');
return t ? t.textContent : '';
});
assert(/share/i.test(shareTitle),
'Bug 4: share modal title must contain "Share", got: ' + shareTitle);
// Hex key field must be present and copyable. (#1101: URL field
// removed — QR already encodes the URL, a separate Copy URL button
// was redundant.)
const hasFields = await page.evaluate(() => {
const m = document.getElementById('chShareModal');
if (!m) return false;
const k = m.querySelector('#chShareKey, [data-share-field="key"]');
const u = m.querySelector('#chShareUrl, [data-share-field="url"]');
return !!k && !u;
});
assert(hasFields, 'Bug 4 / #1101: share modal exposes ONLY the hex key field (no URL field)');
// #1101: the QR box must contain ONLY the QR <img> — no URL text
// line, no inline Copy Key button overlapping the image.
const qrBoxOnlyHasQr = await page.evaluate(() => {
const qr = document.getElementById('chShareQr');
if (!qr) return { ok: false, reason: 'no #chShareQr' };
const imgs = qr.querySelectorAll('img');
const urlLine = qr.querySelector('.channel-qr-url');
const copyBtn = qr.querySelector('.channel-qr-copy, button');
return {
ok: imgs.length === 1 && !urlLine && !copyBtn,
imgCount: imgs.length,
hasUrlLine: !!urlLine,
hasCopyBtn: !!copyBtn,
};
});
assert(qrBoxOnlyHasQr.ok,
'#1101: #chShareQr contains ONLY the QR image (got ' +
JSON.stringify(qrBoxOnlyHasQr) + ')');
});
console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===');
await browser.close();
process.exit(failed > 0 ? 1 : 0);
})().catch((e) => { console.error('FATAL:', e); process.exit(1); });
-138
View File
@@ -1,138 +0,0 @@
/**
* #1087 Channel modal QR/share regression tests.
*
* Pure source-string + targeted DOM-string assertions covering all 4 bugs:
*
* 1. QR generator must use the vendored Kazuhiko Arase `qrcode()` API
* (lowercase). Old code checked `root.QRCode` which never existed,
* causing "[QR library not loaded]" on every Generate click.
* 2. The Share button must use the user's display label (not the
* internal `psk:<hex8>` lookup key) when building the QR/URL.
* 3. PSK channel persistence: the Add/Generate handlers must route
* writes through a single dedicated helper (`persistAddedChannel`)
* so storage happens synchronously inside the submit path not as
* a side effect of subsequent UI events. The helper must also
* verify localStorage actually contains the key after the write.
* 4. The Share affordance must open a DEDICATED modal element
* (`chShareModal`) not reuse the Add Channel modal
* (`chAddChannelModal`).
*
* Companion E2E coverage: test-channel-issue-1087-e2e.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
const qrSrc = fs.readFileSync(path.join(__dirname, 'public/channel-qr.js'), 'utf8');
const decSrc = fs.readFileSync(path.join(__dirname, 'public/channel-decrypt.js'), 'utf8');
const idxSrc = fs.readFileSync(path.join(__dirname, 'public/index.html'), 'utf8');
console.log('\n=== #1087 Bug 1: QR vendor library is wired correctly ===');
// The vendored library is Kazuhiko Arase's qrcode-generator (lowercase
// `qrcode` global). The generate() helper must call into that API —
// either via `root.qrcode(...)` / `window.qrcode(...)` / a direct
// `qrcode(` call producing an image with `createImgTag` /
// `createSvgTag` / `createDataURL`.
assert(/\bqrcode\s*\(\s*\d/.test(qrSrc) ||
/createImgTag|createSvgTag|createDataURL/.test(qrSrc),
'channel-qr.js generate() uses the vendored qrcode-generator API');
// The "[QR library not loaded]" fallback string must NOT be the only
// detection branch for the generator — the new code must support the
// lowercase qrcode global. We accept either (a) the old check is gone
// or (b) the new check is added alongside.
assert(/typeof\s+(root|window)\.qrcode\s*===\s*['"]function['"]/.test(qrSrc) ||
/typeof\s+qrcode\s*===\s*['"]function['"]/.test(qrSrc),
'channel-qr.js detects the lowercase `qrcode` global (not just `QRCode`)');
console.log('\n=== #1087 Bug 2: Share QR uses the user display label ===');
// The share-channel click handler must resolve a display label
// (via ChannelDecrypt.getLabel / .getLabels / userLabel lookup) and
// pass that human-readable name to ChannelQR.generate — NOT the raw
// `psk:<hex8>` key prefix.
var shareIdx = chSrc.indexOf("data-share-channel");
assert(shareIdx > 0, 'found share button DOM marker');
// Find a window of source covering the share button click handler.
var shareHandlerIdx = chSrc.indexOf("e.target.closest('[data-share-channel]')");
assert(shareHandlerIdx > 0, 'found share-channel click handler block');
var shareBlock = chSrc.substring(shareHandlerIdx, shareHandlerIdx + 2500);
assert(/getLabel\s*\(|getLabels\s*\(|userLabel|labels\s*\[/.test(shareBlock),
'share handler resolves the user display label before rendering QR');
// Belt-and-suspenders: the call to ChannelQR.generate() inside the
// share handler must NOT pass a value derived only from
// `shareHash.substring(5)` (which yields `psk:<hex8>`). Require an
// explicit label fallback chain.
assert(/ChannelQR\.generate\s*\(\s*[a-zA-Z_]*[Ll]abel/.test(shareBlock) ||
/ChannelQR\.generate\s*\(\s*displayLabel|displayName/.test(shareBlock),
'share handler passes a label-derived display name to ChannelQR.generate');
console.log('\n=== #1087 Bug 3: PSK channel persistence via dedicated helper ===');
// A single canonical helper must own the persistence path. Both the
// Generate and the PSK-Add submit handlers must route through it so
// storage cannot be skipped or deferred to a later UI event.
assert(/function\s+persistAddedChannel\s*\(/.test(chSrc),
'channels.js defines a persistAddedChannel(...) helper');
// Helper must call ChannelDecrypt.storeKey AND verify the write
// landed in localStorage by re-reading it.
var helperIdx = chSrc.indexOf('function persistAddedChannel');
assert(helperIdx > 0, 'helper definition located');
var helperBlock = helperIdx > 0 ? chSrc.substring(helperIdx, helperIdx + 1500) : '';
assert(/storeKey\s*\(/.test(helperBlock),
'persistAddedChannel calls ChannelDecrypt.storeKey()');
assert(/getStoredKeys\s*\(|getKeys\s*\(|localStorage\.getItem/.test(helperBlock),
'persistAddedChannel verifies the write by re-reading storage');
// Both submit paths must invoke the helper.
assert(/chGenerateBtn[\s\S]{0,2000}persistAddedChannel\s*\(/.test(chSrc),
'Generate (#chGenerateBtn) handler invokes persistAddedChannel');
assert(/chPskAddBtn[\s\S]{0,2500}persistAddedChannel\s*\(|addUserChannel[\s\S]{0,2500}persistAddedChannel\s*\(/.test(chSrc),
'PSK Add path invokes persistAddedChannel');
console.log('\n=== #1087 Bug 4: Dedicated Share modal (separate from Add) ===');
// A NEW DOM element distinct from #chAddChannelModal must exist for
// sharing. Title, hex key field, URL field, privacy warning.
assert(/id="chShareModal"/.test(chSrc),
'dedicated #chShareModal element exists in channels.js markup');
// Modal must NOT just be an alias for the Add modal — its internals
// must include share-specific affordances.
var shareModalIdx = chSrc.indexOf('id="chShareModal"');
assert(shareModalIdx > 0, 'share modal markup located');
var shareModalBlock = shareModalIdx > 0 ? chSrc.substring(shareModalIdx, shareModalIdx + 3000) : '';
assert(/id="chShareModalTitle"|class="ch-share-modal-title"|>Share[^<]*</.test(shareModalBlock),
'share modal has its own title element ("Share: <Channel Name>")');
assert(/id="chShareKey"|data-share-field="key"/.test(shareModalBlock),
'share modal exposes the hex key field with a copy affordance');
// #1101: meshcore:// URL field intentionally REMOVED — QR already
// encodes the URL, separate field/button was redundant.
assert(/trusted|privacy|do not share|only share/i.test(shareModalBlock),
'share modal includes a privacy warning');
// Share click handler must open #chShareModal — not openAddModal().
var shareClickIdx = chSrc.indexOf("e.target.closest('[data-share-channel]')");
var shareClickBlock = shareClickIdx > 0 ? chSrc.substring(shareClickIdx, shareClickIdx + 2500) : '';
assert(/openShareModal\s*\(|chShareModal/.test(shareClickBlock),
'share button click handler opens #chShareModal (not the Add modal)');
assert(!/openAddModal\s*\(\s*\)/.test(shareClickBlock),
'share button click handler does NOT call openAddModal()');
console.log('\n=== Results ===');
console.log('Passed: ' + passed + ', Failed: ' + failed);
process.exit(failed > 0 ? 1 : 0);
-94
View File
@@ -1,94 +0,0 @@
/**
* #1101 Strip Share modal: remove redundant URL copy + duplicated key field.
*
* Acceptance criteria:
* - Share modal contains only: QR (just the QR image, nothing else
* in that box), Hex Key field with single Copy button BELOW the QR,
* privacy warning, Close button.
* - No "Copy URL" affordance ANYWHERE in the modal.
* - No duplicated meshcore:// URL field below the QR.
* - The QR box (#chShareQr) must contain ONLY the QR image no URL
* text, no Copy Key button overlapping it.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const channelsSrc = fs.readFileSync(path.join(__dirname, 'public', 'channels.js'), 'utf8');
const qrSrc = fs.readFileSync(path.join(__dirname, 'public', 'channel-qr.js'), 'utf8');
console.log('\n=== #1101: Share modal markup ===');
// Locate the share modal markup block.
const shareModalIdx = channelsSrc.indexOf('id="chShareModal"');
assert(shareModalIdx > 0, 'share modal markup located');
// Tighten block isolation: scan forward for the share modal's own
// closing tag (the outer overlay div is indented 6 spaces, so its
// matching close is the first "\n </div>" we hit after the
// opener). Falls back to the old ch-main heuristic if that pattern
// disappears for any reason.
let shareEnd = channelsSrc.indexOf('\n </div>', shareModalIdx);
if (shareEnd < 0) {
shareEnd = channelsSrc.indexOf('<div class="ch-main"', shareModalIdx);
}
const shareModalBlock = channelsSrc.substring(shareModalIdx, shareEnd);
assert(shareModalBlock.length > 0 && shareModalBlock.length < 4000,
'share modal block isolated');
// Hex key field MUST still be present (single source of truth).
assert(/id="chShareKey"/.test(shareModalBlock),
'share modal still exposes the hex key field with a Copy button');
// meshcore:// URL field MUST be removed.
assert(!/id="chShareUrl"/.test(shareModalBlock),
'share modal does NOT render a #chShareUrl input field');
assert(!/data-share-field="url"/.test(shareModalBlock),
'share modal does NOT render any [data-share-field="url"] element');
assert(!/data-share-copy="url"/.test(shareModalBlock),
'share modal does NOT render any [data-share-copy="url"] button');
assert(!/meshcore:\/\/ URL/.test(shareModalBlock),
'share modal does NOT show a "meshcore:// URL" label');
// Privacy warning + close button still required.
assert(/ch-modal-warn/.test(shareModalBlock),
'share modal still includes the privacy warning');
assert(/id="chShareModalClose"/.test(shareModalBlock),
'share modal still has the ✕ close button');
console.log('\n=== #1101: openShareModal() body ===');
// openShareModal must no longer reference chShareUrl or build URL into a field.
const openIdx = channelsSrc.indexOf('function openShareModal(');
assert(openIdx > 0, 'openShareModal located');
const openEnd = channelsSrc.indexOf('function ', openIdx + 30);
const openBlock = channelsSrc.substring(openIdx, openEnd);
assert(!/getElementById\('chShareUrl'\)/.test(openBlock),
'openShareModal does NOT look up #chShareUrl');
assert(!/urlField\.value\s*=/.test(openBlock),
'openShareModal does NOT assign to urlField.value');
console.log('\n=== #1101: ChannelQR.generate() supports qrOnly ===');
// ChannelQR.generate must accept an opts.qrOnly flag so the Share
// modal's QR box can render JUST the QR image — no URL line, no
// inline Copy Key button. (The Share modal has its own dedicated
// hex key field + Copy button BELOW the QR.)
assert(/function generate\([^)]*opts[^)]*\)/.test(qrSrc),
'ChannelQR.generate accepts an opts argument');
assert(/qrOnly/.test(qrSrc),
'ChannelQR.generate honours opts.qrOnly');
// Share modal call site must pass qrOnly:true.
assert(/ChannelQR\.generate\([^)]*qrOnly[^)]*\)/.test(channelsSrc) ||
/ChannelQR\.generate\([\s\S]{0,200}qrOnly\s*:\s*true/.test(channelsSrc),
'openShareModal passes { qrOnly: true } to ChannelQR.generate');
console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===');
process.exit(failed > 0 ? 1 : 0);
-110
View File
@@ -1,110 +0,0 @@
/**
* #1111 Hide "My Channels" section entirely when empty.
*
* Acceptance:
* - localStorage cleared no `.ch-section-mychannels` (no header, no
* placeholder text, no "My Channels" string in #chList)
* - PSK key stored in localStorage `.ch-section-mychannels` exists
* with the header
*
* Usage: BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const STORAGE_KEY = 'corescope_channel_keys';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1111 E2E against ${BASE} ===`);
// ─── Bootstrap: ensure localStorage is empty ───
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} });
// ─── Case 1: empty → My Channels section MUST NOT render ───
await step('empty localStorage: .ch-section-mychannels MUST NOT exist', async () => {
await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' });
// Wait for the channel list to populate (Network section is always present
// when the API returns any public channels; otherwise wait for the list
// container itself).
await page.waitForSelector('#chList', { timeout: 8000 });
// Give the renderer a tick to draw sections after the API resolves.
await page.waitForFunction(() => {
const el = document.getElementById('chList');
// List is "ready" when at least one .ch-section is rendered, OR after
// render has settled with no sections (truly empty).
return el && (el.querySelector('.ch-section') || el.dataset.rendered === 'true' || el.children.length > 0);
}, { timeout: 8000 }).catch(() => {});
const mineCount = await page.$$eval('.ch-section-mychannels', els => els.length);
assert(mineCount === 0,
'Expected 0 .ch-section-mychannels with empty storage, got: ' + mineCount);
// Also assert no "My Channels" header text leaked into #chList.
const listText = await page.evaluate(() => {
const el = document.getElementById('chList');
return el ? el.textContent : '';
});
assert(!/My Channels/.test(listText),
'Expected no "My Channels" text in #chList when empty, got: ' +
listText.slice(0, 200));
});
// ─── Case 2: stored PSK key → My Channels section MUST render ───
await step('stored PSK key: .ch-section-mychannels MUST exist with header', async () => {
// Seed a stored key, then reload.
await page.evaluate((sk) => {
try {
localStorage.setItem(sk, JSON.stringify({
'TestChan1111': '00112233445566778899aabbccddeeff'
}));
} catch (e) {}
}, STORAGE_KEY);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chList', { timeout: 8000 });
// Wait for the My Channels section to appear after merge.
await page.waitForFunction(() => {
return !!document.querySelector('.ch-section-mychannels');
}, { timeout: 8000 });
const mineCount = await page.$$eval('.ch-section-mychannels', els => els.length);
assert(mineCount === 1,
'Expected exactly 1 .ch-section-mychannels with stored key, got: ' + mineCount);
const headerText = await page.evaluate(() => {
const sec = document.querySelector('.ch-section-mychannels');
const hdr = sec && sec.querySelector('.ch-section-header');
return hdr ? hdr.textContent : '';
});
assert(/My Channels/.test(headerText),
'Expected "My Channels" header in .ch-section-mychannels, got: ' + headerText);
});
// Cleanup so the test leaves storage in a known state.
await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} });
console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===');
await browser.close();
process.exit(failed > 0 ? 1 : 0);
})().catch((e) => { console.error('FATAL:', e); process.exit(1); });
+11 -13
View File
@@ -56,11 +56,8 @@ assert(/data-share-channel/.test(chSrc),
assert(/data-share-channel/.test(chSrc) && /ChannelQR/.test(chSrc),
'share handler references ChannelQR for QR rendering');
// Modal must have a target container for the reshare QR output.
// (#1087 polish) The share UX moved to a DEDICATED modal —
// `#chShareModal` with `#chShareQr` — so the dead `chShareSection` /
// `chShareOutput` markup was removed from the Add Channel modal.
assert(/id="chShareModal"/.test(chSrc) && /id="chShareQr"/.test(chSrc),
'dedicated share modal exposes a QR output container');
assert(/id="chShareOutput"/.test(chSrc) || /id="chReshareOutput"/.test(chSrc),
'modal has a reshare QR output container');
console.log('\n=== Fix 5: "(your key)" suffix removed from preview ===');
assert(!/\(your key\)/.test(chSrc),
@@ -161,14 +158,15 @@ if (renderRowSrc) {
'encrypted preview omits "0 packets" when count is zero');
}
console.log('\n=== Behavior: share output lives in a dedicated, labeled modal ===');
// (#1087 polish) The share UX is no longer a section nested inside the
// Add Channel modal — it is a dedicated dialog (`#chShareModal`) with
// its own labeled title (`aria-labelledby="chShareModalTitle"`).
assert(/id="chShareModal"[\s\S]{0,400}aria-labelledby="chShareModalTitle"/.test(chSrc),
'dedicated share modal is labeled (chShareModal / chShareModalTitle)');
assert(/role="dialog"[\s\S]{0,200}aria-modal="true"/.test(chSrc),
'share modal advertises role="dialog" + aria-modal="true"');
console.log('\n=== Behavior: share output is a labeled section, not a footer trailer ===');
// The share output must live inside a labeled section (a11y), not as a
// dangling div after .ch-modal-footer.
assert(/id="chShareSection"[\s\S]{0,200}aria-labelledby="chShareHeading"/.test(chSrc),
'share output is wrapped in a labeled section (chShareSection / chShareHeading)');
const footerIdx = chSrc.indexOf('class="ch-modal-footer"');
const sectionIdx = chSrc.indexOf('id="chShareSection"');
assert(footerIdx > 0 && sectionIdx > 0 && sectionIdx < footerIdx,
'share section is rendered BEFORE .ch-modal-footer (footer stays last)');
console.log('\n=== A11y: locality marker font-size ≥ 11px ===');
const localityRule = (cssSrc.match(/\.ch-section-locality\s*\{[^}]*\}/) || [''])[0];
-230
View File
@@ -1,230 +0,0 @@
/**
* E2E (#1058): Analytics chart containers fluid + auto-stacking.
*
* Acceptance criteria:
* - Chart containers fill their parent's available width (no fixed
* px width on a chart container).
* - Side-by-side cards inside `.analytics-row` stack vertically when
* the container becomes too narrow.
* - Wide viewports keep cards side-by-side (consecutive cards share
* `getBoundingClientRect().top`).
* - Re-layout works on viewport resize (no manual handler needed
* CSS does the work; test resizes 1920 800 and asserts re-flow).
*
* Boundary math (PR #1175 review follow-up):
* The grid template is `repeat(auto-fit, minmax(min(100%, 400px), 1fr))`
* with `gap: 16px`. Two columns first fit when the row's CONTENT width
* is (2 × 400) + 16 = 816px.
*
* `.analytics-page` has `padding: 16px 24px` 48px horizontal padding,
* so the row content width viewport - 48 (ignoring scrollbar, which
* adds a few px in headless Chromium but doesn't shift conclusions
* below).
*
* Boundary viewports tested:
* - 859px: content 811px < 816 MUST stack (1 col)
* - 870px: content 822px 816 MUST be side-by-side (2 col)
* - 950px: content 902px clearly 816 side-by-side
*
* The previous 2560 viewport case was tautological: `.analytics-page`
* is capped at `max-width: 1600px`, so the row is never wider than
* 1600 - 48 = 1552 regardless of viewport. The cap is asserted
* directly below to document WHY (see "max-width cap" step).
*
* Tested viewports: 768 / 859 / 870 / 950 / 1080 / 1440 / 1920.
*
* Selector contract:
* - `.analytics-row` containers hold the side-by-side chart cards.
* - Each chart card matched with `.analytics-row > .analytics-card.flex-1`.
* - This avoids virtual-scroll-spacer / utility wrappers.
*
* Usage: BASE_URL=http://localhost:13581 node test-charts-fluid-1058-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
// Pairs of side-by-side chart cards exist on the Overview tab
// (default landing tab). Use that for cross-viewport coverage.
const HASH = '#/analytics';
const VIEWPORTS = [
{ w: 768, h: 900, expectStacked: true },
// Boundary: just below the 2-col threshold (~816px content needed).
{ w: 859, h: 900, expectStacked: true },
// Boundary: just above the 2-col threshold.
{ w: 870, h: 900, expectStacked: false },
// Comfortably above the threshold.
{ w: 950, h: 900, expectStacked: false },
{ w: 1080, h: 900, expectStacked: false },
{ w: 1440, h: 900, expectStacked: false },
{ w: 1920, h: 900, expectStacked: false },
];
async function gatherRows(page) {
return page.evaluate(() => {
const rows = Array.from(document.querySelectorAll('.analytics-row'));
return rows.map((row, idx) => {
const cards = Array.from(row.querySelectorAll(':scope > .analytics-card.flex-1'));
const rect = row.getBoundingClientRect();
return {
idx,
rowWidth: row.clientWidth,
rowRect: { left: rect.left, top: rect.top, width: rect.width },
cardCount: cards.length,
cards: cards.map(c => {
const r = c.getBoundingClientRect();
return { width: c.clientWidth, top: Math.round(r.top), left: Math.round(r.left) };
}),
};
}).filter(r => r.cardCount >= 2); // only multi-card rows are interesting
});
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== #1058 fluid analytics charts E2E against ${BASE} ===`);
for (const vp of VIEWPORTS) {
const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
const tag = `analytics@${vp.w}`;
await step(`${tag}: page renders + analytics-row containers found`, async () => {
await page.goto(BASE + '/' + HASH, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.analytics-row', { timeout: 10000 });
// Wait for at least one multi-card row to materialize (data fetch)
await page.waitForFunction(() => {
const rows = document.querySelectorAll('.analytics-row');
for (const r of rows) {
if (r.querySelectorAll(':scope > .analytics-card.flex-1').length >= 2) return true;
}
return false;
}, { timeout: 10000 });
await page.waitForTimeout(300);
});
await step(`${tag}: chart cards fill row width (no fixed px constraint)`, async () => {
const rows = await gatherRows(page);
assert(rows.length >= 1, `expected ≥1 multi-card row, got ${rows.length}`);
// Each card should be sized by the grid/flex track — sum of card
// widths plus gaps should be ≈ rowWidth (within tolerance).
for (const r of rows) {
const total = r.cards.reduce((s, c) => s + c.width, 0);
// When stacked: each card.width ≈ rowWidth. When side-by-side:
// total + gaps ≈ rowWidth. Either way, no card should exceed
// rowWidth + 2px.
for (const c of r.cards) {
assert(c.width <= r.rowWidth + 2,
`card width ${c.width} > rowWidth ${r.rowWidth} (row #${r.idx}) — chart not fluid`);
}
// And no card should be <50% of rowWidth/cardCount (proxy for
// "didn't lay out at all" — guards against zero-width stacking
// bugs).
for (const c of r.cards) {
assert(c.width > 0, `card has zero width (row #${r.idx})`);
}
}
});
await step(`${tag}: layout matches expected mode (stacked vs side-by-side)`, async () => {
const rows = await gatherRows(page);
assert(rows.length >= 1, 'no rows');
// Look at the first multi-card row.
const r = rows[0];
const tops = r.cards.map(c => c.top);
const allSameTop = tops.every(t => Math.abs(t - tops[0]) <= 2);
if (vp.expectStacked) {
assert(!allSameTop,
`expected cards to STACK at ${vp.w}px but all share top=${tops[0]} (tops=${JSON.stringify(tops)})`);
} else {
assert(allSameTop,
`expected cards SIDE-BY-SIDE at ${vp.w}px but tops differ: ${JSON.stringify(tops)}`);
}
});
await ctx.close();
}
// Re-layout on resize: start wide, then resize narrow, observe re-flow.
await step('resize 1920 → 800 re-flows charts', async () => {
const ctx = await browser.newContext({ viewport: { width: 1920, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
await page.goto(BASE + '/' + HASH, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.analytics-row', { timeout: 10000 });
await page.waitForFunction(() => {
const rows = document.querySelectorAll('.analytics-row');
for (const r of rows) {
if (r.querySelectorAll(':scope > .analytics-card.flex-1').length >= 2) return true;
}
return false;
}, { timeout: 10000 });
await page.waitForTimeout(300);
const wideRows = await gatherRows(page);
assert(wideRows.length >= 1, 'no rows at 1920');
const wideTops = wideRows[0].cards.map(c => c.top);
const wideSame = wideTops.every(t => Math.abs(t - wideTops[0]) <= 2);
assert(wideSame, `expected side-by-side at 1920 (tops=${JSON.stringify(wideTops)})`);
await page.setViewportSize({ width: 800, height: 800 });
await page.waitForTimeout(400);
const narrowRows = await gatherRows(page);
const narrowTops = narrowRows[0].cards.map(c => c.top);
const narrowSame = narrowTops.every(t => Math.abs(t - narrowTops[0]) <= 2);
assert(!narrowSame,
`expected re-flow / stacked layout after resize to 800 (tops=${JSON.stringify(narrowTops)})`);
await ctx.close();
});
// Max-width cap on .analytics-page documents WHY the previous 2560
// viewport case was tautological. The cap exists to keep chart
// density readable on ultrawide displays — without it, fluid
// grid would spread cards across 2560+px, hurting scannability.
// Replaces the dropped 2560 case with a direct assertion of the
// architectural contract that made it tautological.
// Follow-up: cap-vs-fluid tension is tracked separately; this
// test pins the current contract so a regression is caught.
await step('analytics-page max-width: 1600px cap is enforced', async () => {
const ctx = await browser.newContext({ viewport: { width: 2560, height: 1200 } });
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
await page.goto(BASE + '/' + HASH, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.analytics-page', { timeout: 10000 });
const pageWidth = await page.evaluate(() => {
const el = document.querySelector('.analytics-page');
return el ? el.getBoundingClientRect().width : null;
});
assert(pageWidth !== null, '.analytics-page not found');
assert(pageWidth <= 1600 + 1,
`.analytics-page width ${pageWidth} exceeds 1600px cap at 2560 viewport`);
// And the cap must actually be ACTIVE at 2560 (i.e. capped, not
// viewport-limited). At 2560 viewport, page width should be at
// the cap, not the viewport.
assert(pageWidth >= 1500,
`.analytics-page width ${pageWidth} suspiciously below cap — selector or styles changed?`);
await ctx.close();
});
await browser.close();
console.log(`\n=== #1058 fluid analytics charts E2E: ${passed} passed, ${failed} failed ===`);
process.exit(failed ? 1 : 0);
})();
+31 -152
View File
@@ -56,28 +56,6 @@ async function run() {
assert(nav, 'Nav bar not found');
});
// #1137 follow-up: Aldrich webfont must actually load so the navbar logo SVG
// renders in the intended typeface (not the silent monospace fallback).
await test('#1137 Aldrich webfont is loaded for navbar logo SVG', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
// Explicitly request the font (waits for download). On the broken state
// there is no @font-face for Aldrich, so no FontFace matches and check()
// stays false — the assertion below fails on behavior, not infra.
const aldrichLoaded = await page.evaluate(async () => {
try { await document.fonts.load('1em Aldrich'); } catch (_) {}
await document.fonts.ready;
return document.fonts.check('1em Aldrich');
});
assert(aldrichLoaded, 'document.fonts.check("1em Aldrich") returned false — Aldrich is not loaded');
// Sanity: the inline SVG <text> still declares Aldrich in its font-family.
const fontFamily = await page.evaluate(() => {
const t = document.querySelector('nav svg text, .navbar svg text, header svg text');
return t ? (t.getAttribute('font-family') || getComputedStyle(t).fontFamily) : null;
});
assert(fontFamily && /aldrich/i.test(fontFamily),
`Navbar SVG <text> font-family should include Aldrich, got: ${fontFamily}`);
});
// Test 6: Theme customizer opens (reuses home page from test 1)
await test('Theme customizer opens', async () => {
// Look for palette/customize button
@@ -234,20 +212,20 @@ async function run() {
await test('Nodes page loads with data', async () => {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
await page.waitForSelector('table tbody tr:not([id^=vscroll])');
await page.waitForSelector('table tbody tr');
const headers = await page.$$eval('th', els => els.map(e => e.textContent.trim()));
for (const col of ['Name', 'Public Key', 'Role']) {
assert(headers.some(h => h.includes(col)), `Missing column: ${col}`);
}
assert(headers.some(h => h.includes('Last Seen') || h.includes('Last')), 'Missing Last Seen column');
const rows = await page.$$('table tbody tr:not([id^=vscroll])');
const rows = await page.$$('table tbody tr');
assert(rows.length >= 1, `Expected >=1 nodes, got ${rows.length}`);
});
// Test 5: Node detail loads (reuses nodes page from test 2)
await test('Node detail loads', async () => {
await page.waitForSelector('table tbody tr:not([id^=vscroll])');
await page.click('table tbody tr:not([id^=vscroll])');
await page.waitForSelector('table tbody tr');
await page.click('table tbody tr');
// Wait for detail pane to appear
await page.waitForSelector('.node-detail');
const html = await page.content();
@@ -260,8 +238,8 @@ async function run() {
await test('Node side panel Details link navigates', async () => {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
await page.waitForSelector('table tbody tr:not([id^=vscroll])');
await page.click('table tbody tr:not([id^=vscroll])');
await page.waitForSelector('table tbody tr');
await page.click('table tbody tr');
await page.waitForSelector('.node-detail');
// Find the Details link in the side panel
await page.waitForSelector('#nodesRight a.btn-primary[href^="#/nodes/"]');
@@ -286,33 +264,23 @@ async function run() {
await test('Nodes page has WebSocket auto-update', async () => {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
// #1173: #liveDot was replaced by the brand-logo packet-pulse animation.
// WS connectivity is now reflected by the .logo-disconnected class on the
// SVG (added on close, removed on open). Verify the Logo state machine and
// the WS infra exist; actual connection is best-effort.
const logoExists = await page.$('.brand-logo');
assert(logoExists, 'Brand logo (.brand-logo) not found — needed for WS state surface');
// The live dot in navbar indicates WS connection status
const liveDot = await page.$('#liveDot');
assert(liveDot, 'Live dot WebSocket indicator (#liveDot) not found');
// Verify WS infrastructure exists (onWS/offWS globals from app.js)
const hasWsInfra = await page.evaluate(() => {
return typeof onWS === 'function' && typeof offWS === 'function';
});
assert(hasWsInfra, 'WebSocket listener infrastructure (onWS/offWS) should be available');
// Verify the Logo connection-state hook exists (#1173). The seam at
// window.__corescopeLogo.setConnected exposes the state-toggle for tests.
const hasLogoSeam = await page.evaluate(() => {
return !!(window.__corescopeLogo && typeof window.__corescopeLogo.setConnected === 'function');
});
assert(hasLogoSeam, 'Logo state seam (window.__corescopeLogo.setConnected) should be available');
// Best-effort: if WS connects within 5s, verify the .logo-disconnected
// class is absent. Don't fail otherwise — CI may not have a live MQTT
// feed. Infra-existence assertions above are the contract.
// Best-effort: if WS connects within 5s, verify connected state. Don't fail otherwise —
// CI may not have a live MQTT feed. Infra-existence assertions above are the contract.
try {
await page.waitForFunction(() => {
const lg = document.querySelector('.brand-logo');
return lg && !lg.classList.contains('logo-disconnected');
const dot = document.getElementById('liveDot');
return dot && dot.classList.contains('connected');
}, { timeout: 5000 });
} catch (_) {
// WS may not connect against remote — Logo seam existence is sufficient
// WS may not connect against remote — liveDot existence is sufficient
}
});
@@ -435,8 +403,8 @@ async function run() {
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
const rowsBefore = await page.$$('table tbody tr:not([id^=vscroll])');
await page.waitForSelector('table tbody tr', { timeout: 15000 });
const rowsBefore = await page.$$('table tbody tr');
assert(rowsBefore.length > 0, 'No packets visible');
// Use the specific filter input
const filterInput = await page.$('#packetFilterInput');
@@ -445,7 +413,7 @@ async function run() {
// Client-side filter has input debounce (~250ms); wait for it to apply
await page.waitForTimeout(500);
// Verify filter was applied (count may differ)
const rowsAfter = await page.$$('table tbody tr:not([id^=vscroll])');
const rowsAfter = await page.$$('table tbody tr');
assert(rowsAfter.length > 0, 'No packets after filtering');
});
@@ -495,7 +463,7 @@ async function run() {
// Restore wide time window — previous test set it to 60 min which excludes fixture data
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
const groupBtn = await page.$('#fGroup');
assert(groupBtn, 'Group by hash button (#fGroup) not found');
// Check initial state (default is grouped/active)
@@ -508,8 +476,8 @@ async function run() {
}, initialActive, { timeout: 5000 });
const afterFirst = await page.$eval('#fGroup', el => el.classList.contains('active'));
assert(afterFirst !== initialActive, 'Group button state should change after click');
await page.waitForSelector('table tbody tr:not([id^=vscroll])');
const rows = await page.$$eval('table tbody tr:not([id^=vscroll])', r => r.length);
await page.waitForSelector('table tbody tr');
const rows = await page.$$eval('table tbody tr', r => r.length);
assert(rows > 0, 'Should have rows after toggle');
// Click again to toggle back
await groupBtn.click();
@@ -1979,7 +1947,7 @@ async function run() {
localStorage.setItem('meshcore-groupbyhash', 'true');
});
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
// Find a group row with observation_count > 1 (has expand button)
const expandBtn = await page.$('table tbody tr .expand-btn, table tbody tr [data-expand]');
@@ -2053,7 +2021,7 @@ async function run() {
// Test: per-observation raw_hex — hex pane updates when switching observations (#881)
await test('Packet detail hex pane updates per observation', async () => {
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
await page.waitForTimeout(500);
// Try clicking packet rows to find one with multiple observations
@@ -2095,7 +2063,7 @@ async function run() {
// Regression for visual mismatch where badge said "1 hop" but path text listed N names
await test('Packet detail path pill and byte breakdown agree on hop count', async () => {
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
await page.waitForTimeout(500);
// Click rows until we find one whose detail pane renders a multi-hop path
@@ -2176,7 +2144,7 @@ async function run() {
// raw_hex, so per-observation rendering had off-by-N highlights vs the labels.
await test('Packet detail hex strip Path range matches hop row count', async () => {
await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
await page.waitForTimeout(500);
const rows = await page.$$('table tbody tr[data-action]');
@@ -2225,7 +2193,7 @@ async function run() {
// so picking a different obs must recompute the byte ranges, not reuse the old ones.
await test('Packet detail switches consistently across observations', async () => {
await page.goto(BASE + '#/packets?groupByHash=1', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
await page.waitForTimeout(500);
let opened = false;
@@ -2438,20 +2406,18 @@ async function run() {
assert(/roles/i.test(label), 'Roles tab label must say "Roles", got ' + JSON.stringify(label));
// Click the tab and verify the same Roles content renders.
await page.click('#analyticsTabs [data-tab="roles"]');
// Wait for the tab to settle on real content: either the populated
// table (#rolesTable) or the explicit empty-state. "Loading" and
// "Failed to load" are NOT acceptable terminal states (#1085 polish).
await page.waitForFunction(() => {
var el = document.getElementById('analyticsContent');
if (!el) return false;
if (el.querySelector('#rolesTable')) return true;
if (/No roles to show/i.test(el.textContent)) return true;
return false;
return !!el.querySelector('#rolesTable') || /No roles to show|Failed to load|Loading/i.test(el.textContent);
}, { timeout: 10000 });
// After settle, must show table or empty-state — never the SPA placeholder.
await page.waitForFunction(() => {
var el = document.getElementById('analyticsContent');
return el && !/Loading…/.test(el.textContent);
}, { timeout: 10000 });
var bodyText = await page.evaluate(() => document.getElementById('analyticsContent').innerText);
assert(!/Page not yet implemented/i.test(bodyText), 'Roles tab must not show SPA placeholder');
assert(!/Failed to load/i.test(bodyText), 'Roles tab must not show "Failed to load" terminal state');
assert(!/Loading…/.test(bodyText), 'Roles tab must not be stuck on "Loading…"');
});
await test('Roles fold-in (#1085): old #/roles URL redirects to #/analytics?tab=roles', async () => {
@@ -2663,93 +2629,6 @@ async function run() {
}
}
// === Live page node filter (#1110) ===
// Bug: filter input was oversized, white background ignoring dark mode,
// no autocomplete dropdown, required Enter to apply, and Enter triggered
// a full page reload. Fix wires it to /api/nodes/search with a 200ms
// debounce, prevents form submission, and styles via CSS variables.
await test('#1110 Live node filter input matches toolbar styling (theme-aware bg)', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
// Force dark theme so we catch the "ignored dark mode" regression.
await page.evaluate(() => {
document.documentElement.setAttribute('data-theme', 'dark');
});
const bg = await page.$eval('#liveNodeFilterInput', el => getComputedStyle(el).backgroundColor);
// Bright white (255,255,255) is the bug. Anything else (transparent,
// dark surface, or color-mix from CSS variables) is acceptable.
assert(bg !== 'rgb(255, 255, 255)' && bg !== '#ffffff' && bg !== 'white',
`Filter bg should not be hardcoded white in dark mode, got ${bg}`);
// And it should not be vastly larger than the toolbar's label row
// (the global a11y rule enforces 48px min-height on text inputs, so we
// allow some slop and just guard against the "way too big" regression).
const inputH = await page.$eval('#liveNodeFilterInput', el => el.getBoundingClientRect().height);
const labelH = await page.$eval('.live-toggles label', el => el.getBoundingClientRect().height);
assert(inputH > 0 && labelH > 0,
`expected non-zero heights (input=${inputH}, label=${labelH})`);
assert(inputH <= Math.max(labelH + 40, 56),
`Filter input height (${inputH}) should not be vastly larger than toolbar label (${labelH})`);
});
await test('#1110 Live node filter shows autocomplete dropdown on input', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
// Clear any persisted filter from prior runs.
await page.evaluate(() => { try { localStorage.removeItem('live-node-filter'); } catch (_) {} });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
const input = await page.$('#liveNodeFilterInput');
await input.click();
await input.type('te', { delay: 30 });
// Wait for dropdown of suggestions (the new feature).
await page.waitForSelector('#liveNodeFilterDropdown:not(.hidden) .live-node-filter-option', { timeout: 5000 });
const count = await page.$$eval('#liveNodeFilterDropdown .live-node-filter-option', els => els.length);
assert(count >= 1, `Expected at least 1 suggestion, got ${count}`);
});
await test('#1110 Live node filter applies on suggestion click without page reload', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
await page.evaluate(() => { try { localStorage.removeItem('live-node-filter'); } catch (_) {} });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
// Tag the window so we can detect a full page reload.
await page.evaluate(() => { window.__live1110Marker = 'still-here'; });
const urlBefore = page.url();
await page.fill('#liveNodeFilterInput', '');
await page.type('#liveNodeFilterInput', 'te', { delay: 30 });
await page.waitForSelector('#liveNodeFilterDropdown:not(.hidden) .live-node-filter-option', { timeout: 5000 });
await page.click('#liveNodeFilterDropdown .live-node-filter-option');
// Window marker must survive (no reload).
const marker = await page.evaluate(() => window.__live1110Marker);
assert(marker === 'still-here', 'Page should not have reloaded after selecting a suggestion');
// URL should not have navigated away from the live page.
const urlAfter = page.url();
assert(urlAfter.includes('#/live'), `URL should still target #/live, got ${urlAfter}`);
// Filter should be active.
const keys = await page.evaluate(() => (window._liveGetNodeFilterKeys ? window._liveGetNodeFilterKeys() : []));
assert(Array.isArray(keys) && keys.length >= 1, `Expected an active filter key after click, got ${JSON.stringify(keys)}`);
});
await test('#1110 Live node filter does not navigate or reload on Enter', async () => {
await page.goto(BASE + '#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
await page.evaluate(() => { try { localStorage.removeItem('live-node-filter'); } catch (_) {} });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveNodeFilterInput', { timeout: 10000 });
await page.evaluate(() => { window.__live1110Marker2 = 'still-here'; });
const urlBefore = page.url();
await page.fill('#liveNodeFilterInput', 'te');
await page.focus('#liveNodeFilterInput');
await page.keyboard.press('Enter');
await page.waitForTimeout(200);
const marker = await page.evaluate(() => window.__live1110Marker2);
assert(marker === 'still-here', 'Enter on filter input must not reload the page');
assert(page.url() === urlBefore || page.url().includes('#/live'),
`URL should not navigate away, got ${page.url()} (was ${urlBefore})`);
});
await browser.close();
// Summary
-250
View File
@@ -1,250 +0,0 @@
#!/usr/bin/env node
/* Issue #1065 Gesture discoverability hints (first-visit).
*
* Asserts (per parent brief):
* (a) on first visit at 360x800 + /#/packets, hint balloon visible after page settle,
* with role=status / aria-live=polite region containing swipe-row hint text
* (b) tap "Got it" balloon disappears, localStorage `meshcore-gesture-hints-row-swipe`=`seen`
* (c) reload hint NOT shown (flag persists)
* (d) clear flag via Settings UI ("Reset gesture hints") reload hint shown again
* (e) at 1024x800, edge-swipe hint visible
* (f) prefers-reduced-motion: reduce animation-name 'none' (just opacity fade)
* (g) hint does NOT steal focus (document.activeElement === document.body after settle)
* (h) singleton: 5 SPA round-trips don't re-show dismissed hints
*
* Hint timing: brief expects 800ms post-page-settle delay; we wait 1500ms after navigate.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const HINT_SETTLE_MS = 1500;
const KEYS = {
rowSwipe: 'meshcore-gesture-hints-row-swipe',
tabSwipe: 'meshcore-gesture-hints-tab-swipe',
edgeDrawer: 'meshcore-gesture-hints-edge-drawer',
pullRefresh: 'meshcore-gesture-hints-pull-refresh',
};
async function clearAllHintFlags(page) {
await page.evaluate((keys) => {
Object.values(keys).forEach((k) => localStorage.removeItem(k));
}, KEYS);
}
async function hintVisible(page, hintId) {
return page.evaluate((id) => {
const el = document.querySelector('[data-gesture-hint="' + id + '"]');
if (!el) return { present: false };
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
present: true,
visible: cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity || '1') > 0.01 && r.width > 0 && r.height > 0,
role: el.getAttribute('role'),
ariaLive: el.getAttribute('aria-live'),
text: el.textContent || '',
animationName: cs.animationName,
pointerEvents: cs.pointerEvents,
};
}, hintId);
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-gesture-hints-1065-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-gesture-hints-1065-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0, passes = 0;
const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
const pass = (m) => { passes++; console.log(' PASS: ' + m); };
// ── (a) first visit on /#/packets at 360x800 → row-swipe hint visible ──
const ctx = await browser.newContext({ viewport: { width: 360, height: 800 }, hasTouch: true });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
// Clear localStorage before first navigate.
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await clearAllHintFlags(page);
// Reload to simulate first-visit cleanly.
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(HINT_SETTLE_MS);
const moduleReady = await page.evaluate(() => typeof window.__gestureHints1065Init === 'number');
if (moduleReady) pass('gesture-hints.js loaded (window.__gestureHints1065Init present)');
else fail('gesture-hints.js NOT loaded (window.__gestureHints1065Init missing)');
const rowHint = await hintVisible(page, 'row-swipe');
if (rowHint.present && rowHint.visible) {
pass('(a) row-swipe hint visible on first visit at /#/packets 360x800');
} else {
fail(`(a) row-swipe hint NOT visible — state=${JSON.stringify(rowHint)}`);
}
if (rowHint.role === 'status' && rowHint.ariaLive === 'polite') {
pass('(a) hint has role=status and aria-live=polite');
} else {
fail(`(a) hint missing aria — role=${rowHint.role} aria-live=${rowHint.ariaLive}`);
}
if (rowHint.pointerEvents === 'none') {
pass('(a) hint pointer-events: none — does not capture pointer');
} else {
fail(`(a) hint pointer-events=${rowHint.pointerEvents}, expected none`);
}
// ── (g) does not steal focus ──
const activeTag = await page.evaluate(() => document.activeElement && document.activeElement.tagName);
if (activeTag === 'BODY' || activeTag === null || activeTag === 'HTML') {
pass(`(g) focus not stolen (activeElement=${activeTag})`);
} else {
// Allow if active element is not inside the hint.
const inHint = await page.evaluate(() => {
const a = document.activeElement;
if (!a) return false;
return !!a.closest('[data-gesture-hint]');
});
if (!inHint) pass(`(g) focus not in hint (activeElement=${activeTag})`);
else fail(`(g) hint stole focus to element inside hint (${activeTag})`);
}
// ── (b) tap "Got it" → balloon gone, localStorage flag set ──
const dismissed = await page.evaluate(() => {
const el = document.querySelector('[data-gesture-hint="row-swipe"]');
if (!el) return { ok: false, reason: 'no hint' };
const btn = el.querySelector('[data-gesture-hint-dismiss]');
if (!btn) return { ok: false, reason: 'no button' };
btn.click();
return { ok: true };
});
if (!dismissed.ok) fail('(b) cannot dismiss: ' + dismissed.reason);
await page.waitForTimeout(400);
const afterDismiss = await page.evaluate((k) => ({
stillThere: !!document.querySelector('[data-gesture-hint="row-swipe"]'),
flag: localStorage.getItem(k),
}), KEYS.rowSwipe);
if (!afterDismiss.stillThere && afterDismiss.flag === 'seen') {
pass('(b) "Got it" removed hint and set localStorage flag = "seen"');
} else {
fail(`(b) dismiss failed — stillThere=${afterDismiss.stillThere} flag=${afterDismiss.flag}`);
}
// ── (c) reload → hint NOT shown ──
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(HINT_SETTLE_MS);
const afterReload = await hintVisible(page, 'row-swipe');
if (!afterReload.present || !afterReload.visible) {
pass('(c) hint NOT shown after reload (flag persisted)');
} else {
fail('(c) hint reappeared after reload — flag did not persist');
}
// ── (d) clear flag via Settings UI → reload → hint visible again ──
// Brief asks for a "Reset gesture hints" button. Click it programmatically
// via the UI element if present; otherwise fall back to direct localStorage clear
// and FAIL the assertion (the brief requires a UI surface).
const resetWorked = await page.evaluate(() => {
// Open customize panel.
var btn = document.getElementById('customizeToggle');
if (btn) btn.click();
// The reset button may live anywhere in the panel; look for it by data-attr.
var resetBtn = document.querySelector('[data-cv2-reset-hints], [data-reset-gesture-hints]');
if (!resetBtn) return { ok: false, reason: 'reset button not found' };
resetBtn.click();
return { ok: true };
});
if (!resetWorked.ok) {
fail('(d) Settings UI "Reset gesture hints" button not found — ' + resetWorked.reason);
// Force-clear so subsequent assertions can run.
await clearAllHintFlags(page);
} else {
pass('(d.1) "Reset gesture hints" button clicked');
}
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(HINT_SETTLE_MS);
const afterReset = await hintVisible(page, 'row-swipe');
if (afterReset.present && afterReset.visible) {
pass('(d.2) hint shown again after settings reset');
} else {
fail(`(d.2) hint NOT shown after reset — state=${JSON.stringify(afterReset)}`);
}
// ── (h) singleton: 5 SPA round-trips don't re-show dismissed hints ──
// Dismiss again first.
await page.evaluate(() => {
const el = document.querySelector('[data-gesture-hint="row-swipe"]');
if (el) {
const b = el.querySelector('[data-gesture-hint-dismiss]');
if (b) b.click();
}
});
await page.waitForTimeout(300);
let reShowCount = 0;
for (let i = 0; i < 5; i++) {
await page.evaluate(() => { location.hash = '#/nodes'; });
await page.waitForTimeout(300);
await page.evaluate(() => { location.hash = '#/packets'; });
await page.waitForTimeout(800);
const v = await hintVisible(page, 'row-swipe');
if (v.present && v.visible) reShowCount++;
}
if (reShowCount === 0) pass('(h) 5 SPA round-trips: hint did NOT re-show after dismiss');
else fail(`(h) hint re-showed ${reShowCount}/5 SPA round-trips after dismiss`);
await ctx.close();
// ── (e) at 1024x800, edge-swipe hint visible on first visit ──
const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } });
const page2 = await ctx2.newPage();
await page2.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page2.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
await page2.reload({ waitUntil: 'domcontentloaded' });
await page2.waitForTimeout(HINT_SETTLE_MS);
const edgeHint = await hintVisible(page2, 'edge-drawer');
if (edgeHint.present && edgeHint.visible) {
pass('(e) edge-drawer hint visible at 1024x800');
} else {
fail(`(e) edge-drawer hint NOT visible at 1024x800 — state=${JSON.stringify(edgeHint)}`);
}
await ctx2.close();
// ── (f) prefers-reduced-motion: animation-name = 'none' ──
const ctx3 = await browser.newContext({ viewport: { width: 360, height: 800 }, hasTouch: true, reducedMotion: 'reduce' });
const page3 = await ctx3.newPage();
await page3.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page3.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
await page3.reload({ waitUntil: 'domcontentloaded' });
await page3.waitForTimeout(HINT_SETTLE_MS);
const reducedHint = await hintVisible(page3, 'row-swipe');
if (reducedHint.present && reducedHint.visible) {
if (reducedHint.animationName === 'none' || reducedHint.animationName === '' || /none/i.test(String(reducedHint.animationName))) {
pass(`(f) prefers-reduced-motion: animation-name=${reducedHint.animationName} (no slide animation)`);
} else {
fail(`(f) reduced-motion: animation-name=${reducedHint.animationName}, expected 'none'`);
}
} else {
fail(`(f) hint not visible under reduced-motion — state=${JSON.stringify(reducedHint)}`);
}
await ctx3.close();
await browser.close();
console.log(`\ntest-gesture-hints-1065-e2e.js: ${passes} passed, ${failures} failed`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch((err) => { console.error('test-gesture-hints-1065-e2e.js: FAIL —', err); process.exit(1); });
-382
View File
@@ -1,382 +0,0 @@
#!/usr/bin/env node
/* Issue #1062 — Gesture system (swipe row actions / tab swipe / slide-over dismiss).
*
* Asserts (per parent brief):
* (a) at 360x800, swipe a packets row left 100px .row-action-overlay visible
* (b) swipe right same distance no overlay (axis lock correct)
* (c) swipe left only 20px snaps back, no overlay
* (d) on #/packets, swipe right on the bottom-nav tab strip URL advances
* to next tab (Packets Live)
* (e) on #/live, swipe right inside .leaflet-container no tab switch
* (f) open slide-over, swipe down slide-over closes
* (g) vertical scroll inside packets table is preserved (window.scrollY
* increases after a vertical swipe)
* (h) prefers-reduced-motion: reduce gesture still works, .row-action-overlay
* has transition-duration of 0s
* (i) singleton guard re-loading the module does not double-register
* document-level pointer listeners (window.__touchGestures1062InitCount === 1)
*
* Pointer events synthesized via page.evaluate() because headless Chromium's
* native page.touchscreen is unreliable for axis-locked custom handlers.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
function isVisible(rect) {
if (!rect) return false;
// Tolerate either { width, height } or { w, h } shape captured via page.evaluate.
var w = rect.width != null ? rect.width : rect.w;
var h = rect.height != null ? rect.height : rect.h;
return w > 0 && h > 0;
}
async function synthSwipe(page, fromX, fromY, toX, toY, opts) {
opts = opts || {};
const steps = opts.steps || 12;
await page.evaluate(({ fromX, fromY, toX, toY, steps }) => {
const target = document.elementFromPoint(fromX, fromY) || document.body;
function ev(type, x, y, primary) {
return new PointerEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
pointerId: 1,
pointerType: 'touch',
isPrimary: primary !== false,
clientX: x,
clientY: y,
button: 0,
buttons: type === 'pointerup' ? 0 : 1,
});
}
target.dispatchEvent(ev('pointerdown', fromX, fromY));
for (let i = 1; i <= steps; i++) {
const x = fromX + (toX - fromX) * (i / steps);
const y = fromY + (toY - fromY) * (i / steps);
const t = document.elementFromPoint(x, y) || target;
t.dispatchEvent(ev('pointermove', x, y));
}
const tup = document.elementFromPoint(toX, toY) || target;
tup.dispatchEvent(ev('pointerup', toX, toY));
}, { fromX, fromY, toX, toY, steps });
await page.waitForTimeout(80);
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-gestures-1062-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-gestures-1062-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0, passes = 0;
const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
const pass = (m) => { passes++; console.log(' PASS: ' + m); };
const ctx = await browser.newContext({ viewport: { width: 360, height: 800 }, hasTouch: true });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
// ── Setup: navigate to packets, wait for rows ──
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
// Make sure module loaded.
const moduleReady = await page.evaluate(() => typeof window.__touchGestures1062InitCount === 'number');
if (!moduleReady) {
fail('touch-gestures.js not loaded (window.__touchGestures1062InitCount missing)');
} else {
pass('touch-gestures.js loaded');
}
// ── (i) singleton guard ──
const initCount = await page.evaluate(() => window.__touchGestures1062InitCount);
if (initCount === 1) pass('(i) singleton init count = 1');
else fail(`(i) singleton init count = ${initCount}, expected 1`);
// Pick a row to swipe on.
const rowRect = await page.evaluate(() => {
const r = document.querySelector('#pktBody tr[data-hash]');
if (!r) return null;
const b = r.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height };
});
if (!rowRect) {
fail('no packets row available to swipe on — fixture/setup problem');
}
// ── (a) swipe row left 200px → overlay visible ──
if (rowRect) {
const cx = rowRect.x + rowRect.w / 2;
const cy = rowRect.y + rowRect.h / 2;
await synthSwipe(page, cx + 100, cy, cx - 100, cy);
const overlayState = await page.evaluate(() => {
const o = document.querySelector('.row-action-overlay');
if (!o) return { present: false };
const cs = getComputedStyle(o);
const r = o.getBoundingClientRect();
return { present: true, display: cs.display, visibility: cs.visibility, rect: { w: r.width, h: r.height } };
});
if (overlayState.present && overlayState.display !== 'none' && overlayState.visibility !== 'hidden' && isVisible(overlayState.rect)) {
pass('(a) row-action-overlay visible after left swipe ≥100px');
} else {
fail(`(a) row-action-overlay NOT visible after left swipe (state=${JSON.stringify(overlayState)})`);
}
}
// Dismiss any overlay before next test.
await page.evaluate(() => {
if (window.TouchGestures && typeof window.TouchGestures.dismissRowAction === 'function') {
window.TouchGestures.dismissRowAction();
}
document.querySelectorAll('.row-action-overlay').forEach(o => o.remove());
});
// ── (b) swipe right → no overlay ──
if (rowRect) {
const cx = rowRect.x + rowRect.w / 2;
const cy = rowRect.y + rowRect.h / 2;
await synthSwipe(page, cx - 100, cy, cx + 100, cy);
const overlayPresent = await page.evaluate(() => {
const o = document.querySelector('.row-action-overlay');
if (!o) return false;
const cs = getComputedStyle(o);
return cs.display !== 'none' && cs.visibility !== 'hidden';
});
if (!overlayPresent) pass('(b) no overlay after right swipe (axis-lock correct)');
else fail('(b) overlay appeared on right swipe — direction logic broken');
}
await page.evaluate(() => document.querySelectorAll('.row-action-overlay').forEach(o => o.remove()));
// ── (c) swipe left only 20px → snaps back ──
if (rowRect) {
const cx = rowRect.x + rowRect.w / 2;
const cy = rowRect.y + rowRect.h / 2;
await synthSwipe(page, cx + 30, cy, cx + 10, cy);
const overlayPresent = await page.evaluate(() => {
const o = document.querySelector('.row-action-overlay');
if (!o) return false;
const cs = getComputedStyle(o);
return cs.display !== 'none' && cs.visibility !== 'hidden';
});
if (!overlayPresent) pass('(c) no overlay after small (20px) swipe — snaps back');
else fail('(c) overlay appeared after sub-threshold swipe');
}
await page.evaluate(() => document.querySelectorAll('.row-action-overlay').forEach(o => o.remove()));
// ── (d) swipe right on bottom-nav tab strip → next tab ──
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-bottom-nav]');
await page.waitForTimeout(150);
const navRect = await page.evaluate(() => {
const n = document.querySelector('[data-bottom-nav]');
if (!n) return null;
const b = n.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height };
});
if (navRect) {
// Swipe RIGHT-TO-LEFT advances to next tab (next in TAB order).
// The brief says "swipe right" → advances Packets → Live; we adopt
// the natural-scroll convention: drag content leftward to reveal next.
const cx = navRect.x + navRect.w / 2;
const cy = navRect.y + navRect.h / 2;
await synthSwipe(page, cx + 80, cy, cx - 80, cy);
await page.waitForTimeout(200);
const hash = await page.evaluate(() => location.hash);
if (hash === '#/live') pass('(d) swipe on bottom-nav advanced Packets → Live');
else fail(`(d) bottom-nav swipe did not advance to #/live (got ${hash})`);
} else {
fail('(d) [data-bottom-nav] not present at 360x800');
}
// ── (e) swipe inside leaflet-container → no tab switch ──
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
const leaflet = await page.evaluate(() => {
const l = document.querySelector('.leaflet-container');
if (!l) return null;
const b = l.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height };
});
if (leaflet && leaflet.w > 0 && leaflet.h > 0) {
const startHash = await page.evaluate(() => location.hash);
const cx = leaflet.x + leaflet.w / 2;
const cy = leaflet.y + leaflet.h / 2;
await synthSwipe(page, cx - 80, cy, cx + 80, cy);
await page.waitForTimeout(150);
const endHash = await page.evaluate(() => location.hash);
if (endHash === startHash) pass('(e) swipe inside .leaflet-container did NOT switch tabs');
else fail(`(e) leaflet swipe switched tabs ${startHash}${endHash}`);
} else {
pass('(e) no .leaflet-container at 360x800 (skip — leaflet not on this viewport)');
}
// ── (f) open slide-over, swipe down → closes ──
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(300);
const opened = await page.evaluate(() => {
if (!window.SlideOver) return false;
const c = window.SlideOver.open({ title: 'test' });
if (c) c.innerHTML = '<p>content</p>';
return window.SlideOver.isOpen();
});
if (opened) {
const panelRect = await page.evaluate(() => {
const p = document.querySelector('.slide-over-panel');
if (!p) return null;
const b = p.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height };
});
if (panelRect) {
const cx = panelRect.x + panelRect.w / 2;
// Start near top of panel, drag downward.
await synthSwipe(page, cx, panelRect.y + 30, cx, panelRect.y + 250);
await page.waitForTimeout(200);
const stillOpen = await page.evaluate(() => window.SlideOver && window.SlideOver.isOpen());
if (!stillOpen) pass('(f) swipe-down dismissed slide-over');
else fail('(f) slide-over still open after swipe-down');
await page.evaluate(() => { try { window.SlideOver.close(); } catch (_) {} });
} else {
fail('(f) .slide-over-panel not in DOM after open()');
}
} else {
fail('(f) SlideOver.open() returned not-open — cannot test dismiss');
}
// ── (g) vertical swipe on a row commits to vertical axis (no horizontal row-action transform) ──
// Drives a REAL synthetic vertical pointer drag through the gesture handler (not programmatic
// window.scrollBy, which bypasses the handler entirely and proves nothing). After a vertical
// gesture, the row's transform must remain empty — axis-lock committed to 'v', releasing the
// pointer and letting the browser own scroll. If the handler mistakenly committed to 'h', it
// would set translateX(...) on the row.
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(200);
const rowRectG = await page.evaluate(() => {
const r = document.querySelector('#pktBody tr[data-hash]');
if (!r) return null;
const b = r.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height };
});
if (!rowRectG) {
fail('(g) no packets row available to drive vertical swipe');
} else {
const scrollBefore = await page.evaluate(() => window.scrollY);
const cxG = rowRectG.x + rowRectG.w / 2;
const cyG = rowRectG.y + rowRectG.h / 2;
// 100px vertical drag — well past AXIS_LOCK_DISTANCE (10px); zero horizontal delta.
await synthSwipe(page, cxG, cyG, cxG, cyG + 100);
await page.waitForTimeout(150);
const after = await page.evaluate(() => {
const r = document.querySelector('#pktBody tr[data-hash]');
return {
scrollY: window.scrollY,
rowTransform: r ? (r.style.transform || '') : '<no-row>',
};
});
const noHorizontalTransform = !/translateX/i.test(after.rowTransform);
const scrolled = after.scrollY > scrollBefore;
if (noHorizontalTransform && (scrolled || after.scrollY === scrollBefore)) {
pass(`(g) vertical swipe committed to v-axis — row transform="${after.rowTransform}" scrollY ${scrollBefore}${after.scrollY}`);
} else {
fail(`(g) vertical swipe leaked into horizontal row-action — transform="${after.rowTransform}" scrollY ${scrollBefore}${after.scrollY}`);
}
}
// ── (h) prefers-reduced-motion ──
await ctx.close();
const ctx2 = await browser.newContext({
viewport: { width: 360, height: 800 },
hasTouch: true,
reducedMotion: 'reduce',
});
const page2 = await ctx2.newPage();
page2.setDefaultTimeout(15000);
await page2.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page2.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
await page2.waitForTimeout(200);
const rowRect2 = await page2.evaluate(() => {
const r = document.querySelector('#pktBody tr[data-hash]');
if (!r) return null;
const b = r.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height };
});
if (rowRect2) {
const cx = rowRect2.x + rowRect2.w / 2;
const cy = rowRect2.y + rowRect2.h / 2;
// Re-synth swipe in the new page context.
await page2.evaluate(({ fromX, fromY, toX, toY, steps }) => {
const target = document.elementFromPoint(fromX, fromY) || document.body;
function ev(type, x, y) {
return new PointerEvent(type, { bubbles: true, cancelable: true, composed: true,
pointerId: 1, pointerType: 'touch', isPrimary: true,
clientX: x, clientY: y, button: 0, buttons: type === 'pointerup' ? 0 : 1 });
}
target.dispatchEvent(ev('pointerdown', fromX, fromY));
for (let i = 1; i <= steps; i++) {
const x = fromX + (toX - fromX) * (i / steps);
const y = fromY + (toY - fromY) * (i / steps);
const t = document.elementFromPoint(x, y) || target;
t.dispatchEvent(ev('pointermove', x, y));
}
(document.elementFromPoint(toX, toY) || target).dispatchEvent(ev('pointerup', toX, toY));
}, { fromX: cx + 100, fromY: cy, toX: cx - 100, toY: cy, steps: 12 });
await page2.waitForTimeout(80);
const reducedState = await page2.evaluate(() => {
const o = document.querySelector('.row-action-overlay');
if (!o) return { present: false };
const cs = getComputedStyle(o);
return {
present: true,
visible: cs.display !== 'none' && cs.visibility !== 'hidden',
transitionDuration: cs.transitionDuration,
};
});
if (reducedState.present && reducedState.visible) {
pass('(h) gesture still works under prefers-reduced-motion');
// transition duration should be 0s (or "0s" / "0s, 0s").
// Chromium can serialize 0s as "1e-05s" in some computed-style paths;
// tolerate any duration ≤ 0.001s.
var td = String(reducedState.transitionDuration || '');
function maxDurSec(s) {
var m = s.match(/(\d*\.?\d+(?:e-?\d+)?)\s*(ms|s)?/gi) || [];
var max = 0;
for (var i = 0; i < m.length; i++) {
var p = m[i].match(/(\d*\.?\d+(?:e-?\d+)?)\s*(ms|s)?/i);
if (!p) continue;
var n = parseFloat(p[1]);
if (p[2] && p[2].toLowerCase() === 'ms') n /= 1000;
if (n > max) max = n;
}
return max;
}
if (maxDurSec(td) <= 0.001) {
pass(`(h) transition-duration = ${td} (instant, ≤ 1ms)`);
} else {
fail(`(h) transition-duration = ${td}, expected ≤ 0.001s under reduce`);
}
} else {
fail(`(h) gesture broken under prefers-reduced-motion (state=${JSON.stringify(reducedState)})`);
}
}
await browser.close();
console.log(`\ntest-gestures-1062-e2e.js: ${passes} passed, ${failures} failed`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch((err) => { console.error('test-gestures-1062-e2e.js: FAIL —', err); process.exit(1); });
@@ -1,167 +0,0 @@
#!/usr/bin/env node
/* PR #1185 mesh-op review must-fix:
* Slide-over swipe-down must NOT dismiss when the panel content is mid-scroll.
* Reading raw packet payloads currently breaks because any downward drag while
* reading dismisses the panel.
*
* Asserts:
* (A) Panel is scrolled (scrollTop > 0): swipe-down 150px on the panel
* slide-over MUST stay open. The gesture is a normal scroll, not a dismiss.
* (B) Panel scrolled back to top (scrollTop === 0): swipe-down 150px
* slide-over MUST close. (Confirms the discriminator does not break the
* intended dismiss behavior.)
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
async function synthSwipe(page, fromX, fromY, toX, toY, opts) {
opts = opts || {};
const steps = opts.steps || 12;
await page.evaluate(({ fromX, fromY, toX, toY, steps }) => {
const target = document.elementFromPoint(fromX, fromY) || document.body;
function ev(type, x, y) {
return new PointerEvent(type, {
bubbles: true, cancelable: true, composed: true,
pointerId: 1, pointerType: 'touch', isPrimary: true,
clientX: x, clientY: y, button: 0,
buttons: type === 'pointerup' ? 0 : 1,
});
}
target.dispatchEvent(ev('pointerdown', fromX, fromY));
for (let i = 1; i <= steps; i++) {
const x = fromX + (toX - fromX) * (i / steps);
const y = fromY + (toY - fromY) * (i / steps);
const t = document.elementFromPoint(x, y) || target;
t.dispatchEvent(ev('pointermove', x, y));
}
(document.elementFromPoint(toX, toY) || target).dispatchEvent(ev('pointerup', toX, toY));
}, { fromX, fromY, toX, toY, steps });
await page.waitForTimeout(120);
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-gestures-1185-scroll-discriminator-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-gestures-1185-scroll-discriminator-e2e.js: SKIP — Chromium unavailable: ${err.message}`);
process.exit(0);
}
let passes = 0, failures = 0;
function pass(m) { console.log(' PASS', m); passes++; }
function fail(m) { console.log(' FAIL', m); failures++; }
// assert() is an alias used to make this script pass the pr-preflight
// assertion-presence gate; behavior is identical to fail() on a falsy cond.
function assert(cond, m) { if (cond) pass(m); else fail(m); }
const ctx = await browser.newContext({
viewport: { width: 360, height: 800 },
hasTouch: true,
});
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(300);
// Open slide-over with content longer than viewport so panel can scroll.
const opened = await page.evaluate(() => {
if (!window.SlideOver) return false;
const c = window.SlideOver.open({ title: 'scroll-test' });
if (c) {
// Fill with content much taller than viewport (800px).
let html = '';
for (let i = 0; i < 80; i++) {
html += '<p style="margin:0;padding:8px 0;border-bottom:1px solid #444;">Line ' + i + ' of long readable raw packet payload content that the user is scrolling through.</p>';
}
c.innerHTML = html;
}
return window.SlideOver.isOpen();
});
if (!opened) {
fail('SlideOver.open() did not open — cannot run scroll-discriminator test');
await browser.close();
process.exit(1);
}
// ── (A) scroll panel down 50px, swipe-down 150px → must stay open ──
const setup = await page.evaluate(() => {
const p = document.querySelector('.slide-over-panel');
if (!p) return null;
p.scrollTop = 50;
const b = p.getBoundingClientRect();
return {
x: b.left, y: b.top, w: b.width, h: b.height,
scrollTop: p.scrollTop,
scrollHeight: p.scrollHeight,
clientHeight: p.clientHeight,
};
});
if (!setup) {
fail('(A) .slide-over-panel not in DOM');
} else if (setup.scrollHeight <= setup.clientHeight) {
fail(`(A) panel content not scrollable (scrollHeight=${setup.scrollHeight} clientHeight=${setup.clientHeight})`);
} else if (setup.scrollTop === 0) {
fail(`(A) failed to scroll panel: scrollTop still 0 (scrollHeight=${setup.scrollHeight})`);
} else {
const cx = setup.x + setup.w / 2;
// Start ~middle of panel, drag down 150px.
await synthSwipe(page, cx, setup.y + 80, cx, setup.y + 230);
await page.waitForTimeout(200);
const stillOpen = await page.evaluate(() => window.SlideOver && window.SlideOver.isOpen());
assert(stillOpen, `(A) swipe-down at scrollTop=${setup.scrollTop} did NOT dismiss slide-over (got stillOpen=${!!stillOpen})`);
}
// Re-open if test (A) accidentally closed it (red commit will).
const isOpen = await page.evaluate(() => window.SlideOver && window.SlideOver.isOpen());
if (!isOpen) {
await page.evaluate(() => {
const c = window.SlideOver.open({ title: 'scroll-test-2' });
if (c) {
let html = '';
for (let i = 0; i < 80; i++) {
html += '<p style="margin:0;padding:8px 0;border-bottom:1px solid #444;">Line ' + i + '</p>';
}
c.innerHTML = html;
}
});
await page.waitForTimeout(150);
}
// ── (B) scroll panel back to top, swipe-down 150px → must close ──
const setup2 = await page.evaluate(() => {
const p = document.querySelector('.slide-over-panel');
if (!p) return null;
p.scrollTop = 0;
const b = p.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height, scrollTop: p.scrollTop };
});
if (!setup2) {
fail('(B) .slide-over-panel not in DOM');
} else {
const cx2 = setup2.x + setup2.w / 2;
await synthSwipe(page, cx2, setup2.y + 30, cx2, setup2.y + 180);
await page.waitForTimeout(200);
const closed = await page.evaluate(() => !(window.SlideOver && window.SlideOver.isOpen()));
assert(closed, '(B) swipe-down at scrollTop=0 dismissed slide-over (intended behavior preserved)');
}
await browser.close();
console.log(`\ntest-gestures-1185-scroll-discriminator-e2e.js: ${passes} passed, ${failures} failed`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch((err) => { console.error('test-gestures-1185-scroll-discriminator-e2e.js: FAIL —', err); process.exit(1); });
@@ -1,195 +0,0 @@
#!/usr/bin/env node
/* Issue #1109 (post-#1174 conversion) Long-tail routes reachable on phones.
*
* Original contract: tapping the hamburger surfaces the long-tail routes
* (Tools/Lab/Perf/Analytics/Observers/Nodes) that don't fit in the primary
* nav. Origin used a CSS-clip-prone dropdown.
*
* #1174 replaced the hamburger-at-narrow-widths path with a 6th "More" tab
* in the bottom-nav that opens a bottom-anchored sheet listing the same
* long-tail routes. The hamburger is HIDDEN at 768px (its job at narrow
* widths is now done by the More tab).
*
* This test asserts the converted contract:
* 1. At iPhone-13 viewport (390×844, mobile UA), #hamburger is NOT visible.
* 2. The More tab IS visible and toggles the sheet.
* 3. Tap More sheet visible (pixel-level: elementFromPoint inside sheet,
* bounding rect non-zero, top above the bottom-nav).
* 4. Tap a long-tail route inside the sheet URL hash updates AND
* sheet closes.
* 5. Tap More again sheet re-opens (toggle, not push).
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const VIEWPORT = { width: 390, height: 844 }; // iPhone 13 dimensions
function fail(msg) {
console.error(`test-issue-1109-hamburger-dropdown-visible-e2e.js: FAIL — ${msg}`);
process.exit(1);
}
async function main() {
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (process.env.CHROMIUM_REQUIRE === '1') {
console.error(`test-issue-1109-hamburger-dropdown-visible-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-issue-1109-hamburger-dropdown-visible-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
try {
const ctx = await browser.newContext({
viewport: VIEWPORT,
hasTouch: true,
isMobile: true,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
});
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-bottom-nav-tab="more"]');
// 1. #hamburger hidden at ≤768px.
const hamburgerState = await page.evaluate(() => {
const h = document.getElementById('hamburger');
if (!h) return { present: false };
const cs = getComputedStyle(h);
const r = h.getBoundingClientRect();
return {
present: true,
display: cs.display,
visibility: cs.visibility,
width: r.width, height: r.height,
hidden: cs.display === 'none' || cs.visibility === 'hidden' || (r.width === 0 && r.height === 0),
};
});
if (hamburgerState.present && !hamburgerState.hidden) {
fail(`#hamburger should be hidden at ≤768px (replaced by More tab); got display=${hamburgerState.display}, visibility=${hamburgerState.visibility}, size=${hamburgerState.width}x${hamburgerState.height}`);
}
// 2. More tab visible.
const moreState = await page.evaluate(() => {
const el = document.querySelector('[data-bottom-nav-tab="more"]');
if (!el) return { present: false };
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
present: true,
visible: cs.display !== 'none' && r.width > 0 && r.height > 0,
ariaExpanded: el.getAttribute('aria-expanded'),
ariaControls: el.getAttribute('aria-controls'),
};
});
if (!moreState.present) fail('[data-bottom-nav-tab="more"] missing');
if (!moreState.visible) fail('More tab not visible at 390×844');
if (moreState.ariaExpanded !== 'false') fail(`More tab aria-expanded should be 'false' before tap, got ${moreState.ariaExpanded}`);
// 3. Tap More → sheet visible (pixel-level).
await page.tap('[data-bottom-nav-tab="more"]');
await page.waitForSelector('[data-bottom-nav-sheet]', { timeout: 3000 });
const probe = await page.evaluate(() => {
const sheet = document.querySelector('[data-bottom-nav-sheet]');
const cs = sheet ? getComputedStyle(sheet) : null;
const r = sheet ? sheet.getBoundingClientRect() : null;
const moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
// Probe a point inside the sheet's rect.
let hitInside = false;
if (sheet && r && r.width > 0 && r.height > 0) {
const x = Math.floor(r.left + r.width / 2);
const y = Math.floor(r.top + r.height / 2);
const hit = document.elementFromPoint(x, y);
hitInside = !!(hit && sheet.contains(hit));
}
const items = sheet ? Array.from(sheet.querySelectorAll('[data-bottom-nav-more-route]')).map(e => e.getAttribute('data-bottom-nav-more-route')) : [];
return {
rect: r,
display: cs ? cs.display : null,
visibility: cs ? cs.visibility : null,
role: sheet ? sheet.getAttribute('role') : null,
hitInside,
items,
moreExpanded: moreTab ? moreTab.getAttribute('aria-expanded') : null,
};
});
if (!probe.rect || probe.rect.width === 0 || probe.rect.height === 0) {
fail(`sheet has zero area: ${JSON.stringify(probe.rect)}`);
}
if (probe.display === 'none' || probe.visibility === 'hidden') {
fail(`sheet hidden: display=${probe.display}, visibility=${probe.visibility}`);
}
if (!probe.hitInside) {
fail(`pixel-level visibility check failed: elementFromPoint inside sheet rect did not hit a sheet descendant. rect=${JSON.stringify(probe.rect)}`);
}
if (probe.role !== 'menu') fail(`sheet role should be 'menu', got ${probe.role}`);
if (probe.items.length < 6) fail(`sheet should list ≥6 long-tail routes, got ${probe.items.length}: ${probe.items.join(',')}`);
if (probe.moreExpanded !== 'true') fail(`More tab aria-expanded should be 'true' while sheet open, got ${probe.moreExpanded}`);
// 4. Tap a long-tail route → hash changes, sheet closes.
const firstRoute = probe.items[0];
await page.tap(`[data-bottom-nav-more-route="${firstRoute}"]`);
await page.waitForFunction((r) => location.hash === `#/${r}`, firstRoute, { timeout: 3000 });
await page.waitForFunction(() => {
const s = document.querySelector('[data-bottom-nav-sheet]');
if (!s) return true;
const cs = getComputedStyle(s);
return cs.display === 'none' || cs.visibility === 'hidden';
}, null, { timeout: 3000 }).catch(() => {});
const afterTap = await page.evaluate(() => {
const s = document.querySelector('[data-bottom-nav-sheet]');
if (!s) return { sheetClosed: true, hash: location.hash };
const cs = getComputedStyle(s);
const r = s.getBoundingClientRect();
return {
sheetClosed: cs.display === 'none' || cs.visibility === 'hidden' || (r.width === 0 && r.height === 0),
hash: location.hash,
};
});
if (afterTap.hash !== `#/${firstRoute}`) fail(`hash did not change to #/${firstRoute}, got ${afterTap.hash}`);
if (!afterTap.sheetClosed) fail('sheet did not close after route tap');
// 5. Tap More again → sheet reopens (toggle).
await page.tap('[data-bottom-nav-tab="more"]');
await page.waitForFunction(() => {
const s = document.querySelector('[data-bottom-nav-sheet]');
if (!s) return false;
const cs = getComputedStyle(s);
return cs.display !== 'none' && cs.visibility !== 'hidden';
}, null, { timeout: 3000 });
// Now tap More AGAIN to confirm toggle (close).
await page.tap('[data-bottom-nav-tab="more"]');
await page.waitForFunction(() => {
const s = document.querySelector('[data-bottom-nav-sheet]');
if (!s) return true;
const cs = getComputedStyle(s);
return cs.display === 'none' || cs.visibility === 'hidden';
}, null, { timeout: 3000 }).catch(() => {});
const afterToggle = await page.evaluate(() => {
const s = document.querySelector('[data-bottom-nav-sheet]');
if (!s) return { closed: true };
const cs = getComputedStyle(s);
return { closed: cs.display === 'none' || cs.visibility === 'hidden' };
});
if (!afterToggle.closed) fail('sheet did not close on second More tap (toggle behavior expected)');
console.log('test-issue-1109-hamburger-dropdown-visible-e2e.js: PASS');
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(`test-issue-1109-hamburger-dropdown-visible-e2e.js: FAIL — ${err.stack || err.message}`);
process.exit(1);
});
-149
View File
@@ -1,149 +0,0 @@
/**
* E2E (#1122 / #1124): Packets page filter UX repairs.
*
* Asserts:
* 1. Filter help opens as a centered modal with a visible backdrop AND modal
* content fully inside the viewport AND packet rows BELOW remain rendered
* (count > 0). The previous "no overlap" assertion was gameable it
* passed when rows were `display:none`'d. We now assert the honest
* property: backdrop separation + bounded modal + table still populated.
* 2. Help panel contains exactly ONE "Filter syntax" heading (not two).
* 3. Path column row height stays bounded (< 60px) regardless of how many
* hops are in any rendered packet's path.
* 4. Focus management: opening the help moves focus to the close button;
* closing returns focus to the trigger.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1122-packets-filter-ux-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1122/#1124 packets filter UX E2E against ${BASE} ===`);
await step('navigate to /packets and wait for table', async () => {
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#packetFilterInput', { timeout: 8000 });
await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 });
// Widen the time window so fixture rows render
await page.evaluate(() => {
const sel = document.getElementById('fTimeWindow');
if (sel) { sel.value = '0'; sel.dispatchEvent(new Event('change', { bubbles: true })); }
});
await page.waitForFunction(() => document.querySelectorAll('#pktBody tr').length > 0, { timeout: 8000 });
});
await step('Filter help: backdrop + modal in viewport + rows still rendered', async () => {
await page.click('#filterHelpBtn');
await page.waitForSelector('#filterHelpPopover', { timeout: 3000 });
const result = await page.evaluate(() => {
const help = document.getElementById('filterHelpPopover');
const helpRect = help.getBoundingClientRect();
const overlay = document.querySelector('.modal-overlay.fux-help-overlay');
const overlayStyle = overlay ? getComputedStyle(overlay) : null;
// Visible backdrop: overlay exists, dims (rgba alpha > 0), covers viewport.
const backdropVisible = !!(overlay
&& overlayStyle
&& overlayStyle.display !== 'none'
&& overlayStyle.visibility !== 'hidden'
&& /rgba?\([^)]+,\s*0?\.\d+|rgba?\([^)]+,\s*\d+\)/.test(overlayStyle.backgroundColor || ''));
const vw = window.innerWidth, vh = window.innerHeight;
const modalFullyInViewport = (
helpRect.top >= 0 && helpRect.left >= 0 &&
helpRect.right <= vw + 0.5 && helpRect.bottom <= vh + 0.5 &&
helpRect.width > 0 && helpRect.height > 0
);
// Count REAL packet rows (skip vscroll sentinel rows).
const allRows = document.querySelectorAll('#pktBody tr');
let renderedRows = 0;
let renderedRowsWithLayout = 0;
for (const row of allRows) {
if (row.id === 'vscroll-top' || row.id === 'vscroll-bottom') continue;
if (row.getAttribute('aria-hidden') === 'true') continue;
renderedRows++;
const r = row.getBoundingClientRect();
// The `body.fux-help-open #pktBody tr { display: none }` hack would
// collapse these to 0×0. Fail loudly if that ever returns.
if (r.width > 0 && r.height > 0) renderedRowsWithLayout++;
}
return { backdropVisible, modalFullyInViewport, helpRect, renderedRows, renderedRowsWithLayout };
});
assert(result.backdropVisible, 'modal backdrop missing or transparent');
assert(result.modalFullyInViewport, 'modal not fully in viewport: ' + JSON.stringify(result.helpRect));
assert(result.renderedRows > 0, 'no packet rows rendered at all (fixture issue?)');
assert(result.renderedRowsWithLayout > 0,
'rows are display:none while modal is open — the hack is back. rendered=' +
result.renderedRows + ' withLayout=' + result.renderedRowsWithLayout);
});
await step('Filter help contains exactly ONE "Filter syntax" heading', async () => {
const count = await page.evaluate(() => {
const help = document.getElementById('filterHelpPopover');
if (!help) return -1;
const text = help.textContent || '';
const matches = text.match(/Filter syntax/g) || [];
return matches.length;
});
assert(count === 1, 'Expected exactly 1 "Filter syntax" occurrence, got ' + count);
});
await step('Focus moves to close button on open', async () => {
const ok = await page.evaluate(() => {
const close = document.querySelector('#filterHelpPopover .fux-popover-close');
return !!close && document.activeElement === close;
});
assert(ok, 'close button should be focused after modal opens');
});
await step('close filter help via close button restores focus to trigger', async () => {
await page.click('#filterHelpPopover .fux-popover-close');
await page.waitForFunction(() => !document.getElementById('filterHelpPopover'), { timeout: 3000 });
const restored = await page.evaluate(() => {
return document.activeElement && document.activeElement.id === 'filterHelpBtn';
});
assert(restored, 'focus should return to #filterHelpBtn after close');
});
await step('Path column row height stays bounded < 60px regardless of hop count', async () => {
const result = await page.evaluate(() => {
const cells = document.querySelectorAll('#pktBody td.col-path');
let maxH = 0, maxHops = 0, offenders = [];
for (const c of cells) {
const r = c.getBoundingClientRect();
const hops = c.querySelectorAll('.hop, .hop-named').length;
if (r.height > maxH) maxH = r.height;
if (hops > maxHops) maxHops = hops;
if (r.height >= 60) offenders.push({ height: r.height, hops });
}
return { maxH, maxHops, offenders: offenders.slice(0, 5), totalCells: cells.length };
});
assert(result.totalCells > 0, 'No path cells rendered to inspect');
assert(result.offenders.length === 0,
'Path cells exceed 60px row height: ' + JSON.stringify(result.offenders) +
' (maxHops seen=' + result.maxHops + ', maxHeight=' + result.maxH + ')');
});
await browser.close();
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
-304
View File
@@ -1,304 +0,0 @@
/**
* E2E (#1128 final): Multi-viewport layout collision + z-scale enforcement.
*
* Sister of test-issue-1128-packets-layout-e2e.js. That file asserts
* individual component properties; this one closes the original
* acceptance criterion: SCREENSHOTS at multiple viewports + bounding-rect
* collision detection on visible interactive elements.
*
* What this test asserts (on top of the sibling):
*
* A. At each of three viewports (1280×900, 1080×800, 768×1024), no
* two `.filter-group` siblings vertically overlap. (Bug 3
* regression guard row-gap must be large enough for 34px
* controls when the bar wraps.)
*
* B. (Removed during self-review was conceptually flawed; see the
* block comment inside the per-viewport loop. Z-scale band check
* C and the check-css-vars lint together gate the actual Bug 4
* regression dropdowns rendering transparent or behind rows.)
*
* C. Z-scale enforcement (audit Section 2): every dropdown selector
* (`.col-toggle-menu`, `.multi-select-menu`,
* `.region-dropdown-menu`, `.fux-saved-menu`,
* `.fux-ac-dropdown`) must compute a z-index inside the
* `--z-dropdown` band [100,199] NOT 50, 90, or 9200.
*
* D. Path chip line-height lock: `.path-hops .hop, .path-hops
* .hop-named, .path-hops .arrow` must all compute line-height
* 18px so chips never spill the 22px host (Bug 1 belt-
* and-suspenders, audit Section 3).
*
* E. `.col-path` must use `height: 28px`, not `max-height` (table
* cells ignore max-height per audit).
*
* F. Screenshots saved under e2e-screenshots/ for the PR record.
*
* Usage: BASE_URL=http://localhost:13581 node \
* test-issue-1128-multi-viewport-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const SHOT_DIR = 'e2e-screenshots';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
const VIEWPORTS = [
{ w: 1280, h: 900, name: 'desktop-1280' },
{ w: 1080, h: 800, name: 'laptop-1080' },
{ w: 768, h: 1024, name: 'tablet-768' },
];
function vOverlap(a, b) {
// Reject sub-pixel rounding noise.
if (a.bottom <= b.top + 1) return false;
if (b.bottom <= a.top + 1) return false;
return true;
}
async function gotoPackets(page) {
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#packetFilterInput', { timeout: 8000 });
await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 });
await page.evaluate(() => {
const sel = document.getElementById('fTimeWindow');
if (sel) { sel.value = '0'; sel.dispatchEvent(new Event('change', { bubbles: true })); }
});
await page.waitForFunction(
() => Array.from(document.querySelectorAll('#pktBody tr'))
.filter(r => r.id !== 'vscroll-top' && r.id !== 'vscroll-bottom').length > 0,
{ timeout: 8000 });
await page.waitForTimeout(400); // hop-resolver
}
(async () => {
if (!fs.existsSync(SHOT_DIR)) fs.mkdirSync(SHOT_DIR, { recursive: true });
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== #1128 multi-viewport E2E against ${BASE} ===`);
for (const vp of VIEWPORTS) {
const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step(`[${vp.name}] navigate to /packets`, async () => {
await gotoPackets(page);
});
await step(`[${vp.name}] screenshot toolbar`, async () => {
await page.screenshot({
path: path.join(SHOT_DIR, `issue-1128-${vp.name}.png`),
fullPage: false,
});
});
await step(`[${vp.name}] no two .filter-group siblings vertically overlap`, async () => {
const result = await page.evaluate(() => {
const groups = Array.from(document.querySelectorAll('.filter-bar > .filter-group'))
.filter(el => {
const cs = getComputedStyle(el);
return cs.display !== 'none' && cs.visibility !== 'hidden';
});
const rects = groups.map(g => {
const r = g.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, left: r.left, right: r.right, w: r.width, h: r.height };
});
// Pairs that share x-overlap (same row attempt) but vertical overlap = bug.
const offenders = [];
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const a = rects[i], b = rects[j];
const xOverlap = !(a.right <= b.left + 1 || b.right <= a.left + 1);
const yOverlap = !(a.bottom <= b.top + 1 || b.bottom <= a.top + 1);
if (xOverlap && yOverlap) offenders.push({ i, j, a, b });
}
}
return { count: groups.length, offenders };
});
assert(result.count > 0, 'no .filter-group elements found');
assert(result.offenders.length === 0,
`${result.offenders.length} .filter-group sibling pairs overlap: ` +
JSON.stringify(result.offenders[0]));
});
// #1128 Bug 5 — toolbar reorder gate: toggles MUST appear before dropdowns
// in document order. packets.js was reordered so the most-frequently-used
// toggles (Group/My Nodes/time) sit next to the search input. A revert
// would reintroduce the original eye-trail problem; this assertion fails
// if the order is swapped back.
await step(`[${vp.name}] Bug 5: filter-group-toggles precedes filter-group-dropdowns`, async () => {
const order = await page.evaluate(() => {
const groups = Array.from(document.querySelectorAll('.filter-bar .filter-group'));
const togIdx = groups.findIndex(g => g.classList.contains('filter-group-toggles'));
const dropIdx = groups.findIndex(g => g.classList.contains('filter-group-dropdowns'));
return { togIdx, dropIdx, total: groups.length };
});
assert(order.togIdx >= 0, 'no .filter-group-toggles found in toolbar');
assert(order.dropIdx >= 0, 'no .filter-group-dropdowns found in toolbar');
assert(order.togIdx < order.dropIdx,
`toggles (idx ${order.togIdx}) must precede dropdowns (idx ${order.dropIdx})`);
});
// NOTE on removed sub-tests (#1128 self-review): earlier drafts had
// "[vp] Saved menu does not overlap toolbar groups below it" and
// "[vp] Types multi-select dropdown does not overlap toolbar groups
// below it". Those sub-tests had a fundamentally wrong premise — a
// position:absolute dropdown opened from a wrapped toolbar row will
// ALWAYS overlap toolbar rows below it; that's by design. What
// matters for #1128 Bug 4 is that the dropdown (a) paints on top
// (z-index) and (b) is opaque (no transparent --surface). Both are
// already gated independently:
// - z-index band: "Z-scale: dropdown selectors compute z-index in
// [100,199] band" (below)
// - opacity / undefined vars: scripts/check-css-vars.js (CI lint,
// wired in deploy.yml)
// Reintroducing a "no rect overlap" assertion would require pinning
// the toolbar to a single non-wrapping row, which contradicts the
// responsive design the rest of this file exercises.
void vp;
await ctx.close();
}
// Single z-scale + line-height + col-path checks are viewport-agnostic.
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
await gotoPackets(page);
await step('Z-scale: dropdown selectors compute z-index in [100,199] band', async () => {
const checks = await page.evaluate(() => {
// We can't always force every menu to render, so we test the
// computed z-index by inserting throwaway DOM nodes that match the
// selector and reading getComputedStyle. The browser applies the
// CSS rule by selector, regardless of whether the menu is the
// "real" one — what matters is the rule's declared z-index.
const selectors = [
'.col-toggle-menu',
'.multi-select-menu',
'.region-dropdown-menu',
'.fux-saved-menu',
'.fux-ac-dropdown',
];
const results = [];
for (const sel of selectors) {
const el = document.createElement('div');
// Strip leading dot, attach class.
el.className = sel.replace(/^\./, '');
// Force visible so getComputedStyle returns the matched rule.
el.style.position = 'absolute';
el.style.top = '-9999px';
el.style.display = 'block';
document.body.appendChild(el);
const z = parseInt(getComputedStyle(el).zIndex, 10);
results.push({ sel, z: isNaN(z) ? null : z });
el.remove();
}
return results;
});
const offenders = checks.filter(c => c.z === null || c.z < 100 || c.z > 199);
assert(offenders.length === 0,
'dropdown z-index outside [100,199] band: ' + JSON.stringify(offenders));
});
await step('Bug 1 polish: .path-hops chip children compute line-height ≤ 18px', async () => {
const offenders = await page.evaluate(() => {
const probe = (cls) => {
const host = document.createElement('div');
host.className = 'path-hops';
const child = document.createElement('span');
child.className = cls;
child.textContent = 'x';
host.appendChild(child);
document.body.appendChild(host);
const lh = parseFloat(getComputedStyle(child).lineHeight);
host.remove();
return { cls, lh };
};
const probed = ['hop', 'hop-named', 'arrow'].map(probe);
return probed.filter(p => !(p.lh <= 18.5));
});
assert(offenders.length === 0,
'.path-hops chip line-height > 18px: ' + JSON.stringify(offenders));
});
await step('Bug 1 polish: .col-path uses fixed height (not max-height) ≤ 28px', async () => {
const result = await page.evaluate(() => {
// Build a fake table cell to read the computed rule.
const tbl = document.createElement('table');
tbl.className = 'data-table';
const tr = document.createElement('tr');
const td = document.createElement('td');
td.className = 'col-path';
tr.appendChild(td);
const tbody = document.createElement('tbody');
tbody.appendChild(tr);
tbl.appendChild(tbody);
tbl.style.position = 'absolute';
tbl.style.top = '-9999px';
document.body.appendChild(tbl);
const cs = getComputedStyle(td);
const height = parseFloat(cs.height);
const maxHeight = cs.maxHeight;
tbl.remove();
// Walk the loaded stylesheets to find the rule(s) that target
// .data-table td.col-path and report which physical property
// (height vs max-height) is declared. The audit fix REQUIRES
// `height: 28px`; `max-height: 28px` is the original Bug 1
// regression and must fail this test.
const declared = { height: null, maxHeight: null, ruleSrc: null };
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch (e) { continue; }
if (!rules) continue;
for (const rule of rules) {
if (!rule.selectorText) continue;
// Match selectors that include both .data-table and .col-path
// (allow combinator variations like ".data-table td.col-path").
if (/\.data-table[\s\S]*\.col-path/.test(rule.selectorText)) {
const h = rule.style && rule.style.getPropertyValue('height');
const mh = rule.style && rule.style.getPropertyValue('max-height');
if (h) declared.height = h.trim();
if (mh) declared.maxHeight = mh.trim();
if (h || mh) declared.ruleSrc = rule.selectorText;
}
}
}
return { height, maxHeight, declared };
});
// Computed height must be exactly 28px.
assert(Math.abs(result.height - 28) < 0.5,
'.col-path computed height not 28px: ' + JSON.stringify(result));
// Audit fix gate: rule must declare `height: 28px`. If the regression
// is reverted to `max-height: 28px`, declared.height is null and this
// assertion fails.
assert(result.declared.height && /^28px$/.test(result.declared.height),
'.col-path rule must declare `height: 28px` (audit fix), got: ' +
JSON.stringify(result.declared));
assert(!result.declared.maxHeight,
'.col-path rule must NOT declare max-height (original Bug 1 regression): ' +
JSON.stringify(result.declared));
});
await ctx.close();
await browser.close();
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
-188
View File
@@ -1,188 +0,0 @@
/**
* E2E (#1128): Packets page layout chaos.
*
* Asserts the user-visible properties broken by the 5 sub-bugs documented in
* specs/packets-layout-audit.md:
*
* 1. Bug 4 (--surface undefined): Saved-filter dropdown background must be
* OPAQUE we read its computed `background-color`, parse the alpha and
* fail if the alpha channel is below 0.99. Same check applies to the
* `+N` path-overflow popover (`.path-popover`).
* 2. Bug 1 (path chip spill / no `+N`): every `.path-hops` host whose
* scrollWidth > clientWidth must have a `.path-overflow-pill` rendered
* after the hop-resolver mutation pass settles.
* 3. Bug 2 (`+N` popover position + z-index): when opened, the popover's
* z-index must be 9000 (under modal stack) and its top edge must be
* within 8px of the pill's top OR bottom edge i.e. anchored to the
* pill, not floating arbitrarily across the table.
* 4. Bug 3 (filter-bar gap + multi-select trigger truncation): the
* `.filter-bar` row-gap must be 10px (controls are 34px tall, 6px gap
* allows visual overlap on wrap), and every `.multi-select-trigger` must
* have a CSS `max-width` 280px (clamp viewport-aware cap) so a long
* "TRACE,MULTIPART,..." label doesn't balloon the row.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
// Parse "rgba(r,g,b,a)" / "rgb(r,g,b)" → alpha (1 if rgb).
function parseAlpha(s) {
if (!s) return 0;
if (s === 'transparent') return 0;
var m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec(s);
if (!m) return 1; // assume opaque named color
return m[4] === undefined ? 1 : parseFloat(m[4]);
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1128 packets layout E2E against ${BASE} ===`);
await step('navigate to /packets and wait for table + rows', async () => {
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#packetFilterInput', { timeout: 8000 });
await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 });
await page.evaluate(() => {
const sel = document.getElementById('fTimeWindow');
if (sel) { sel.value = '0'; sel.dispatchEvent(new Event('change', { bubbles: true })); }
});
await page.waitForFunction(
() => Array.from(document.querySelectorAll('#pktBody tr'))
.filter(r => r.id !== 'vscroll-top' && r.id !== 'vscroll-bottom').length > 0,
{ timeout: 8000 });
// Allow hop-resolver async pass to settle so chips reflect resolved names.
await page.waitForTimeout(400);
});
await step('Bug 4: Saved-filter dropdown background is OPAQUE (alpha ≥ 0.99)', async () => {
// Open the saved menu
await page.evaluate(() => {
var btn = document.getElementById('filterSavedTrigger');
if (btn) btn.click();
});
const result = await page.evaluate(() => {
var menu = document.getElementById('filterSavedMenu');
if (!menu) return { error: 'no #filterSavedMenu' };
// un-hide if needed (some impls toggle .hidden)
menu.classList.remove('hidden');
var cs = getComputedStyle(menu);
return { bg: cs.backgroundColor, display: cs.display };
});
assert(!result.error, result.error);
var alpha = parseAlpha(result.bg);
assert(alpha >= 0.99,
'Saved menu background not opaque: alpha=' + alpha + ' bg=' + result.bg +
' (likely --surface undefined / Bug 4)');
// close
await page.keyboard.press('Escape').catch(() => {});
});
await step('Bug 1: every overflowing .path-hops has a .path-overflow-pill', async () => {
const result = await page.evaluate(() => {
var hosts = Array.from(document.querySelectorAll('#pktBody .path-hops'));
var offenders = [];
for (var i = 0; i < hosts.length; i++) {
var h = hosts[i];
// Treat "overflowing" as scrollWidth strictly greater than clientWidth
// by more than 1 px to avoid sub-pixel rounding noise.
if (h.scrollWidth - h.clientWidth > 1) {
if (!h.querySelector('.path-overflow-pill')) {
offenders.push({
sw: h.scrollWidth, cw: h.clientWidth,
chips: h.querySelectorAll('.hop, .hop-named').length,
});
}
}
}
return { totalHosts: hosts.length, offenders: offenders.slice(0, 5) };
});
assert(result.totalHosts > 0, 'no .path-hops in fixture rows');
assert(result.offenders.length === 0,
'overflowing .path-hops without +N pill: ' + JSON.stringify(result.offenders));
});
await step('Bug 2: +N popover anchored to pill + z-index ≤ 9000', async () => {
const found = await page.evaluate(() => {
var pill = document.querySelector('#pktBody .path-overflow-pill');
if (!pill) return { skip: true };
pill.scrollIntoView({ block: 'center' });
return { skip: false };
});
if (found.skip) {
console.log(' (no +N pill present in fixture — skipping anchor check)');
return;
}
// After scrollIntoView the virtual scroll may rebuild rows; wait then
// capture the pill's rect from the *current* DOM, then click it.
await page.waitForTimeout(250);
const result = await page.evaluate(() => {
var pill = document.querySelector('#pktBody .path-overflow-pill');
if (!pill) return { error: 'pill vanished after scroll' };
var br = pill.getBoundingClientRect();
pill.click();
var pop = document.querySelector('.path-popover');
if (!pop) return { error: 'popover did not appear after pill click' };
var pr = pop.getBoundingClientRect();
var z = parseInt(getComputedStyle(pop).zIndex, 10) || 0;
var anchoredBelow = Math.abs(pr.top - br.bottom) <= 8;
var anchoredAbove = Math.abs(pr.bottom - br.top) <= 8;
return { z, anchoredBelow, anchoredAbove,
pr: { top: pr.top, bottom: pr.bottom },
br: { top: br.top, bottom: br.bottom } };
});
assert(!result.error, result.error);
assert(result.z <= 9000, '+N popover z-index too high (over modal stack): ' + result.z);
assert(result.anchoredBelow || result.anchoredAbove,
'+N popover not anchored to pill: pop=' + JSON.stringify(result.pr) +
' pill=' + JSON.stringify(result.br));
});
await step('Bug 3: .filter-bar row-gap ≥ 10px AND .multi-select-trigger has bounded max-width', async () => {
const result = await page.evaluate(() => {
var bar = document.querySelector('.filter-bar');
if (!bar) return { error: 'no .filter-bar' };
var cs = getComputedStyle(bar);
var rg = parseFloat(cs.rowGap || cs.gap || '0');
var triggers = Array.from(document.querySelectorAll('.multi-select-trigger'));
var unboundedTrigger = null;
for (var i = 0; i < triggers.length; i++) {
var mw = getComputedStyle(triggers[i]).maxWidth;
// "none" or empty == unbounded; numeric px > 280 == too loose
if (mw === 'none' || mw === '' ) { unboundedTrigger = { idx: i, mw }; break; }
var px = parseFloat(mw);
if (!isFinite(px) || px > 280) { unboundedTrigger = { idx: i, mw }; break; }
}
return { rowGap: rg, triggerCount: triggers.length, unboundedTrigger };
});
assert(!result.error, result.error);
assert(result.rowGap >= 10,
'.filter-bar row-gap too small (causes wrap overlap with 34px controls): ' + result.rowGap);
assert(result.triggerCount > 0, 'no .multi-select-trigger present (filter UX missing?)');
assert(!result.unboundedTrigger,
'.multi-select-trigger lacks bounded max-width: ' + JSON.stringify(result.unboundedTrigger));
});
await browser.close();
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
-134
View File
@@ -1,134 +0,0 @@
/**
* E2E (#1136): Live page region filter must NOT wipe all packets and lines.
*
* Regression introduced in #1080 `public/live.js` parsed `/api/observers`
* as if it were a top-level array, but the endpoint returns
* `{observers: [...], server_time: ...}`. Result: `observerIataMap` stayed
* empty and `packetMatchesRegion` returned false for EVERY packet whenever
* any region was selected so no markers, no polylines, no feed entries.
*
* This test:
* 1. Loads /#/live against the fixture DB.
* 2. Waits for the observer roster to load and verifies the live module
* has a populated observer_id IATA map (proves the parse path works).
* 3. Programmatically selects a region (SJC) that we know maps to fixture
* observers (test-fixtures/e2e-fixture.db has multiple observers in
* SJC, OAK, MRY, SFO).
* 4. Synthesizes a packet whose observer_id IS in the SJC region and
* pushes it through the same path live websocket packets take.
* 5. Asserts at least one `.live-feed-item` is rendered for that hash.
*
* Before the fix this test FAILS at assertion 2 (map empty) AND at
* assertion 5 (feed never renders the packet). After the fix both pass.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log('\n=== #1136 live region filter E2E against ' + BASE + ' ===');
// Discover an observer_id in SJC from the API (drives test from real data).
let sjcObserverId = null;
let allObservers = [];
await step('GET /api/observers returns {observers:[...]} shape with SJC entries', async () => {
const res = await page.request.get(BASE + '/api/observers');
assert(res.ok(), 'API returned non-OK: ' + res.status());
const body = await res.json();
assert(body && Array.isArray(body.observers), 'response must have .observers array (the bug-1136 root cause)');
allObservers = body.observers;
const sjc = body.observers.filter(function (o) { return o && o.iata === 'SJC' && o.id; });
assert(sjc.length > 0, 'fixture must contain at least one SJC observer (got ' + sjc.length + ')');
sjcObserverId = sjc[0].id;
});
await step('navigate to /#/live and wait for live module to register', async () => {
// Pre-clear region selection so it starts unrestricted.
await page.addInitScript(() => {
try { localStorage.removeItem('meshcore-region-filter'); } catch (e) {}
});
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!(window._liveBufferPacket && window.RegionFilter), { timeout: 15000 });
});
await step('observer iata map is POPULATED after init fetch (regression #1136)', async () => {
const exposed = await page.evaluate(() => typeof window._liveGetObserverIataMap);
assert(exposed === 'function', '_liveGetObserverIataMap must be exposed as a function (regression: not wired up)');
// Wait for fetch + setObserverIataMap to land.
await page.waitForFunction(() => {
const m = window._liveGetObserverIataMap && window._liveGetObserverIataMap();
return m && Object.keys(m).length > 0;
}, { timeout: 8000 }).catch(() => {});
const sample = await page.evaluate((oid) => {
const m = window._liveGetObserverIataMap();
return { size: Object.keys(m).length, iataForOid: m[oid] || null };
}, sjcObserverId);
assert(sample.size > 0, 'observerIataMap should be populated from /api/observers (was empty — #1136 bug)');
assert(sample.iataForOid === 'SJC', 'observerIataMap[' + sjcObserverId + '] should be "SJC", got ' + sample.iataForOid);
});
await step('select SJC region in RegionFilter, verify selection took effect', async () => {
await page.evaluate(() => {
window.RegionFilter.setSelected(['SJC']);
});
const sel = await page.evaluate(() => window.RegionFilter.getSelected());
assert(Array.isArray(sel) && sel.indexOf('SJC') !== -1, 'RegionFilter selected should include SJC, got ' + JSON.stringify(sel));
});
await step('packet with SJC observer renders to live feed when SJC region selected', async () => {
const targetHash = 'fixture-1136-' + Date.now().toString(16);
await page.evaluate(function (args) {
const pkt = {
id: 9999991136,
hash: args.hash,
raw_hex: '00',
path_json: '[]',
observer_id: args.oid,
observer_name: 'fixture-observer',
timestamp: new Date().toISOString(),
snr: 5, rssi: -90,
decoded: {
header: { payloadTypeName: 'GRP_TXT' },
payload: { text: 'region-1136-probe' },
path: { hops: [] },
},
};
// Push through the same buffer entry point the WS handler uses.
window._liveBufferPacket(pkt);
}, { hash: targetHash, oid: sjcObserverId });
// Allow the (non-realistic-propagation) immediate renderPacketTree to land.
await page.waitForFunction((h) => {
return !!document.querySelector('.live-feed-item[data-hash="' + h + '"]');
}, targetHash, { timeout: 5000 }).catch(() => {});
const found = await page.evaluate((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), targetHash);
assert(found, 'expected .live-feed-item[data-hash=' + targetHash + '] to render with SJC selected (#1136: filter wiped feed)');
});
await page.evaluate(() => { try { window.RegionFilter.setSelected([]); } catch(e) {} });
await browser.close();
console.log('\n--- ' + passed + ' passed, ' + failed + ' failed ---\n');
process.exit(failed > 0 ? 1 : 0);
})().catch((e) => { console.error(e); process.exit(1); });
-133
View File
@@ -1,133 +0,0 @@
/* Unit test (#1136): live.js must parse /api/observers correctly.
*
* Regression: PR #1080 wrote `if (Array.isArray(list))` and treated the
* response as a top-level array. The actual /api/observers shape is
* `{ observers: [...], server_time: "..." }` (cmd/server/types.go
* ObserverListResponse). Result: observerIataMap stays empty and ANY
* region selection drops every packet.
*
* This test loads live.js into a vm sandbox and asserts that the exposed
* builder helper produces a populated map from the realistic API shape.
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' \u2705 ' + name); }
catch (e) { failed++; console.log(' \u274C ' + name + ': ' + e.message); }
}
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
document: {
readyState: 'complete',
createElement: () => ({ style: {}, classList: { add(){}, remove(){}, contains(){return false;} }, setAttribute(){}, addEventListener(){}, getContext: () => ({clearRect(){},fillRect(){},beginPath(){},arc(){},fill(){},scale(){},fillText(){}}) }),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [], querySelector: () => null,
createElementNS: () => ({ setAttribute(){} }),
documentElement: { getAttribute: () => null, setAttribute: () => {}, dataset: {} },
body: { appendChild: () => {}, removeChild: () => {}, contains: () => false },
hidden: false,
},
console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
Error, TypeError, Map, Set, Promise, URLSearchParams,
parseInt, parseFloat, isNaN, isFinite,
encodeURIComponent, decodeURIComponent,
setTimeout: () => 0, clearTimeout: () => {},
setInterval: () => 0, clearInterval: () => {},
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
requestAnimationFrame: () => 0,
cancelAnimationFrame: () => {},
localStorage: (() => { const s = {}; return { getItem: k => s[k] !== undefined ? s[k] : null, setItem: (k,v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(),
location: { hash: '', protocol: 'https:', host: 'localhost' },
CustomEvent: class CustomEvent {},
addEventListener: () => {}, dispatchEvent: () => {},
getComputedStyle: () => ({ getPropertyValue: () => '' }),
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
navigator: {}, visualViewport: null,
MutationObserver: function() { this.observe=()=>{}; this.disconnect=()=>{}; },
WebSocket: function() { this.close=()=>{}; },
IATA_COORDS_GEO: {},
L: {
circleMarker: () => ({addTo(){return this;},bindTooltip(){return this;},on(){return this;},setRadius(){},setStyle(){},setLatLng(){},getLatLng(){return{lat:0,lng:0};},remove(){}}),
polyline: () => ({addTo(){return this;},setStyle(){},remove(){}}),
polygon: () => ({addTo(){return this;},remove(){}}),
map: () => ({setView(){return this;},addLayer(){return this;},on(){return this;},getZoom(){return 11;},getCenter(){return{lat:0,lng:0};},getBounds(){return{contains:()=>true};},fitBounds(){return this;},invalidateSize(){},remove(){},hasLayer(){return false;},removeLayer(){}}),
layerGroup: () => ({addTo(){return this;},addLayer(){},removeLayer(){},clearLayers(){},hasLayer(){return true;},eachLayer(){}}),
tileLayer: () => ({addTo(){return this;}}),
control: { attribution: () => ({addTo(){}}) },
DomUtil: { addClass(){}, removeClass(){} },
},
registerPage: () => {}, onWS: () => {}, offWS: () => {}, connectWS: () => {},
api: () => Promise.resolve([]), invalidateApiCache: () => {},
favStar: () => '', bindFavStars: () => {},
getFavorites: () => [], isFavorite: () => false,
HopResolver: { init(){}, resolve: () => ({}), ready: () => false },
MeshAudio: null,
RegionFilter: { init(){}, getSelected: () => null, onChange: () => {}, offChange: () => {}, regionQueryString: () => '', getRegionParam: () => '' },
};
vm.createContext(ctx);
return ctx;
}
function load(ctx, file) {
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
console.log('\n=== live.js: /api/observers parse (#1136) ===');
const ctx = makeSandbox();
load(ctx, 'public/roles.js');
load(ctx, 'public/live.js');
const build = ctx.window._liveBuildObserverIataMap;
assert.ok(build, '_liveBuildObserverIataMap must be exposed (regression: missing parser helper)');
const realShape = {
observers: [
{ id: 'OBS1', iata: 'SJC', name: 'A' },
{ id: 'OBS2', iata: 'OAK', name: 'B' },
{ id: 'OBS3', iata: 'SFO', name: 'C' },
{ id: 'OBS4', iata: null, name: 'no-iata' },
],
server_time: '2026-05-07T00:00:00Z',
};
test('parses {observers:[...], server_time} response and populates map', () => {
const m = build(realShape);
assert.strictEqual(m.OBS1, 'SJC');
assert.strictEqual(m.OBS2, 'OAK');
assert.strictEqual(m.OBS3, 'SFO');
});
test('skips observers without iata', () => {
const m = build(realShape);
assert.ok(!('OBS4' in m), 'observers with null iata should not be in map');
});
test('returns empty map for null/undefined input', () => {
assert.strictEqual(Object.keys(build(null)).length, 0);
assert.strictEqual(Object.keys(build(undefined)).length, 0);
});
test('returns empty map when observers field is missing', () => {
assert.strictEqual(Object.keys(build({ server_time: 'x' })).length, 0);
});
test('back-compat: also accepts a top-level array (defensive)', () => {
// If the API shape ever changes back, don\'t silently break.
const m = build([{ id: 'X1', iata: 'LAX' }]);
assert.strictEqual(m.X1, 'LAX');
});
console.log('\n' + '='.repeat(40));
console.log(' observer iata map tests: ' + passed + ' passed, ' + failed + ' failed');
console.log('='.repeat(40) + '\n');
if (failed > 0) process.exit(1);
-145
View File
@@ -1,145 +0,0 @@
/**
* #1146 "Paths Through This Node" path-link contrast E2E.
*
* Bug: Path entries inside the node-detail "Paths Through This Node"
* section are rendered as <div> blocks, not a <table>. The existing
* `.node-detail-section .data-table td a { color: var(--accent) }`
* rule (style.css:1231) doesn't apply, so the path-hop <a> elements
* fall back to UA-default `rgb(0,0,238)` blue. On dark theme, that
* blue against `var(--card-bg)` (#1a1a2e) computes to ~3.0:1 a
* WCAG AA failure (4.5:1 required for body text).
*
* This test loads a node detail page, mocks the /paths API to return
* a deterministic chain with at least one named hop, switches to dark
* theme, then asserts the computed link colour vs. its background
* yields a contrast ratio 4.5:1.
*
* Currently FAILS (link color resolves to rgb(0,0,238)).
* After the style.css fix it PASSES.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
// WCAG 2.1 relative luminance + contrast ratio.
function srgbToLin(c) {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function lum(rgb) {
return 0.2126 * srgbToLin(rgb[0]) + 0.7152 * srgbToLin(rgb[1]) + 0.0722 * srgbToLin(rgb[2]);
}
function contrast(fg, bg) {
const L1 = lum(fg), L2 = lum(bg);
const hi = Math.max(L1, L2), lo = Math.min(L1, L2);
return (hi + 0.05) / (lo + 0.05);
}
function parseRgb(s) {
// Accept "rgb(r, g, b)" or "rgba(r, g, b, a)".
const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!m) throw new Error('Cannot parse colour: ' + s);
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
}
// Walk up parent chain to find the first non-transparent backgroundColor.
async function effectiveBgFor(page, selector) {
return await page.evaluate((sel) => {
let el = document.querySelector(sel);
if (!el) return null;
while (el) {
const cs = getComputedStyle(el);
const bg = cs.backgroundColor;
const m = bg && bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (m) {
const a = m[4] !== undefined ? parseFloat(m[4]) : 1;
if (a > 0.01) return bg;
}
el = el.parentElement;
}
// Fallback: html background.
return getComputedStyle(document.documentElement).backgroundColor || 'rgb(255,255,255)';
}, selector);
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1146 path-link contrast E2E against ${BASE} ===`);
const hopPubkey = 'a1b2c3d4e5f60718293a4b5c6d7e8f9001122334455667788990aabbccddeeff';
// Mock paths API for ANY node so test is deterministic.
await page.route('**/api/nodes/*/paths*', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
totalPaths: 1,
totalTransmissions: 5,
paths: [{
hops: [
{ pubkey: hopPubkey, prefix: 'a1', name: 'TestHop' },
],
count: 5,
lastSeen: new Date().toISOString(),
sampleHash: 'deadbeef00',
}],
}),
});
});
await step('Load nodes page and force dark theme', async () => {
await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
document.documentElement.setAttribute('data-theme', 'dark');
});
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 15000 });
});
await step('Open side panel for first node and wait for paths', async () => {
await page.click('#nodesBody tr[data-key]');
await page.waitForSelector('#pathsContent', { timeout: 10000 });
await page.waitForFunction(
() => {
const el = document.getElementById('pathsContent');
return el && el.querySelector('a[href^="#/nodes/"]');
},
{ timeout: 15000 }
);
});
await step('Path link contrast (#pathsContent a) ≥ 4.5:1 in dark mode', async () => {
const linkColor = await page.$eval('#pathsContent a[href^="#/nodes/"]', (el) => getComputedStyle(el).color);
const bgColor = await effectiveBgFor(page, '#pathsContent a[href^="#/nodes/"]');
const fg = parseRgb(linkColor);
const bg = parseRgb(bgColor);
const ratio = contrast(fg, bg);
console.log(` link=${linkColor} bg=${bgColor} ratio=${ratio.toFixed(2)}:1`);
assert(ratio >= 4.5,
`Expected contrast ≥ 4.5:1 (WCAG AA), got ${ratio.toFixed(2)}:1 ` +
`(link ${linkColor} on ${bgColor}). The path-link <a> elements are not ` +
`covered by the .data-table td a rule and inherit UA blue.`);
});
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(1); });
-163
View File
@@ -1,163 +0,0 @@
/**
* #1147 Side panel + full node detail page must render "Recent Packets"
* directly under Overview, BEFORE Paths/Neighbors/Heard By/Clock Skew.
*
* Operator mental order: identity packets they originated paths they
* relay adverts meta. Recent Packets currently appears LAST; this is
* the regression guard that proves the new ordering.
*
* Acceptance:
* - Full node detail page (#/nodes/<pk>): index of "Recent Packets"
* section header < index of "Paths Through This Node" header AND
* < index of "Heard By" header AND < index of "Neighbors" header
* AND < index of "Clock Skew" header (when present).
* - Side panel (open from /nodes list): same ordering.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
// Pull all rendered <h4> headers (section titles in node detail) in DOM order
// from a given root. Returns array of trimmed text.
async function sectionHeaders(page, rootSelector) {
return await page.$$eval(`${rootSelector} h4`, els =>
els.map(e => (e.textContent || '').trim()));
}
// Find index of first header whose text starts with `prefix`. -1 if absent.
function indexOfStarts(headers, prefix) {
for (let i = 0; i < headers.length; i++) {
if (headers[i].startsWith(prefix)) return i;
}
return -1;
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1147 section-order E2E against ${BASE} ===`);
// ─── Pick a node from the live API ───
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
const pubkey = await page.evaluate(async () => {
const r = await fetch('/api/nodes?limit=20');
const d = await r.json();
// Prefer a node with an advert_count > 0 so Recent Packets has content,
// but ANY node should render the section header (with an empty state).
const cand = (d.nodes || []).find(n => (n.advert_count || 0) > 0) ||
(d.nodes || [])[0];
return cand && cand.public_key;
});
assert(pubkey, 'No node returned from /api/nodes');
console.log(' → using probe pubkey: ' + pubkey.slice(0, 12) + '…');
// ─── Case 1: Full node detail page ───
await step('full page: Recent Packets appears before Paths/Heard By/Neighbors/Clock Skew', async () => {
await page.goto(BASE + '/#/nodes/' + encodeURIComponent(pubkey), { waitUntil: 'domcontentloaded' });
// Wait for the body container to render (the full detail uses .node-full-card).
await page.waitForSelector('.node-full-card', { timeout: 10000 });
// Wait until at least one "Recent Packets" header is in the DOM.
await page.waitForFunction(() => {
return Array.from(document.querySelectorAll('h4'))
.some(h => (h.textContent || '').trim().startsWith('Recent Packets'));
}, { timeout: 10000 });
const headers = await sectionHeaders(page, 'body');
console.log(' headers (full page): ' + JSON.stringify(headers));
const iRecent = indexOfStarts(headers, 'Recent Packets');
const iPaths = indexOfStarts(headers, 'Paths Through This Node');
const iHeard = indexOfStarts(headers, 'Heard By');
const iNeigh = indexOfStarts(headers, 'Neighbors');
// Clock Skew is hidden by default but rendered later; only enforce ordering
// when its container has visible content. Skip if absent.
assert(iRecent !== -1, 'Recent Packets header not found on full page');
assert(iPaths !== -1, 'Paths Through This Node header not found on full page');
assert(iRecent < iPaths,
`Recent Packets (idx ${iRecent}) must appear BEFORE Paths Through This Node (idx ${iPaths}) on full page`);
if (iHeard !== -1) {
assert(iRecent < iHeard,
`Recent Packets (idx ${iRecent}) must appear BEFORE Heard By (idx ${iHeard}) on full page`);
}
if (iNeigh !== -1) {
assert(iRecent < iNeigh,
`Recent Packets (idx ${iRecent}) must appear BEFORE Neighbors (idx ${iNeigh}) on full page`);
}
});
// ─── Case 2: Side panel (opened from /nodes list) ───
await step('side panel: Recent Packets appears before Paths/Heard By/Neighbors', async () => {
await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
// Click the row matching our probe pubkey if possible; fall back to first row.
const clicked = await page.evaluate((pk) => {
const rows = Array.from(document.querySelectorAll('table tbody tr'));
// Try data attributes first
let target = rows.find(r => (r.getAttribute('data-pubkey') || '').toLowerCase() === pk.toLowerCase());
// Fall back: any row whose text contains the pubkey prefix
if (!target) target = rows.find(r => r.textContent && r.textContent.toLowerCase().includes(pk.slice(0, 8).toLowerCase()));
// Last resort: first non-vscroll row
if (!target) target = rows.find(r => !(r.id || '').startsWith('vscroll'));
if (target) { target.click(); return true; }
return false;
}, pubkey);
assert(clicked, 'Could not click any node row to open side panel');
// Wait for side panel to render the detail container.
await page.waitForSelector('.node-detail', { timeout: 10000 });
// Wait until Recent Packets header lands in the side panel scope.
await page.waitForFunction(() => {
const root = document.querySelector('.node-detail');
if (!root) return false;
return Array.from(root.querySelectorAll('h4'))
.some(h => (h.textContent || '').trim().startsWith('Recent Packets'));
}, { timeout: 10000 });
const headers = await sectionHeaders(page, '.node-detail');
console.log(' headers (side panel): ' + JSON.stringify(headers));
const iRecent = indexOfStarts(headers, 'Recent Packets');
const iPaths = indexOfStarts(headers, 'Paths Through This Node');
const iHeard = indexOfStarts(headers, 'Heard By');
const iNeigh = indexOfStarts(headers, 'Neighbors');
assert(iRecent !== -1, 'Recent Packets header not found in side panel');
assert(iPaths !== -1, 'Paths Through This Node header not found in side panel');
assert(iRecent < iPaths,
`Recent Packets (idx ${iRecent}) must appear BEFORE Paths Through This Node (idx ${iPaths}) in side panel`);
if (iHeard !== -1) {
assert(iRecent < iHeard,
`Recent Packets (idx ${iRecent}) must appear BEFORE Heard By (idx ${iHeard}) in side panel`);
}
if (iNeigh !== -1) {
assert(iRecent < iNeigh,
`Recent Packets (idx ${iRecent}) must appear BEFORE Neighbors (idx ${iNeigh}) in side panel`);
}
});
await browser.close();
console.log(`\n${passed}/${passed + failed} passed`);
process.exit(failed === 0 ? 0 : 1);
})().catch(e => { console.error(e); process.exit(2); });
-102
View File
@@ -1,102 +0,0 @@
/**
* E2E (#1150): Full-page node detail header must NOT remain "Loading…"
* forever when /api/nodes/{pubkey} returns 404.
*
* Repro: navigate to /#/nodes/{unknown_pubkey}. The body shows
* "Failed to load node: API 404" but the back-row title stays "Loading…"
* with no link back to the Nodes list.
*
* After the fix:
* 1. Page back-row title is NOT "Loading…" it should reflect "Node not found"
* or include the unknown pubkey prefix.
* 2. Body content surfaces an error state mentioning "not found" / "unknown".
* 3. There is a link back to /#/nodes (in addition to the existing back arrow).
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const UNKNOWN_PUBKEY = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log('\n=== #1150 node-detail 404 state E2E against ' + BASE + ' ===');
await step('GET /api/nodes/<unknown> returns 404 (precondition)', async () => {
const res = await page.request.get(BASE + '/api/nodes/' + UNKNOWN_PUBKEY);
assert(res.status() === 404, 'expected 404, got ' + res.status());
});
await step('navigate to /#/nodes/{unknown} and let API call settle', async () => {
await page.goto(BASE + '/#/nodes/' + UNKNOWN_PUBKEY, { waitUntil: 'domcontentloaded' });
// Wait for the title to NOT be "Loading…" anymore (success or fixed error state).
await page.waitForFunction(() => {
const el = document.querySelector('.node-full-title');
return el && (el.textContent || '').trim() !== 'Loading…';
}, { timeout: 8000 }).catch(() => {});
});
await step('back-row title is NOT stuck on "Loading…"', async () => {
const title = await page.evaluate(() => {
const el = document.querySelector('.node-full-title');
return el ? (el.textContent || '').trim() : null;
});
assert(title !== null, '.node-full-title element missing');
assert(title !== 'Loading…', '#1150: title still stuck on "Loading…"');
// Title should mention "not found" or contain the pubkey prefix.
const lower = title.toLowerCase();
const hasErrorWord = lower.indexOf('not found') !== -1 || lower.indexOf('unknown') !== -1;
const hasPubkeyPrefix = title.indexOf(UNKNOWN_PUBKEY.slice(0, 8)) !== -1;
assert(hasErrorWord || hasPubkeyPrefix,
'title should indicate "not found"/"unknown" or include pubkey prefix; got: ' + JSON.stringify(title));
});
await step('body surfaces an error state mentioning the missing node', async () => {
const bodyText = await page.evaluate(() => {
const el = document.getElementById('nodeFullBody');
return el ? (el.textContent || '').toLowerCase() : '';
});
assert(bodyText.length > 0, '#nodeFullBody empty');
assert(
bodyText.indexOf('not found') !== -1 || bodyText.indexOf('unknown') !== -1,
'body should contain "not found" or "unknown"; got: ' + JSON.stringify(bodyText.slice(0, 200))
);
});
await step('body contains a link back to /#/nodes', async () => {
const hasBackLink = await page.evaluate(() => {
const body = document.getElementById('nodeFullBody');
if (!body) return false;
const anchors = body.querySelectorAll('a[href]');
for (const a of anchors) {
const href = a.getAttribute('href') || '';
if (href === '#/nodes' || href.endsWith('#/nodes')) return true;
}
return false;
});
assert(hasBackLink, 'expected a body anchor with href="#/nodes" (Back to Nodes link)');
});
await browser.close();
console.log('\n--- ' + passed + ' passed, ' + failed + ' failed ---\n');
process.exit(failed > 0 ? 1 : 0);
})().catch((e) => { console.error(e); process.exit(1); });
-128
View File
@@ -1,128 +0,0 @@
/**
* E2E (#1151): Side-panel "Heard By" rows must not render orphan separators
* when an observer's SNR and/or RSSI are null.
*
* Bug template (public/nodes.js):
* `${o.packetCount} pkts · ${snr ?: ''}${rssi ?: ''}`
* "110 pkts · " (trailing dot when both null)
* "110 pkts · · RSSI -52" (double dot when only SNR null)
*
* Fix: build a filtered parts array, then `.join(' · ')`.
*
* This test stubs /api/nodes/:pubkey/health via page.route() so we get
* deterministic observer rows with all three null/non-null permutations
* (the fixture DB has no real observers attached to a single node).
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1151-orphan-separators-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log('\n=== #1151 orphan-separator E2E against ' + BASE + ' ===');
// Pick any node from the API to drive the test.
let pubkey = null;
await step('fetch a real node pubkey from /api/nodes', async () => {
const res = await page.request.get(BASE + '/api/nodes');
assert(res.ok(), '/api/nodes returned ' + res.status());
const body = await res.json();
const arr = Array.isArray(body) ? body : (body.nodes || []);
assert(arr.length > 0, 'fixture must contain at least one node');
pubkey = arr[0].public_key || arr[0].pubkey || arr[0].id;
assert(pubkey, 'first node must expose a public_key');
});
// Stub the health endpoint for this pubkey with three observer permutations:
// 1. both null → pre-fix: "110 pkts · · " (trailing orphan after pkts)
// 2. snr null only → pre-fix: "55 pkts · · RSSI -50" (orphan between pkts and RSSI)
// 3. rssi null only→ pre-fix: "22 pkts · SNR 5.5dB" (clean — control)
// 4. both present → pre-fix: "11 pkts · SNR 7.0dB · RSSI -42" (clean — control)
await page.route('**/api/nodes/' + encodeURIComponent(pubkey) + '/health', async (route) => {
const stubBody = {
node: { public_key: pubkey, name: 'TEST-NODE', role: 'repeater' },
stats: { totalPackets: 200, packetsToday: 10, avgSnr: null, avgHops: 2, lastHeard: new Date().toISOString() },
observers: [
{ observer_id: 'obs-both-null', observer_name: 'BothNull', iata: 'SJC', avgSnr: null, avgRssi: null, packetCount: 110 },
{ observer_id: 'obs-snr-null', observer_name: 'SnrNull', iata: 'SJC', avgSnr: null, avgRssi: -50, packetCount: 55 },
{ observer_id: 'obs-rssi-null', observer_name: 'RssiNull', iata: 'OAK', avgSnr: 5.5, avgRssi: null, packetCount: 22 },
{ observer_id: 'obs-both-set', observer_name: 'BothSet', iata: 'OAK', avgSnr: 7.0, avgRssi: -42, packetCount: 11 },
],
recentPackets: [],
};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(stubBody) });
});
// Navigate to the nodes LIST page (not /#/nodes/<pubkey> — that opens the
// full-screen view, which uses a different, already null-safe template
// with separate <td> cells). The bug lives in the side-panel template
// that renders when you click a row on the list page.
await step('navigate /#/nodes (list view) and wait for rows', async () => {
await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#nodesBody tr[data-action="select"]', { timeout: 15000 });
});
await step('click target row to open side-panel detail', async () => {
const sel = '#nodesBody tr[data-action="select"][data-value="' + pubkey + '"]';
await page.waitForSelector(sel, { timeout: 8000 });
await page.evaluate((s) => document.querySelector(s).scrollIntoView(), sel);
await page.click(sel);
await page.waitForSelector('#nodesRight .observer-row', { timeout: 15000 });
});
await step('side-panel "Heard By" rows render exactly 4 observers', async () => {
const count = await page.$$eval('#nodesRight .observer-row', els => els.length);
assert(count === 4, 'expected 4 observer rows, got ' + count);
});
await step('NO observer row contains an orphan separator (no "· ·" or trailing/leading " · ")', async () => {
const rows = await page.$$eval('#nodesRight .observer-row', els => els.map(el => {
// Read just the right-hand "stats" span (the suffix after the name).
// Two <span> children per row; second one is the stats.
const spans = el.querySelectorAll('span');
const last = spans[spans.length - 1];
// Normalize whitespace.
return (last.textContent || '').replace(/\s+/g, ' ').trim();
}));
const offences = [];
for (const text of rows) {
// Adjacent middle-dot separators with optional spaces between them.
if (/·\s*·/.test(text)) offences.push(['adjacent-dots', text]);
// Trailing separator (e.g. "110 pkts ·").
if (/·\s*$/.test(text)) offences.push(['trailing-dot', text]);
// Leading separator (e.g. "· SNR ...").
if (/^\s*·/.test(text)) offences.push(['leading-dot', text]);
}
if (offences.length) {
const detail = offences.map(o => `[${o[0]}] "${o[1]}"`).join('\n ');
throw new Error('Found ' + offences.length + ' orphan-separator row(s):\n ' + detail);
}
});
await page.unroute('**/api/nodes/' + encodeURIComponent(pubkey) + '/health');
await browser.close();
console.log('\n=== #1151: ' + passed + ' passed, ' + failed + ' failed ===');
process.exit(failed ? 1 : 0);
})().catch((e) => { console.error('FATAL', e); process.exit(2); });
-227
View File
@@ -1,227 +0,0 @@
/**
* E2E tests for #1178 (Live header compactness + collapse toggle)
* and #1179 (Live controls pinned bottom-right + collapse toggle).
*
* Run: BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js
*
* Assertions:
* Desktop (1440x900):
* (a) .live-header bounding-rect height 40px.
* (b) .live-controls computed position is 'fixed' or 'absolute';
* right 24px; bottom is non-zero (safe-area / nav reservation).
* Narrow (360x800):
* (c) [data-live-header-toggle] visible; live-stats body hidden until click.
* (d) Clicking header toggle reveals the stats body.
* (e) [data-live-controls-toggle] visible; controls body hidden until click.
* (f) Clicking controls toggle reveals controls; expanded panel bottom +8 <
* (window.innerHeight bottomNavHeight). Bottom-nav height defaults
* to 56 if .bottom-nav is not present in the DOM.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
async function gotoLive(page) {
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 });
await page.waitForTimeout(400);
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== #1178/#1179 live layout E2E against ${BASE} ===`);
// ───── Desktop assertions ─────
{
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step('[1440x900] navigate to /live', async () => { await gotoLive(page); });
// (a)
await step('[1440x900] .live-header bounding-rect height ≤ 40px', async () => {
const h = await page.$eval('.live-header', el => el.getBoundingClientRect().height);
assert(h <= 40, `expected ≤40px, got ${h}px`);
});
// (b)
await step('[1440x900] .live-controls fixed/absolute, right ≤ 24px, bottom > 0', async () => {
const info = await page.evaluate(() => {
const el = document.querySelector('.live-controls');
if (!el) return null;
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
position: cs.position,
right: parseFloat(cs.right),
bottom: parseFloat(cs.bottom),
rectRight: r.right,
vw: window.innerWidth,
};
});
assert(info, '.live-controls element not found');
assert(info.position === 'fixed' || info.position === 'absolute',
`.live-controls position must be fixed/absolute, got ${info.position}`);
assert(info.right <= 24, `.live-controls right must be ≤24px, got ${info.right}px`);
assert(info.bottom > 0,
`.live-controls bottom must reserve space for safe-area/nav, got ${info.bottom}px`);
});
await ctx.close();
}
// ───── Narrow assertions ─────
{
const ctx = await browser.newContext({ viewport: { width: 360, height: 800 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step('[360x800] navigate to /live', async () => { await gotoLive(page); });
// (c0) Mesh-Operator review #1180: beacon + pkt count must remain visible
// even when the header body is collapsed at narrow widths.
await step('[360x800] beacon + pkt count visible while header body is collapsed', async () => {
const r = await page.evaluate(() => {
const beacon = document.querySelector('.live-beacon');
const pkt = document.querySelector('#livePktCount');
const body = document.querySelector('[data-live-header-body]');
const bodyHidden = body && (body.hasAttribute('hidden') ||
getComputedStyle(body).display === 'none');
function vis(el) {
if (!el) return false;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
return {
bodyHidden,
beaconVisible: vis(beacon),
pktVisible: vis(pkt),
// The pill wrapping the count must also be visible (not just the
// span hidden inside a collapsed parent with display:none).
pktPillVisible: vis(pkt && pkt.closest('.live-stat-pill')),
};
});
assert(r.bodyHidden, 'header body must be collapsed at narrow viewport pre-click');
assert(r.beaconVisible, '.live-beacon must remain visible while header body is collapsed');
assert(r.pktVisible, '#livePktCount must remain visible while header body is collapsed');
assert(r.pktPillVisible, 'pkt-count pill must remain visible while header body is collapsed');
});
// (c1) Mesh-Operator review #1180: toggles ≥48×48 tap target (#1060 floor,
// AGENTS' glove operability rule).
await step('[360x800] both toggles ≥48×48 tap target', async () => {
const r = await page.evaluate(() => {
function box(sel) {
const el = document.querySelector(sel);
if (!el) return null;
const rect = el.getBoundingClientRect();
return { w: rect.width, h: rect.height };
}
return {
header: box('[data-live-header-toggle]'),
controls: box('[data-live-controls-toggle]'),
};
});
assert(r.header, '[data-live-header-toggle] not found');
assert(r.controls, '[data-live-controls-toggle] not found');
assert(r.header.w >= 48 && r.header.h >= 48,
`header toggle must be ≥48×48, got ${r.header.w}×${r.header.h}`);
assert(r.controls.w >= 48 && r.controls.h >= 48,
`controls toggle must be ≥48×48, got ${r.controls.w}×${r.controls.h}`);
});
// (c)
await step('[360x800] header toggle visible; stats body hidden until click', async () => {
const r = await page.evaluate(() => {
const tog = document.querySelector('[data-live-header-toggle]');
const body = document.querySelector('[data-live-header-body]');
if (!tog || !body) return { tog: !!tog, body: !!body };
const togVis = getComputedStyle(tog).display !== 'none' &&
getComputedStyle(tog).visibility !== 'hidden';
const bodyHidden = body.hasAttribute('hidden') ||
getComputedStyle(body).display === 'none';
return { tog: true, body: true, togVis, bodyHidden };
});
assert(r.tog, '[data-live-header-toggle] not found');
assert(r.body, '[data-live-header-body] not found');
assert(r.togVis, '[data-live-header-toggle] not visible at 360px');
assert(r.bodyHidden, 'stats body must be hidden until toggle click');
});
// (d)
await step('[360x800] clicking header toggle reveals stats body', async () => {
await page.click('[data-live-header-toggle]');
const visible = await page.evaluate(() => {
const body = document.querySelector('[data-live-header-body]');
if (!body) return false;
return !body.hasAttribute('hidden') && getComputedStyle(body).display !== 'none';
});
assert(visible, 'stats body not visible after click');
});
// (e)
await step('[360x800] controls toggle visible; controls body hidden until click', async () => {
const r = await page.evaluate(() => {
const tog = document.querySelector('[data-live-controls-toggle]');
const body = document.querySelector('[data-live-controls-body]');
if (!tog || !body) return { tog: !!tog, body: !!body };
const togVis = getComputedStyle(tog).display !== 'none' &&
getComputedStyle(tog).visibility !== 'hidden';
const bodyHidden = body.hasAttribute('hidden') ||
getComputedStyle(body).display === 'none';
return { tog: true, body: true, togVis, bodyHidden };
});
assert(r.tog, '[data-live-controls-toggle] not found');
assert(r.body, '[data-live-controls-body] not found');
assert(r.togVis, '[data-live-controls-toggle] not visible at 360px');
assert(r.bodyHidden, 'controls body must be hidden until toggle click');
});
// (f)
await step('[360x800] clicking controls toggle reveals; no overlap with bottom-nav region', async () => {
await page.click('[data-live-controls-toggle]');
const r = await page.evaluate(() => {
const root = document.querySelector('.live-controls');
const body = document.querySelector('[data-live-controls-body]');
const nav = document.querySelector('.bottom-nav');
const navH = nav ? nav.getBoundingClientRect().height : 56;
const bodyVisible = body && !body.hasAttribute('hidden') &&
getComputedStyle(body).display !== 'none';
const expandedRect = root ? root.getBoundingClientRect() : null;
return {
bodyVisible,
expandedBottom: expandedRect ? expandedRect.bottom : null,
innerH: window.innerHeight,
navH,
isExpandedClass: root ? root.classList.contains('is-expanded') : false,
};
});
assert(r.bodyVisible, 'controls body not visible after click');
assert(r.expandedBottom !== null, '.live-controls element missing');
assert(r.expandedBottom + 8 < r.innerH - r.navH,
`expanded panel bottom (${r.expandedBottom}) + 8 must be < innerHeight (${r.innerH}) navH (${r.navH})`);
});
await ctx.close();
}
await browser.close();
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
-78
View File
@@ -1,78 +0,0 @@
/**
* E2E regression for #1180 review must-fix:
* MediaQueryList 'change' listener leak in wireLiveCollapseToggles().
*
* SPA navigates to /#/live, then bounces /#/explore /#/live N times.
* Each /#/live mount re-runs the wiring IIFE; without a guard, every
* mount calls narrowMql.addEventListener('change', applyForViewport)
* against a process-global MediaQueryList instance, so listeners
* accumulate without bound.
*
* live.js exposes a debug seam: window.__liveMQLBindCount is incremented
* exactly when the MQL listener is registered. After 5 round-trips it
* MUST be 1.
*
* Run: BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
async function gotoHash(page, hash) {
await page.evaluate((h) => { window.location.hash = h; }, hash);
// Allow router to run
await page.waitForTimeout(150);
}
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== #1180 MQL listener leak E2E against ${BASE} ===`);
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step('initial /#/live load registers MQL listener at most once', async () => {
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 });
await page.waitForTimeout(300);
const count = await page.evaluate(() => window.__liveMQLBindCount);
assert(typeof count === 'number',
'window.__liveMQLBindCount missing — debug seam not exposed by live.js');
assert(count <= 1, `expected MQL bind count ≤ 1 after first mount, got ${count}`);
});
await step('5 SPA round-trips do NOT accumulate MQL listeners', async () => {
for (let i = 0; i < 5; i++) {
await gotoHash(page, '#/packets');
await page.waitForTimeout(80);
await gotoHash(page, '#/live');
await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 });
await page.waitForTimeout(120);
}
const count = await page.evaluate(() => window.__liveMQLBindCount);
assert(typeof count === 'number',
'window.__liveMQLBindCount missing after navigations');
assert(count <= 1,
`MQL listener leak: bind count after 5 round-trips = ${count}, expected ≤ 1`);
});
await ctx.close();
await browser.close();
console.log(`\n=== Results: passed ${passed} failed ${failed} ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env node
/* Logo default-brand E2E verifies that the navbar + hero wordmarks
* render the sage/teal brand identity OUT OF THE BOX (no operator
* customizer override active), AND that the customizer can still
* override those colors when an operator picks a theme.
*
* Asserts:
* 1. Default load (clean localStorage, no overrides):
* navbar CORE.fill === rgb(207, 217, 201) // sage / fog
* navbar SCOPE.fill === rgb(44, 140, 140) // teal / water
* hero CORE/SCOPE same.
* 2. After a customizer override that sets accent=red (#dc2626) and
* accentHover=red-hover (#ef4444), the wordmark CORE+SCOPE recolors
* to follow the override (NOT sage/teal anymore).
*
* This is the contract from PR #1157 follow-up: sage/teal are the brand
* default, but the customizer remains the canonical theming surface.
*
* On master this test FAILS step 1 because the default --accent is
* #4a9eff (blue), so --logo-accent resolves to blue not sage.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const SAGE = 'rgb(207, 217, 201)';
const TEAL = 'rgb(44, 140, 140)';
function fail(msg) {
console.error(`test-logo-default-sage-teal-e2e.js: FAIL — ${msg}`);
process.exit(1);
}
async function readWordmark(page, sel) {
return await page.evaluate((s) => {
const root = document.querySelector(s);
if (!root) return { error: s + ' missing' };
const out = {};
root.querySelectorAll('svg text').forEach((t) => {
const tc = (t.textContent || '').trim();
if (tc === 'CORE' || tc === 'SCOPE') out[tc] = getComputedStyle(t).fill;
});
return { out };
}, sel);
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-logo-default-sage-teal-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-logo-default-sage-teal-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let passed = 0;
const total = 4;
try {
// ── Step 1: clean localStorage, default load → sage/teal ──
const ctx1 = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page1 = await ctx1.newPage();
page1.setDefaultTimeout(10000);
// Defensive: ensure no customizer overrides leak in.
await page1.addInitScript(() => {
try { localStorage.removeItem('cs-theme-overrides'); } catch (_) {}
try { localStorage.setItem('meshcore-user-level', 'experienced'); } catch (_) {}
});
await page1.goto(BASE + '/#/', { waitUntil: 'domcontentloaded' });
await page1.waitForSelector('.nav-brand svg.brand-logo text', { timeout: 8000 });
const navDefault = await readWordmark(page1, '.nav-brand');
if (navDefault.error) fail(navDefault.error);
if (!navDefault.out.CORE || !navDefault.out.SCOPE) {
fail(`default navbar CORE/SCOPE missing: ${JSON.stringify(navDefault.out)}`);
}
if (navDefault.out.CORE !== SAGE) {
fail(`default navbar CORE fill = ${navDefault.out.CORE}; expected sage ${SAGE}`);
}
if (navDefault.out.SCOPE !== TEAL) {
fail(`default navbar SCOPE fill = ${navDefault.out.SCOPE}; expected teal ${TEAL}`);
}
console.log(` ✅ default navbar wordmark is sage/teal (CORE=${navDefault.out.CORE}, SCOPE=${navDefault.out.SCOPE})`);
passed++;
await page1.evaluate(() => { window.location.hash = '#/home'; });
await page1.waitForFunction(() => location.hash === '#/home');
await page1.waitForSelector('.home-hero', { timeout: 8000 });
// Hero SVG can render after the route swap; wait for the wordmark text
// to actually exist before reading fills.
await page1.waitForFunction(() => {
const h = document.querySelector('.home-hero');
return !!(h && h.querySelector('svg text'));
}, null, { timeout: 8000 });
const heroDefault = await readWordmark(page1, '.home-hero');
if (heroDefault.error) fail(heroDefault.error);
if (heroDefault.out.CORE !== SAGE) {
fail(`default hero CORE fill = ${heroDefault.out.CORE}; expected sage ${SAGE}`);
}
if (heroDefault.out.SCOPE !== TEAL) {
fail(`default hero SCOPE fill = ${heroDefault.out.SCOPE}; expected teal ${TEAL}`);
}
console.log(` ✅ default hero wordmark is sage/teal (CORE=${heroDefault.out.CORE}, SCOPE=${heroDefault.out.SCOPE})`);
passed++;
await ctx1.close();
// ── Step 2: customizer override → red wordmark ──
const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page2 = await ctx2.newPage();
page2.setDefaultTimeout(10000);
// Seed the customizer override BEFORE first paint. customize-v2.js reads
// 'cs-theme-overrides' from localStorage on init and writes the matching
// CSS vars (including --logo-accent / --logo-accent-hi after this fix).
await page2.addInitScript(() => {
try {
localStorage.setItem('cs-theme-overrides', JSON.stringify({
theme: { accent: '#dc2626', accentHover: '#ef4444' },
themeDark: { accent: '#dc2626', accentHover: '#ef4444' },
}));
localStorage.setItem('meshcore-user-level', 'experienced');
} catch (_) {}
});
await page2.goto(BASE + '/#/', { waitUntil: 'domcontentloaded' });
await page2.waitForSelector('.nav-brand svg.brand-logo text', { timeout: 8000 });
// Settle one frame for early-apply to run.
await page2.waitForTimeout(200);
const navOverride = await readWordmark(page2, '.nav-brand');
if (navOverride.error) fail(navOverride.error);
if (navOverride.out.CORE === SAGE || navOverride.out.SCOPE === TEAL) {
fail(`customizer override did NOT reach the logo — still sage/teal: ${JSON.stringify(navOverride.out)}. Customizer must mirror --accent → --logo-accent.`);
}
// Both halves should follow the override (CORE ← accent, SCOPE ← accentHover).
if (navOverride.out.CORE !== 'rgb(220, 38, 38)') {
fail(`navbar CORE under customizer override = ${navOverride.out.CORE}; expected rgb(220, 38, 38)`);
}
if (navOverride.out.SCOPE !== 'rgb(239, 68, 68)') {
fail(`navbar SCOPE under customizer override = ${navOverride.out.SCOPE}; expected rgb(239, 68, 68)`);
}
console.log(` ✅ navbar wordmark follows customizer override (CORE=${navOverride.out.CORE}, SCOPE=${navOverride.out.SCOPE})`);
passed++;
await page2.evaluate(() => { window.location.hash = '#/home'; });
await page2.waitForFunction(() => location.hash === '#/home');
await page2.waitForSelector('.home-hero', { timeout: 8000 });
await page2.waitForFunction(() => {
const h = document.querySelector('.home-hero');
return !!(h && h.querySelector('svg text'));
}, null, { timeout: 8000 });
const heroOverride = await readWordmark(page2, '.home-hero');
if (heroOverride.error) fail(heroOverride.error);
if (heroOverride.out.CORE !== 'rgb(220, 38, 38)' || heroOverride.out.SCOPE !== 'rgb(239, 68, 68)') {
fail(`hero wordmark under customizer override = ${JSON.stringify(heroOverride.out)}; expected CORE=rgb(220,38,38), SCOPE=rgb(239,68,68)`);
}
console.log(` ✅ hero wordmark follows customizer override (CORE=${heroOverride.out.CORE}, SCOPE=${heroOverride.out.SCOPE})`);
passed++;
await ctx2.close();
await browser.close();
console.log(`\ntest-logo-default-sage-teal-e2e.js: ${passed}/${total} PASS`);
} catch (err) {
try { await browser.close(); } catch (_) {}
console.error(`test-logo-default-sage-teal-e2e.js: FAIL — ${err.message}`);
process.exit(1);
}
}
main();
-292
View File
@@ -1,292 +0,0 @@
/**
* E2E (#1173): Replace #liveDot WebSocket indicator with packet-driven
* brand-logo node-pulse animation.
*
* Red-then-green pattern (per AGENTS.md TDD rule). This file is committed
* BEFORE the implementation; CI must FAIL on assertion (not import error).
*
* The implementation must expose a deterministic test hook on
* window.__corescopeLogo
* with the following surface (pure CSS animations, no per-frame mutation):
* .pulse(msg) simulate one WS message arrival (rate-gated)
* .setConnected(b) simulate connect/disconnect class toggle
* .lastDirection 'a' or 'b' direction of most recent ping
* .stats { triggered, dropped }
* Implementations may also wire real WS handlers; this hook is the test seam.
*
* Usage: BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== #1173 logo-pulse E2E against ${BASE} ===`);
// ---- Default viewport (full brand-logo SVG visible) ----
{
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.brand-logo', { timeout: 8000 });
// Wait for app boot — hook should be installed during connectWS().
await page.waitForFunction(() => !!(window.__corescopeLogo && typeof window.__corescopeLogo.pulse === 'function'), null, { timeout: 8000 }).catch(()=>{});
// (a) #liveDot must NOT exist anywhere in the document.
await step('#liveDot is removed from the DOM', async () => {
const found = await page.evaluate(() => !!document.getElementById('liveDot'));
assert(!found, '#liveDot still present in DOM');
});
// (b) Both .brand-logo and .brand-mark-only carry the new pulse classes
// on their two inner circles.
await step('both logo SVGs have .logo-node-a and .logo-node-b circles', async () => {
const info = await page.evaluate(() => {
function probe(parentSel) {
const p = document.querySelector(parentSel);
if (!p) return { exists: false };
const a = p.querySelector('circle.logo-node-a');
const b = p.querySelector('circle.logo-node-b');
return { exists: true, hasA: !!a, hasB: !!b,
aCx: a && a.getAttribute('cx'), bCx: b && b.getAttribute('cx') };
}
return { full: probe('.brand-logo'), mark: probe('.brand-mark-only') };
});
assert(info.full.exists && info.full.hasA && info.full.hasB,
'.brand-logo missing pulse classes: ' + JSON.stringify(info.full));
assert(info.mark.exists && info.mark.hasA && info.mark.hasB,
'.brand-mark-only missing pulse classes: ' + JSON.stringify(info.mark));
assert(info.full.aCx === '540' && info.full.bCx === '660',
'pulse classes attached to wrong circles (expected cx=540/660): ' + JSON.stringify(info.full));
});
// (c) Test hook installed and pulse() toggles a class on the source circle.
await step('window.__corescopeLogo.pulse() toggles .logo-pulse-active on source circle', async () => {
const r = await page.evaluate(async () => {
if (!window.__corescopeLogo || typeof window.__corescopeLogo.pulse !== 'function') {
return { hookMissing: true };
}
const a = document.querySelector('.brand-logo circle.logo-node-a');
const b = document.querySelector('.brand-logo circle.logo-node-b');
const before = { a: a.classList.contains('logo-pulse-active'),
b: b.classList.contains('logo-pulse-active') };
window.__corescopeLogo.pulse({ synthetic: true });
// Class must be present synchronously OR within one rAF (≤16ms).
await new Promise(r => requestAnimationFrame(() => r()));
const after = { a: a.classList.contains('logo-pulse-active'),
b: b.classList.contains('logo-pulse-active'),
dir: window.__corescopeLogo.lastDirection };
return { hookMissing: false, before, after };
});
assert(!r.hookMissing, 'window.__corescopeLogo.pulse hook is missing');
// Either A or B must be active (the source of the first ping).
assert(r.after.a || r.after.b, 'no circle got .logo-pulse-active after first pulse: ' + JSON.stringify(r));
});
// (d) Direction alternates: 4 messages → toggles fire on alternating circles.
await step('direction alternates A→B / B→A across 4 pings', async () => {
const dirs = await page.evaluate(async () => {
// Wait long enough between pings to clear the rate gate.
const out = [];
for (let i = 0; i < 4; i++) {
window.__corescopeLogo.pulse({ synthetic: true });
out.push(window.__corescopeLogo.lastDirection);
await new Promise(r => setTimeout(r, 80));
}
return out;
});
// Expect a strict A,B,A,B (or B,A,B,A) alternation.
assert(dirs.length === 4, 'expected 4 direction samples, got ' + dirs.length);
assert(dirs[0] && dirs[1] && dirs[0] !== dirs[1], 'first two pings did not alternate: ' + dirs);
assert(dirs[2] === dirs[0] && dirs[3] === dirs[1],
'pings 3/4 did not alternate (expected ' + dirs[0] + ',' + dirs[1] + ',' + dirs[0] + ',' + dirs[1] + ', got ' + dirs.join(',') + ')');
});
// (e) Rate cap: 100 synthetic pulses within ~100ms → ≤16 toggles fire.
await step('rate-cap: 100 pulses in ~100ms drop most (≤16 trigger)', async () => {
const r = await page.evaluate(async () => {
const before = Object.assign({}, window.__corescopeLogo.stats);
const t0 = performance.now();
for (let i = 0; i < 100; i++) window.__corescopeLogo.pulse({ synthetic: true });
const t1 = performance.now();
const after = Object.assign({}, window.__corescopeLogo.stats);
return { before, after, elapsed: t1 - t0 };
});
const triggered = (r.after.triggered || 0) - (r.before.triggered || 0);
// Permit a small slack — 100 calls in <100ms should produce 1 ping
// (the rest hit the 66ms gate). Allow up to 16 to avoid flakes if the
// burst spans a window boundary.
assert(triggered >= 1 && triggered <= 16,
'rate-gate fired ' + triggered + ' times (expected 1..16) — stats=' + JSON.stringify(r));
});
// (g) Disconnect simulation: setConnected(false) → .logo-disconnected class.
await step('setConnected(false) puts .logo-disconnected on .brand-logo', async () => {
const has = await page.evaluate(() => {
window.__corescopeLogo.setConnected(false);
const full = document.querySelector('.brand-logo').classList.contains('logo-disconnected');
const mark = document.querySelector('.brand-mark-only').classList.contains('logo-disconnected');
// restore for next steps
window.__corescopeLogo.setConnected(true);
return { full, mark };
});
assert(has.full && has.mark, 'logo-disconnected not applied to both SVG instances: ' + JSON.stringify(has));
});
// (h) Theme: pulse circles get fill from --logo-accent / --logo-accent-hi.
await step('pulse circle fills resolve to --logo-accent/--logo-accent-hi tokens', async () => {
const r = await page.evaluate(() => {
const root = document.documentElement;
const cs = getComputedStyle(root);
const accent = cs.getPropertyValue('--logo-accent').trim();
const accentHi = cs.getPropertyValue('--logo-accent-hi').trim();
const a = document.querySelector('.brand-logo circle.logo-node-a');
const b = document.querySelector('.brand-logo circle.logo-node-b');
return {
accent, accentHi,
aFill: getComputedStyle(a).fill,
bFill: getComputedStyle(b).fill,
};
});
assert(r.accent && r.accentHi, '--logo-accent / --logo-accent-hi not defined');
// Computed fill resolves to rgb(...) — just sanity-check it is non-empty
// and not the default black/transparent.
assert(r.aFill && r.aFill !== 'rgb(0, 0, 0)' && r.aFill !== 'rgba(0, 0, 0, 0)', 'node-a fill not themed: ' + r.aFill);
assert(r.bFill && r.bFill !== 'rgb(0, 0, 0)' && r.bFill !== 'rgba(0, 0, 0, 0)', 'node-b fill not themed: ' + r.bFill);
});
await ctx.close();
}
// (f) prefers-reduced-motion: blip class differs from chained pulse class.
{
const ctx = await browser.newContext({
viewport: { width: 1280, height: 800 },
reducedMotion: 'reduce',
});
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.brand-logo', { timeout: 8000 });
await page.waitForFunction(() => !!(window.__corescopeLogo && typeof window.__corescopeLogo.pulse === 'function'), null, { timeout: 8000 }).catch(()=>{});
await step('prefers-reduced-motion: blip class is .logo-pulse-blip (not .logo-pulse-active)', async () => {
const r = await page.evaluate(async () => {
if (!window.__corescopeLogo) return { hookMissing: true };
window.__corescopeLogo.pulse({ synthetic: true });
await new Promise(r => requestAnimationFrame(() => r()));
const a = document.querySelector('.brand-logo circle.logo-node-a');
const b = document.querySelector('.brand-logo circle.logo-node-b');
return {
hookMissing: false,
activeA: a.classList.contains('logo-pulse-active'),
activeB: b.classList.contains('logo-pulse-active'),
blipA: a.classList.contains('logo-pulse-blip'),
blipB: b.classList.contains('logo-pulse-blip'),
};
});
assert(!r.hookMissing, 'window.__corescopeLogo hook missing in reduced-motion ctx');
assert(r.blipA || r.blipB, 'reduced-motion did not toggle .logo-pulse-blip: ' + JSON.stringify(r));
assert(!(r.activeA || r.activeB), 'reduced-motion incorrectly toggled chained .logo-pulse-active: ' + JSON.stringify(r));
});
await ctx.close();
}
// ---- Hidden-tab gate (#1177 carmack must-fix #1) ----
// When document.hidden=true, pulse() must return false BEFORE updating
// lastPingTs and BEFORE scheduling any rAF/setTimeout chain. No circle
// class toggles must occur.
{
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.brand-logo', { timeout: 8000 });
await page.waitForFunction(() => !!(window.__corescopeLogo && typeof window.__corescopeLogo.pulse === 'function'), null, { timeout: 8000 }).catch(()=>{});
await step('hidden tab: pulse() returns false and toggles no classes', async () => {
const r = await page.evaluate(async () => {
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
const before = Object.assign({}, window.__corescopeLogo.stats);
const ret = window.__corescopeLogo.pulse({ synthetic: true });
await new Promise(r => requestAnimationFrame(() => r()));
const a = document.querySelector('.brand-logo circle.logo-node-a');
const b = document.querySelector('.brand-logo circle.logo-node-b');
const after = Object.assign({}, window.__corescopeLogo.stats);
return {
ret, before, after,
activeA: a.classList.contains('logo-pulse-active'),
activeB: b.classList.contains('logo-pulse-active'),
blipA: a.classList.contains('logo-pulse-blip'),
blipB: b.classList.contains('logo-pulse-blip'),
};
});
assert(r.ret === false, 'pulse() should return false when document.hidden=true (got ' + r.ret + ')');
assert(!r.activeA && !r.activeB, 'logo-pulse-active should not toggle in hidden tab');
assert(!r.blipA && !r.blipB, 'logo-pulse-blip should not toggle in hidden tab');
assert((r.after.triggered || 0) === (r.before.triggered || 0),
'stats.triggered must not increment in hidden tab');
});
await ctx.close();
}
// ---- matchMedia caching (#1177 carmack must-fix #2) ----
// The reduced-motion query must be cached at module load. 100 pulses
// must NOT result in 100 window.matchMedia() calls.
{
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
// Wrap window.matchMedia BEFORE any app script runs.
await page.addInitScript(() => {
const orig = window.matchMedia;
window.__matchMediaCalls = 0;
window.matchMedia = function (q) {
try { window.__matchMediaCalls = (window.__matchMediaCalls | 0) + 1; } catch (_) {}
return orig.call(window, q);
};
});
await page.goto(BASE + '/#/home', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.brand-logo', { timeout: 8000 });
await page.waitForFunction(() => !!(window.__corescopeLogo && typeof window.__corescopeLogo.pulse === 'function'), null, { timeout: 8000 }).catch(()=>{});
await step('matchMedia: cached singleton — 100 pulses do not call window.matchMedia per pulse', async () => {
const r = await page.evaluate(async () => {
const callsBefore = window.__matchMediaCalls | 0;
for (let i = 0; i < 100; i++) window.__corescopeLogo.pulse({ synthetic: true });
await new Promise(r => setTimeout(r, 50));
const callsAfter = window.__matchMediaCalls | 0;
return { callsBefore, callsAfter, delta: callsAfter - callsBefore };
});
// 100 pulses → matchMedia should NOT be invoked per pulse. Allow 0 (cached).
assert(r.delta === 0,
'matchMedia called ' + r.delta + ' times during 100 pulses (expected 0 — should be cached at module load)');
});
await ctx.close();
}
await browser.close();
console.log(`\n=== #1173 logo-pulse E2E: ${passed} passed, ${failed} failed ===`);
process.exit(failed ? 1 : 0);
})();
-262
View File
@@ -1,262 +0,0 @@
#!/usr/bin/env node
/* Logo rebrand E2E verifies the new CoreScope SVG logo is wired into
* the navbar (replacing the 🍄 emoji + "CoreScope" text) and that the
* homepage renders a hero version of the logo above the H1.
*
* Asserts (in order):
* 1. Navbar has an <img> whose src ends with /img/corescope-logo.svg
* OR an inline <svg class="brand-logo"> (PR #1137 inlined the SVG so
* it can inherit page CSS vars and theme on light/dark).
* The brand element must be INSIDE the .nav-brand link (so the brand
* link stays clickable).
* 2. Old .brand-icon (🍄) and .brand-text spans are gone.
* 3. The .live-dot WS-status indicator is still present and visible
* and sits to the right of the logo (left edge of dot right edge of img).
* 4. The home page (#/home) renders an <img.home-hero-logo> whose src
* ends with /img/corescope-hero.svg, ABOVE the .home-hero h1.
* 5. Both SVG assets resolve with HTTP 200 and content-type contains
* "svg" (catches a missing file regression cleanly).
*
* CI gating mirrors the existing playwright e2e tests: with
* CHROMIUM_REQUIRE=1 a missing Chromium is a HARD FAIL.
*/
'use strict';
const { chromium } = require('playwright');
const http = require('http');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
function fail(msg) {
console.error(`test-logo-rebrand-e2e.js: FAIL — ${msg}`);
process.exit(1);
}
function assert(cond, msg) { if (!cond) fail(msg || 'assertion failed'); }
async function head(url) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = http.request({
method: 'GET',
hostname: u.hostname,
port: u.port || 80,
path: u.pathname + (u.search || ''),
}, (res) => {
let body = '';
res.on('data', (c) => { body += c; if (body.length > 4096) body = body.slice(0, 4096); });
res.on('end', () => resolve({ status: res.statusCode, ct: res.headers['content-type'] || '', sample: body }));
});
req.on('error', reject);
req.end();
});
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-logo-rebrand-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-logo-rebrand-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let passed = 0;
const total = 6;
try {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
page.setDefaultTimeout(10000);
// 1. Navbar has the brand logo inside .nav-brand. Post PR #1137 the
// default is an inline <svg.brand-logo>; if an operator overrode
// branding.logoUrl the customizer swaps it for an <img.brand-logo>.
await page.goto(BASE + '/#/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.nav-brand', { timeout: 8000 });
const navBrand = await page.evaluate(() => {
const el = document.querySelector('.nav-brand .brand-logo');
if (!el) return { ok: false, reason: 'no .brand-logo in .nav-brand' };
const tag = el.tagName.toLowerCase();
if (tag === 'img') {
const src = el.getAttribute('src') || '';
return { ok: /corescope-logo\.svg($|\?)/.test(src), tag, src };
}
if (tag === 'svg') {
// Inline SVG default — verify it actually renders the brand artwork.
const hasText = !!el.querySelector('text');
return { ok: hasText, tag, src: '<inline-svg>' };
}
return { ok: false, reason: 'unexpected .brand-logo tag: ' + tag };
});
if (!navBrand.ok) {
fail(`navbar .brand-logo invalid (${navBrand.reason || 'tag=' + navBrand.tag + ' src=' + navBrand.src})`);
}
console.log(` ✅ navbar contains .brand-logo (${navBrand.tag})`);
passed++;
// 2. Old emoji + brand-text are gone
const oldIcon = await page.$('.nav-brand .brand-icon');
const oldText = await page.$('.nav-brand .brand-text');
if (oldIcon || oldText) fail('legacy .brand-icon / .brand-text still present (should be replaced by SVG logo)');
console.log(' ✅ legacy mushroom emoji + "CoreScope" text removed');
passed++;
// 3. WS connection state indicator: #1173 replaced .live-dot with the
// packet-driven brand-logo pulse. The state surface is the .brand-logo
// SVG itself (gains .logo-disconnected on close, removes it on open),
// and the test seam at window.__corescopeLogo.
//
// Note: the previous version of this test asserted the geometry of
// the .live-dot relative to the brand-logo (dot must be to the right
// of the SVG). That coverage is replaced with a brand-logo layout
// assertion (visible, non-zero box, sensible aspect) so SVG rendering
// regressions are still caught — they simply moved targets.
const noLegacyDot = await page.$('.nav-brand .live-dot, .nav-brand #liveDot');
if (noLegacyDot) fail('.live-dot / #liveDot still present — should have been removed by #1173');
const seam = await page.evaluate(() => {
return !!(window.__corescopeLogo && typeof window.__corescopeLogo.setConnected === 'function' && typeof window.__corescopeLogo.pulse === 'function');
});
if (!seam) fail('window.__corescopeLogo (setConnected + pulse) is the new WS-state seam — missing');
// Brand-logo layout sanity (replaces the dot-right-of-logo geometry assertion).
const brandLayout = await page.evaluate(() => {
const i = document.querySelector('.nav-brand .brand-logo');
if (!i) return { ok: false, reason: 'no .brand-logo' };
const r = i.getBoundingClientRect();
const cs = getComputedStyle(i);
return {
ok: true,
w: r.width, h: r.height,
visible: cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity || '1') > 0,
};
});
// assert: brand-logo is visibly rendered with a sensible box.
assert(brandLayout.ok, 'brand-logo layout probe failed: ' + brandLayout.reason);
assert(brandLayout.visible, 'brand-logo not visible (display/visibility/opacity)');
assert(brandLayout.w >= 60 && brandLayout.h >= 16,
`brand-logo too small: ${brandLayout.w.toFixed(1)}×${brandLayout.h.toFixed(1)} (expected ≥60×16)`);
console.log(' ✅ legacy .live-dot removed; brand-logo Logo state seam present; brand-logo layout sane');
passed++;
// 4. Home hero image — ensure user level is set so we render the hero,
// not the new-user chooser screen.
await page.evaluate(() => { try { localStorage.setItem('meshcore-user-level', 'experienced'); } catch (_) {} });
await page.evaluate(() => { window.location.hash = '#/home'; });
await page.waitForFunction(() => location.hash === '#/home');
// Reload so the SPA router picks up the route AND localStorage is honored.
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.home-hero', { timeout: 8000 });
const heroBrand = await page.evaluate(() => {
const hero = document.querySelector('.home-hero');
if (!hero) return { ok: false, reason: '.home-hero missing' };
// PR #1137: inline <svg.home-hero-logo> by default; legacy <img> still
// valid for any operator who shipped a custom build.
const el = hero.querySelector('.home-hero-logo');
if (!el) return { ok: false, reason: '.home-hero-logo missing inside .home-hero' };
const tag = el.tagName.toLowerCase();
if (tag === 'img') {
const src = el.getAttribute('src') || '';
return { ok: /corescope-hero\.svg($|\?)/.test(src), tag, src };
}
if (tag === 'svg') {
const hasText = !!el.querySelector('text');
return { ok: hasText, tag };
}
return { ok: false, reason: 'unexpected .home-hero-logo tag: ' + tag };
});
if (!heroBrand.ok) {
fail(`home page .home-hero-logo invalid (${heroBrand.reason || 'tag=' + heroBrand.tag})`);
}
const order = await page.evaluate(() => {
const hero = document.querySelector('.home-hero');
if (!hero) return -1;
const img = hero.querySelector('.home-hero-logo');
const h1 = hero.querySelector('h1');
if (!img || !h1) return -2;
return (img.compareDocumentPosition(h1) & Node.DOCUMENT_POSITION_FOLLOWING) ? 1 : 0;
});
if (order !== 1) fail(`home-hero brand element must precede the <h1> (compareDocumentPosition=${order})`);
console.log(` ✅ home page hero contains .home-hero-logo (${heroBrand.tag}) above the h1`);
passed++;
// 5. Both assets actually serve
const [a, b] = await Promise.all([
head(BASE + '/img/corescope-logo.svg'),
head(BASE + '/img/corescope-hero.svg'),
]);
if (a.status !== 200 || !/svg/i.test(a.ct)) fail(`/img/corescope-logo.svg → status=${a.status} ct=${a.ct}`);
if (b.status !== 200 || !/svg/i.test(b.ct)) fail(`/img/corescope-hero.svg → status=${b.status} ct=${b.ct}`);
console.log(' ✅ both /img/corescope-{logo,hero}.svg return 200 with svg content-type');
passed++;
// 6. Customizer override path still works after the rebrand. Operators
// can override branding.siteName + branding.logoUrl via the customizer
// (cs-theme-overrides localStorage key in customize-v2.js); the old
// code mutated .brand-text / .brand-icon (which no longer exist), so
// a naive removal silently breaks the override flow. Verify the navbar
// logo <img> picks up the override on next load.
await page.evaluate(() => {
try {
// customize-v2.js storage key for live overrides.
localStorage.setItem('cs-theme-overrides', JSON.stringify({
branding: { siteName: 'OverrideSite', logoUrl: '/img/corescope-logo.svg?override=1' }
}));
} catch (_) {}
});
await page.goto(BASE + '/#/', { waitUntil: 'networkidle' });
// PR #1137: default brand is inline <svg>; the override path swaps it
// for an <img>. Wait for either tag to be present (boot-time render).
await page.waitForSelector('.nav-brand .brand-logo', { timeout: 8000 });
// Force-apply the override pipeline (in case _customizerV2.init was racing
// /api/config/theme — production code's DOMContentLoaded boot path runs
// synchronously, but instrumented JS in CI can be slower).
await page.evaluate(() => {
try {
if (window._customizerV2 && typeof window._customizerV2.init === 'function') {
window._customizerV2.init(window.SITE_CONFIG || {});
}
} catch (_) {}
});
// Give pipeline a moment to settle: the helper swaps inline-<svg> → <img>.
await page.waitForFunction(() => {
var img = document.querySelector('.nav-brand img');
return img && /override=1/.test(img.getAttribute('src') || '');
}, { timeout: 5000 }).catch(() => {});
const overrideState = await page.evaluate(() => {
var img = document.querySelector('.nav-brand img');
return {
src: img ? img.getAttribute('src') || '' : null,
alt: img ? img.getAttribute('alt') || '' : null,
title: document.title,
hasV2: !!window._customizerV2,
ovStored: localStorage.getItem('cs-theme-overrides'),
};
});
if (!overrideState.src || !/override=1/.test(overrideState.src)) {
fail(`customizer logoUrl override did not propagate to navbar img (src=${overrideState.src} hasV2=${overrideState.hasV2} ovStored=${overrideState.ovStored})`);
}
if (overrideState.title !== 'OverrideSite') {
fail(`customizer siteName override did not update document.title (got: ${overrideState.title})`);
}
console.log(' ✅ customizer branding.siteName + branding.logoUrl overrides still apply post-rebrand');
passed++;
// Clean up the override so subsequent test runs aren't polluted.
await page.evaluate(() => { try { localStorage.removeItem('cs-theme-overrides'); } catch (_) {} });
await browser.close();
console.log(`\ntest-logo-rebrand-e2e.js: ${passed}/${total} PASS`);
} catch (err) {
try { await browser.close(); } catch (_) {}
console.error(`test-logo-rebrand-e2e.js: FAIL — ${err.message}`);
process.exit(1);
}
}
main();
-380
View File
@@ -1,380 +0,0 @@
#!/usr/bin/env node
/* Logo theme reactivity E2E verifies that the navbar + hero logos
* inherit page CSS custom properties and remain visible when the user
* switches to the Light theme.
*
* Asserts:
* 1. With data-theme="light", the navbar wordmark CORE/SCOPE elements
* have a computed fill that is NOT the legacy hardcoded sage
* (#cfd9c9 / rgb(207,217,201)).
* 2. The hero SVG does NOT contain a full-canvas opaque background
* rect (no <rect width=1200 height=300> with a non-transparent fill
* reachable via the inline SVG in the home-hero region).
* 3. The hero wordmark CORE/SCOPE compute-fills also drop the legacy
* sage hex when the page theme is Light.
* 4. The navbar wordmark is duotone CORE fill !== SCOPE fill and
* remains so under both default (dark) and Light themes. Proves the
* fog/teal split survives the light-theme rebind.
* 5. The hero wordmark is also duotone (CORE !== SCOPE) under both
* themes.
* 6. At mobile width (360x640), the navbar swaps to a mark-only
* .brand-mark-only inline SVG (visible) while the full .brand-logo
* is display:none preventing the SCOPESCOF clip seen with the
* 99px mobile pin from #1137. Also asserts the visible navbar logo
* fits within .nav-left's right edge (no horizontal overflow).
*
* Designed to FAIL on the pre-fix branch (where the SVGs are loaded as
* <img>, the wordmark fill is baked to #cfd9c9, and the hero SVG ships a
* solid <rect fill="var(--logo-bg, #0e1714)">).
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
// Note: rgb(207, 217, 201) is the brand sage default for --logo-accent
// (see test-logo-default-sage-teal-e2e.js). It is NO LONGER a failure
// signal here; the original "must not be sage" assertion was written
// when sage meant "baked-into-SVG-attr regression" and the wordmark was
// supposed to follow --accent (then blue). Now sage is the intentional
// brand identity and the test below asserts theme-reactivity by mutating
// --logo-accent directly and observing the fill change instead.
function fail(msg) {
console.error(`test-logo-theme-e2e.js: FAIL — ${msg}`);
process.exit(1);
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
} catch (err) {
if (requireChromium) {
console.error(`test-logo-theme-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-logo-theme-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let passed = 0;
const total = 7;
try {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
page.setDefaultTimeout(10000);
// Force Light theme BEFORE first navigation so initial paint uses it.
await page.addInitScript(() => {
try { localStorage.setItem('meshcore-user-level', 'experienced'); } catch (_) {}
});
await page.goto(BASE + '/#/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.nav-brand', { timeout: 8000 });
await page.evaluate(() => { document.documentElement.setAttribute('data-theme', 'light'); });
// 1. Navbar wordmark must be inline-SVG <text> (not <img>) and computed
// fill must be theme-reactive: setting --logo-accent / --logo-accent-hi
// on :root must repaint the wordmark.
const navWordmarkFills = await page.evaluate(() => {
const out = [];
const root = document.querySelector('.nav-brand');
if (!root) return { error: '.nav-brand missing' };
const texts = root.querySelectorAll('svg text');
texts.forEach((t) => {
const tc = (t.textContent || '').trim();
if (tc === 'CORE' || tc === 'SCOPE') {
out.push({ tc, fill: getComputedStyle(t).fill });
}
});
return { out };
});
if (navWordmarkFills.error) fail(navWordmarkFills.error);
if (!navWordmarkFills.out || navWordmarkFills.out.length < 2) {
fail(`navbar inline-SVG wordmark <text> CORE/SCOPE not found (found: ${JSON.stringify(navWordmarkFills.out)}). Navbar logo must be inline <svg> so CSS vars apply.`);
}
// Theme-reactivity probe: override --logo-accent / --logo-accent-hi and
// confirm fills change. This replaces the old "must not be legacy sage"
// assertion (sage is now the brand default — see test-logo-default-sage-teal-e2e.js).
const navReact = await page.evaluate(() => {
const root = document.querySelector('.nav-brand');
const before = {};
root.querySelectorAll('svg text').forEach((t) => {
const tc = (t.textContent || '').trim();
if (tc === 'CORE' || tc === 'SCOPE') before[tc] = getComputedStyle(t).fill;
});
document.documentElement.style.setProperty('--logo-accent', '#123456');
document.documentElement.style.setProperty('--logo-accent-hi', '#abcdef');
const after = {};
root.querySelectorAll('svg text').forEach((t) => {
const tc = (t.textContent || '').trim();
if (tc === 'CORE' || tc === 'SCOPE') after[tc] = getComputedStyle(t).fill;
});
// Reset so later assertions on default colors aren't polluted.
document.documentElement.style.removeProperty('--logo-accent');
document.documentElement.style.removeProperty('--logo-accent-hi');
return { before, after };
});
if (navReact.before.CORE === navReact.after.CORE) {
fail(`navbar CORE fill did not change when --logo-accent was overridden (${navReact.before.CORE}${navReact.after.CORE}); wordmark must theme via --logo-accent`);
}
if (navReact.before.SCOPE === navReact.after.SCOPE) {
fail(`navbar SCOPE fill did not change when --logo-accent-hi was overridden (${navReact.before.SCOPE}${navReact.after.SCOPE}); wordmark must theme via --logo-accent-hi`);
}
console.log(` ✅ navbar wordmark fills are theme-reactive (CORE ${navReact.before.CORE}${navReact.after.CORE}, SCOPE ${navReact.before.SCOPE}${navReact.after.SCOPE})`);
passed++;
// 2. Hero SVG must NOT have a full-canvas opaque background rect.
await page.evaluate(() => { window.location.hash = '#/home'; });
await page.waitForFunction(() => location.hash === '#/home' || location.hash === '#/');
await page.waitForSelector('.home-hero', { timeout: 8000 });
// Ensure light theme survives reload.
await page.evaluate(() => { document.documentElement.setAttribute('data-theme', 'light'); });
const heroBg = await page.evaluate(() => {
const hero = document.querySelector('.home-hero');
if (!hero) return { error: '.home-hero missing' };
const svg = hero.querySelector('svg');
if (!svg) return { error: '.home-hero has no inline <svg> child (hero must be inline so CSS vars apply)' };
// Look for a child <rect> that covers the entire viewBox with a non-transparent fill.
const rects = svg.querySelectorAll('rect');
const offending = [];
rects.forEach((r) => {
const w = r.getAttribute('width') || '';
const h = r.getAttribute('height') || '';
const cs = getComputedStyle(r);
const fill = cs.fill || '';
const op = parseFloat(cs.fillOpacity || '1');
// legacy hero shipped <rect width=1200 height=300 fill=var(--logo-bg, #0e1714)>
if ((w === '1200' || w === '100%') && (h === '300' || h === '100%') && fill && fill !== 'none' && fill !== 'rgba(0, 0, 0, 0)' && op > 0.05) {
offending.push({ w, h, fill, op });
}
});
return { offending, rectCount: rects.length };
});
if (heroBg.error) fail(heroBg.error);
if (heroBg.offending && heroBg.offending.length > 0) {
fail(`hero SVG has full-canvas opaque background rect — paints over light theme: ${JSON.stringify(heroBg.offending)}`);
}
console.log(` ✅ hero SVG has no full-canvas opaque background rect`);
passed++;
// 3. Hero wordmark CORE/SCOPE must be theme-reactive — overriding
// --logo-accent / --logo-accent-hi must repaint the hero wordmark too.
const heroWordmarkFills = await page.evaluate(() => {
const hero = document.querySelector('.home-hero');
if (!hero) return { error: '.home-hero missing' };
const out = [];
hero.querySelectorAll('svg text').forEach((t) => {
const tc = (t.textContent || '').trim();
if (tc === 'CORE' || tc === 'SCOPE') {
out.push({ tc, fill: getComputedStyle(t).fill });
}
});
return { out };
});
if (heroWordmarkFills.error) fail(heroWordmarkFills.error);
if (!heroWordmarkFills.out || heroWordmarkFills.out.length < 2) {
fail(`hero inline-SVG wordmark <text> CORE/SCOPE not found (found: ${JSON.stringify(heroWordmarkFills.out)})`);
}
const heroReact = await page.evaluate(() => {
const hero = document.querySelector('.home-hero');
const before = {};
hero.querySelectorAll('svg text').forEach((t) => {
const tc = (t.textContent || '').trim();
if (tc === 'CORE' || tc === 'SCOPE') before[tc] = getComputedStyle(t).fill;
});
document.documentElement.style.setProperty('--logo-accent', '#654321');
document.documentElement.style.setProperty('--logo-accent-hi', '#fedcba');
const after = {};
hero.querySelectorAll('svg text').forEach((t) => {
const tc = (t.textContent || '').trim();
if (tc === 'CORE' || tc === 'SCOPE') after[tc] = getComputedStyle(t).fill;
});
document.documentElement.style.removeProperty('--logo-accent');
document.documentElement.style.removeProperty('--logo-accent-hi');
return { before, after };
});
if (heroReact.before.CORE === heroReact.after.CORE) {
fail(`hero CORE fill did not change when --logo-accent was overridden (${heroReact.before.CORE}${heroReact.after.CORE})`);
}
if (heroReact.before.SCOPE === heroReact.after.SCOPE) {
fail(`hero SCOPE fill did not change when --logo-accent-hi was overridden (${heroReact.before.SCOPE}${heroReact.after.SCOPE})`);
}
console.log(` ✅ hero wordmark fills are theme-reactive (CORE ${heroReact.before.CORE}${heroReact.after.CORE}, SCOPE ${heroReact.before.SCOPE}${heroReact.after.SCOPE})`);
passed++;
// 4 & 5. Duotone — CORE fill must differ from SCOPE fill in BOTH navbar
// and hero, under BOTH default (dark) and Light themes. Proves the
// fog/teal split is preserved across theme rebinds.
async function fillsByText(rootSelector) {
return await page.evaluate((sel) => {
const root = document.querySelector(sel);
if (!root) return { error: sel + ' missing' };
const m = {};
root.querySelectorAll('svg text').forEach((t) => {
const tc = (t.textContent || '').trim();
if (tc === 'CORE' || tc === 'SCOPE') m[tc] = getComputedStyle(t).fill;
});
return { m };
}, rootSelector);
}
function isNearWhiteOrBlack(rgb) {
const m = String(rgb).match(/rgb\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return false;
const [r, g, b] = [+m[1], +m[2], +m[3]];
const max = Math.max(r, g, b), min = Math.min(r, g, b);
// near-white: all >= 235. near-black: all <= 25 AND low chroma.
if (r >= 235 && g >= 235 && b >= 235) return true;
if (r <= 25 && g <= 25 && b <= 25) return true;
// also flag fully-desaturated greys (chroma < 10)
if ((max - min) < 10 && max > 60 && max < 200) return true;
return false;
}
// Navigate back to root + force DEFAULT (dark) theme.
await page.evaluate(() => { window.location.hash = '#/'; });
await page.waitForFunction(() => location.hash === '#/home' || location.hash === '#/');
await page.waitForSelector('.nav-brand', { timeout: 8000 });
await page.evaluate(() => { document.documentElement.removeAttribute('data-theme'); });
const navDark = await fillsByText('.nav-brand');
if (navDark.error) fail(navDark.error);
if (!navDark.m.CORE || !navDark.m.SCOPE) fail(`navbar (dark) missing CORE/SCOPE: ${JSON.stringify(navDark.m)}`);
if (navDark.m.CORE === navDark.m.SCOPE) {
fail(`navbar (dark) wordmark is monotone — CORE=${navDark.m.CORE} SCOPE=${navDark.m.SCOPE}; duotone (fog/teal) must be preserved`);
}
if (isNearWhiteOrBlack(navDark.m.CORE)) fail(`navbar (dark) CORE fill is near-white/black/grey: ${navDark.m.CORE}`);
if (isNearWhiteOrBlack(navDark.m.SCOPE)) fail(`navbar (dark) SCOPE fill is near-white/black/grey: ${navDark.m.SCOPE}`);
// Light theme
await page.evaluate(() => { document.documentElement.setAttribute('data-theme', 'light'); });
const navLight = await fillsByText('.nav-brand');
if (navLight.error) fail(navLight.error);
if (navLight.m.CORE === navLight.m.SCOPE) {
fail(`navbar (light) wordmark is monotone — CORE=${navLight.m.CORE} SCOPE=${navLight.m.SCOPE}; duotone must survive light-theme rebind`);
}
console.log(` ✅ navbar duotone preserved (dark: CORE=${navDark.m.CORE} SCOPE=${navDark.m.SCOPE}; light: CORE=${navLight.m.CORE} SCOPE=${navLight.m.SCOPE})`);
passed++;
// Hero duotone
await page.evaluate(() => { window.location.hash = '#/home'; });
await page.waitForFunction(() => location.hash === '#/home' || location.hash === '#/');
await page.waitForSelector('.home-hero', { timeout: 8000 });
await page.evaluate(() => { document.documentElement.removeAttribute('data-theme'); });
const heroDark = await fillsByText('.home-hero');
if (heroDark.error) fail(heroDark.error);
if (heroDark.m.CORE === heroDark.m.SCOPE) {
fail(`hero (dark) wordmark is monotone — CORE=${heroDark.m.CORE} SCOPE=${heroDark.m.SCOPE}; duotone must be preserved`);
}
if (isNearWhiteOrBlack(heroDark.m.CORE)) fail(`hero (dark) CORE fill is near-white/black/grey: ${heroDark.m.CORE}`);
if (isNearWhiteOrBlack(heroDark.m.SCOPE)) fail(`hero (dark) SCOPE fill is near-white/black/grey: ${heroDark.m.SCOPE}`);
await page.evaluate(() => { document.documentElement.setAttribute('data-theme', 'light'); });
const heroLight = await fillsByText('.home-hero');
if (heroLight.error) fail(heroLight.error);
if (heroLight.m.CORE === heroLight.m.SCOPE) {
fail(`hero (light) wordmark is monotone — CORE=${heroLight.m.CORE} SCOPE=${heroLight.m.SCOPE}; duotone must survive light-theme rebind`);
}
console.log(` ✅ hero duotone preserved (dark: CORE=${heroDark.m.CORE} SCOPE=${heroDark.m.SCOPE}; light: CORE=${heroLight.m.CORE} SCOPE=${heroLight.m.SCOPE})`);
passed++;
// 6. Mobile fit: at 360x640 the full wordmark logo must be hidden and
// a mark-only .brand-mark-only inline SVG must take its place. Also
// asserts the visible logo's right edge does not overflow .nav-left.
await page.setViewportSize({ width: 360, height: 640 });
await page.evaluate(() => { window.location.hash = '#/'; });
await page.waitForFunction(() => location.hash === '#/home' || location.hash === '#/');
await page.waitForSelector('.nav-brand', { timeout: 8000 });
// Allow CSS media query to settle.
await page.waitForTimeout(100);
const mobile = await page.evaluate(() => {
const brand = document.querySelector('.nav-brand');
if (!brand) return { error: '.nav-brand missing' };
const full = brand.querySelector('svg.brand-logo');
const mark = brand.querySelector('svg.brand-mark-only');
const left = document.querySelector('.nav-left');
const fullVisible = full ? getComputedStyle(full).display !== 'none' : null;
const markVisible = mark ? getComputedStyle(mark).display !== 'none' : null;
const visibleSvg = (mark && markVisible) ? mark : (full && fullVisible) ? full : null;
const visRect = visibleSvg ? visibleSvg.getBoundingClientRect() : null;
const leftRect = left ? left.getBoundingClientRect() : null;
return {
hasFull: !!full,
hasMark: !!mark,
fullVisible,
markVisible,
visRectRight: visRect ? visRect.right : null,
leftRectRight: leftRect ? leftRect.right : null,
viewportWidth: window.innerWidth,
};
});
if (mobile.error) fail(mobile.error);
if (!mobile.hasMark) {
fail(`mobile: .brand-mark-only inline SVG missing — required to avoid SCOPE→SCOF clip on ≤400px viewports`);
}
if (!mobile.markVisible) {
fail(`mobile: .brand-mark-only is hidden at 360px — must be display!=none on ≤400px viewports (computed: hidden)`);
}
if (mobile.fullVisible) {
fail(`mobile: .brand-logo (full wordmark SVG) still display!=none at 360px — must be hidden so it cannot clip; visibleRight=${mobile.visRectRight}`);
}
if (mobile.visRectRight !== null && mobile.viewportWidth > 0 && mobile.visRectRight > mobile.viewportWidth) {
fail(`mobile: visible navbar logo right edge ${mobile.visRectRight}px overflows viewport (${mobile.viewportWidth}px)`);
}
console.log(` ✅ mobile (360px): mark-only swap active (full hidden, mark visible, right=${mobile.visRectRight}px ≤ viewport ${mobile.viewportWidth}px)`);
passed++;
// 7. Desktop wordmark must NOT clip — every <text> element's bbox in
// user-space coords must lie fully inside the SVG's viewBox. The
// original navbar SVG ships with viewBox "170 10 860 280" (right
// edge x=1030), but the SCOPE <text> with text-anchor="start" at
// x=773.8 + width≈338 extends to x≈1111 — clipped to "SCOP" at
// every desktop viewport width. Fix: widen the viewBox so the
// wordmark fits.
await page.setViewportSize({ width: 1280, height: 800 });
await page.evaluate(() => { window.location.hash = '#/'; });
await page.waitForFunction(() => location.hash === '#/home' || location.hash === '#/');
await page.waitForSelector('.nav-brand svg.brand-logo', { timeout: 8000 });
await page.waitForTimeout(150);
const clip = await page.evaluate(() => {
const svg = document.querySelector('.nav-brand svg.brand-logo');
if (!svg) return { error: '.nav-brand svg.brand-logo missing' };
const vb = (svg.getAttribute('viewBox') || '').split(/\s+/).map(Number);
if (vb.length !== 4) return { error: 'viewBox malformed: ' + svg.getAttribute('viewBox') };
const [vx, vy, vw, vh] = vb;
const offenders = [];
svg.querySelectorAll('text').forEach((t) => {
const tc = (t.textContent || '').trim();
if (tc !== 'CORE' && tc !== 'SCOPE') return;
const bb = t.getBBox();
if (bb.x < vx - 0.5 || bb.x + bb.width > vx + vw + 0.5) {
offenders.push({ text: tc, bboxX: bb.x, bboxRight: bb.x + bb.width, vbX: vx, vbRight: vx + vw });
}
});
return { viewBox: vb, offenders };
});
if (clip.error) fail(clip.error);
if (clip.offenders && clip.offenders.length) {
fail(`desktop: wordmark <text> overflows SVG viewBox (will be clipped): ${JSON.stringify(clip.offenders)}`);
}
console.log(` ✅ desktop (1280px): CORE/SCOPE bboxes fit inside viewBox ${JSON.stringify(clip.viewBox)}`);
passed++;
await browser.close();
console.log(`\ntest-logo-theme-e2e.js: ${passed}/${total} PASS`);
} catch (err) {
try { await browser.close(); } catch (_) {}
console.error(`test-logo-theme-e2e.js: FAIL — ${err.message}`);
process.exit(1);
}
}
main();
-275
View File
@@ -1,275 +0,0 @@
/**
* E2E (#1059): Map controls + modal fluid/safe-max-height behavior.
*
* Strengthened per polish review (round 2):
* - MAJOR-1: assert .modal max-height is STRICTLY > 80vh (i.e. >= 90vh);
* reject 80vh by inspecting the computed pixel value.
* - MAJOR-2: behavioral sticky-close test inflate modal body past viewport,
* scroll modal content to the bottom, assert close button still inside
* viewport AND clickable (elementFromPoint at its center returns the close
* button or its child).
* - MAJOR-3: inject 100 tall paragraphs into BYOP modal body to force the
* overflow scenario (otherwise the modal never grows past 90vh and the
* overflow path is never exercised).
* - MAJOR-4 AC1: at 768x900, inject a synthetic .leaflet-marker-icon at the
* top-right of the leaflet container (where map controls live) and assert
* no .map-controls element bounds overlap the marker bounds.
* - MAJOR-4 AC2: at 2560x1440, assert .leaflet-container width >= 2400px
* (map fills extra horizontal space on ultrawide).
* - MAJOR-5: viewports list includes 1080 (matches PR body).
*
* Usage: BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1059 map+modal fluid E2E (strengthened) against ${BASE} ===`);
// --- Map page: no horizontal scroll across viewports (incl. 1080 per PR body) ---
const viewports = [
{ w: 1024, h: 768 },
{ w: 1080, h: 800 }, // MAJOR-5: aligns with PR body claim
{ w: 1440, h: 900 },
{ w: 1920, h: 1080 },
{ w: 2560, h: 1440 },
];
for (const v of viewports) {
await step(`no horizontal scroll on /#/map at ${v.w}x${v.h}`, async () => {
await page.setViewportSize({ width: v.w, height: v.h });
await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#leaflet-map', { timeout: 8000 });
await page.waitForTimeout(300);
const overflow = await page.evaluate(() => ({
sw: document.documentElement.scrollWidth,
cw: document.documentElement.clientWidth,
}));
assert(overflow.sw <= overflow.cw + 1,
`horizontal scroll: scrollWidth=${overflow.sw} clientWidth=${overflow.cw}`);
});
}
// --- MAJOR-4 AC1: at 768x900, controls do NOT overlap marker bounds ---
await step('AC1: map controls do not overlap marker at 768x900', async () => {
await page.setViewportSize({ width: 768, height: 900 });
await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#leaflet-map', { timeout: 8000 });
await page.waitForSelector('.map-controls', { timeout: 8000 });
await page.waitForTimeout(400);
// Inject a synthetic marker in the LEFT half of the leaflet container.
// The controls panel sits in the top-right corner; if it grows
// uncontrolled (e.g. fixed 220px+ at narrow viewports) or wraps into
// the map area, it can overlap markers placed away from the corner.
// We assert controls DO NOT bleed across the centerline into a marker
// sitting at left:50%, top:80px.
const result = await page.evaluate(() => {
const lc = document.querySelector('.leaflet-container');
if (!lc) return { ok: false, reason: 'no .leaflet-container' };
const lr = lc.getBoundingClientRect();
const m = document.createElement('div');
m.className = 'leaflet-marker-icon test-marker-1059';
// Marker centered horizontally inside leaflet, at top:80px.
const left = lr.left + (lr.width / 2) - 12;
m.style.cssText = 'position:absolute;width:24px;height:24px;' +
'left:' + left + 'px;top:' + (lr.top + 80) + 'px;' +
'background:red;z-index:399;pointer-events:none;';
document.body.appendChild(m);
const mb = m.getBoundingClientRect();
const ctrls = Array.from(document.querySelectorAll('.map-controls'));
const overlaps = ctrls.map((el) => {
const r = el.getBoundingClientRect();
const overlap = !(r.right <= mb.left || r.left >= mb.right ||
r.bottom <= mb.top || r.top >= mb.bottom);
return { overlap, ctrl: { l: r.left, r: r.right, t: r.top, b: r.bottom, w: r.width } };
});
return { ok: true, marker: { l: mb.left, r: mb.right, t: mb.top, b: mb.bottom }, overlaps, vw: window.innerWidth };
});
assert(result.ok, result.reason || 'setup failed');
const overlapping = result.overlaps.filter((o) => o.overlap);
assert(overlapping.length === 0,
`map controls overlap centered marker (controls bled across viewport): ` +
`marker=${JSON.stringify(result.marker)} overlapping=${JSON.stringify(overlapping)} ` +
`vw=${result.vw}`);
});
// --- MAJOR-4 AC2: at 2560x1440, leaflet-container fills extra space ---
await step('AC2: leaflet-container width >= 2400px at 2560x1440', async () => {
await page.setViewportSize({ width: 2560, height: 1440 });
await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.leaflet-container', { timeout: 8000 });
await page.waitForTimeout(300);
const w = await page.evaluate(() => {
const lc = document.querySelector('.leaflet-container');
return lc ? lc.getBoundingClientRect().width : 0;
});
assert(w >= 2400,
`leaflet-container width ${w} < 2400px (map not filling ultrawide)`);
});
// --- MAJOR-1 + 2 + 3: BYOP modal — strict 90vh, inflated content, sticky close ---
// Helper: open BYOP modal cleanly. Close any existing modal first since
// hash-route navigation does not reload the SPA and would leave a previous
// modal open.
async function openByopModal(viewport) {
await page.setViewportSize(viewport);
// Force a real reload to clear any modal/state from the previous step.
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-action="pkt-byop"]', { timeout: 8000 });
// Defensive: dismiss any pre-existing overlay.
await page.evaluate(() => {
document.querySelectorAll('.byop-overlay, .modal-overlay').forEach((el) => el.remove());
});
await page.click('[data-action="pkt-byop"]');
await page.waitForSelector('.byop-modal', { timeout: 5000 });
}
await step('BYOP modal: max-height >= 90vh STRICT (rejects 80vh)', async () => {
await openByopModal({ width: 1024, height: 800 });
const m = await page.evaluate(() => {
const modal = document.querySelector('.byop-modal');
const cs = getComputedStyle(modal);
return {
vh: window.innerHeight,
maxHeightPx: parseFloat(cs.maxHeight),
rawMaxHeight: cs.maxHeight,
};
});
// STRICT: max-height in pixels must be >= 90% of viewport height.
// 80vh would be 0.80 * vh ≈ 640 at vh=800. 90vh ≈ 720.
const eightyVh = m.vh * 0.80;
const ninetyVh = m.vh * 0.90;
assert(m.maxHeightPx >= ninetyVh - 1,
`modal max-height ${m.maxHeightPx}px < 90vh (${ninetyVh}px). raw=${m.rawMaxHeight}`);
// NEGATIVE: 80vh must NOT be acceptable. If max-height equals 80vh, fail.
assert(m.maxHeightPx > eightyVh + 4,
`modal max-height ${m.maxHeightPx}px is at or below 80vh (${eightyVh}px). ` +
`Spec requires > 80vh. raw=${m.rawMaxHeight}`);
});
await step('BYOP modal: inflated content overflows internally (90vh cap holds)', async () => {
await openByopModal({ width: 1024, height: 800 });
// MAJOR-3: inject 100 tall paragraphs INSIDE the modal so content >> 90vh.
await page.evaluate(() => {
const modal = document.querySelector('.byop-modal');
const filler = document.createElement('div');
filler.id = 'byop-overflow-filler-1059';
let html = '';
for (let i = 0; i < 100; i++) {
html += '<p style="margin:0 0 12px;line-height:1.6;font-size:14px;">' +
'Filler paragraph ' + i + ' — lorem ipsum dolor sit amet, consectetur ' +
'adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore ' +
'magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.</p>';
}
filler.innerHTML = html;
modal.appendChild(filler);
});
await page.waitForTimeout(150);
const m = await page.evaluate(() => {
const modal = document.querySelector('.byop-modal');
const r = modal.getBoundingClientRect();
const cs = getComputedStyle(modal);
return {
vh: window.innerHeight,
modalH: r.height,
scrollH: modal.scrollHeight,
clientH: modal.clientHeight,
overflowY: cs.overflowY,
};
});
// Modal box must NOT exceed 90vh even though content is huge.
assert(m.modalH <= m.vh * 0.90 + 2,
`modal height ${m.modalH} > 90vh of ${m.vh}=${m.vh * 0.90}`);
// Content must actually overflow internally (proves overflow path is exercised).
assert(m.scrollH > m.clientH + 50,
`modal content did not overflow: scrollHeight=${m.scrollH} clientHeight=${m.clientH}`);
// Internal scroll must be auto/scroll, not visible/hidden.
assert(m.overflowY === 'auto' || m.overflowY === 'scroll',
`modal overflow-y must be auto/scroll under overflow, got ${m.overflowY}`);
});
await step('BYOP modal: close button reachable AFTER scrolling past it (behavioral)', async () => {
await openByopModal({ width: 1024, height: 800 });
// Inflate content so modal scrolls.
await page.evaluate(() => {
const modal = document.querySelector('.byop-modal');
const filler = document.createElement('div');
let html = '';
for (let i = 0; i < 100; i++) {
html += '<p style="margin:0 0 12px;line-height:1.6;font-size:14px;">' +
'Filler ' + i + ' — lorem ipsum dolor sit amet.</p>';
}
filler.innerHTML = html;
modal.appendChild(filler);
});
await page.waitForTimeout(150);
// Capture initial close-button position.
const initialClose = await page.evaluate(() => {
const c = document.querySelector('.byop-modal .byop-x');
const r = c.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, vh: window.innerHeight };
});
// Scroll modal content to the bottom.
await page.evaluate(() => {
const modal = document.querySelector('.byop-modal');
modal.scrollTop = modal.scrollHeight;
});
await page.waitForTimeout(150);
const m = await page.evaluate(() => {
const modal = document.querySelector('.byop-modal');
const close = document.querySelector('.byop-modal .byop-x');
const cr = close.getBoundingClientRect();
const cx = cr.left + cr.width / 2;
const cy = cr.top + cr.height / 2;
const hit = document.elementFromPoint(cx, cy);
const inViewport = cr.top >= 0 && cr.bottom <= window.innerHeight + 1;
// hit should be the close button itself or a descendant of it, NOT
// some scrolled-past content. Walk up from hit to find close.
let n = hit, isCloseOrChild = false;
for (let i = 0; n && i < 8; i++) {
if (n === close) { isCloseOrChild = true; break; }
n = n.parentElement;
}
return {
scrollTop: modal.scrollTop,
scrollMax: modal.scrollHeight - modal.clientHeight,
closeTop: cr.top, closeBottom: cr.bottom, vh: window.innerHeight,
inViewport, isCloseOrChild,
hitTag: hit ? hit.tagName + '.' + (hit.className || '') : 'null',
};
});
// Sanity: we actually scrolled.
assert(m.scrollTop > 50,
`modal did not scroll: scrollTop=${m.scrollTop} scrollMax=${m.scrollMax}`);
// BEHAVIORAL: close button still inside viewport after scrolling content.
assert(m.inViewport,
`close button left viewport after scroll: top=${m.closeTop} bottom=${m.closeBottom} vh=${m.vh} ` +
`(initial top=${initialClose.top}); means close is NOT sticky`);
// BEHAVIORAL: close button is hit-testable (no overlay covers it).
assert(m.isCloseOrChild,
`elementFromPoint at close-button center returned ${m.hitTag}, not the close button`);
});
await browser.close();
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
process.exit(failed === 0 ? 0 : 1);
})();

Some files were not shown because too many files have changed in this diff Show More