mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 21:45:40 +00:00
Compare commits
2 Commits
perf/cover
...
fix/e2e-fl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1c0b4849 | ||
|
|
b47571c7f0 |
25
.github/workflows/deploy.yml
vendored
25
.github/workflows/deploy.yml
vendored
@@ -184,20 +184,6 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up Go 1.22
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: cmd/server/go.sum
|
||||
|
||||
- name: Build Go server for E2E tests
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
run: |
|
||||
cd cmd/server
|
||||
go build -o ../../corescope-server .
|
||||
echo "Go server built successfully"
|
||||
|
||||
- name: Install npm dependencies
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
run: npm ci --production=false
|
||||
@@ -258,7 +244,7 @@ jobs:
|
||||
# Kill any stale server on 13581
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
sleep 2
|
||||
./corescope-server -port 13581 -public public-instrumented &
|
||||
COVERAGE=1 PORT=13581 node server.js &
|
||||
echo $! > .server.pid
|
||||
echo "Server PID: $(cat .server.pid)"
|
||||
# Health-check poll loop (up to 30s)
|
||||
@@ -270,12 +256,16 @@ jobs:
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "Server failed to start within 30s"
|
||||
echo "Last few lines from server logs:"
|
||||
ps aux | grep "corescope-server" || echo "No server process found"
|
||||
ps aux | grep "PORT=13581" || echo "No server process found"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Seed test data for Playwright
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||
run: BASE_URL=http://localhost:13581 node tools/seed-test-data.js
|
||||
|
||||
- name: Run Playwright E2E + coverage collection concurrently
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
@@ -326,13 +316,14 @@ jobs:
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'false'
|
||||
run: |
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
./corescope-server -port 13581 -public public &
|
||||
PORT=13581 node server.js &
|
||||
SERVER_PID=$!
|
||||
# Wait for server to be ready (up to 15s)
|
||||
for i in $(seq 1 15); do
|
||||
curl -sf http://localhost:13581/api/stats > /dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
BASE_URL=http://localhost:13581 node tools/seed-test-data.js || true
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
|
||||
|
||||
@@ -60,10 +60,10 @@ func (c *Config) NodeDaysOrDefault() int {
|
||||
}
|
||||
|
||||
type HealthThresholds struct {
|
||||
InfraDegradedHours float64 `json:"infraDegradedHours"`
|
||||
InfraSilentHours float64 `json:"infraSilentHours"`
|
||||
NodeDegradedHours float64 `json:"nodeDegradedHours"`
|
||||
NodeSilentHours float64 `json:"nodeSilentHours"`
|
||||
InfraDegradedMs int `json:"infraDegradedMs"`
|
||||
InfraSilentMs int `json:"infraSilentMs"`
|
||||
NodeDegradedMs int `json:"nodeDegradedMs"`
|
||||
NodeSilentMs int `json:"nodeSilentMs"`
|
||||
}
|
||||
|
||||
// ThemeFile mirrors theme.json overlay.
|
||||
@@ -126,46 +126,34 @@ func LoadTheme(baseDirs ...string) *ThemeFile {
|
||||
|
||||
func (c *Config) GetHealthThresholds() HealthThresholds {
|
||||
h := HealthThresholds{
|
||||
InfraDegradedHours: 24,
|
||||
InfraSilentHours: 72,
|
||||
NodeDegradedHours: 1,
|
||||
NodeSilentHours: 24,
|
||||
InfraDegradedMs: 86400000,
|
||||
InfraSilentMs: 259200000,
|
||||
NodeDegradedMs: 3600000,
|
||||
NodeSilentMs: 86400000,
|
||||
}
|
||||
if c.HealthThresholds != nil {
|
||||
if c.HealthThresholds.InfraDegradedHours > 0 {
|
||||
h.InfraDegradedHours = c.HealthThresholds.InfraDegradedHours
|
||||
if c.HealthThresholds.InfraDegradedMs > 0 {
|
||||
h.InfraDegradedMs = c.HealthThresholds.InfraDegradedMs
|
||||
}
|
||||
if c.HealthThresholds.InfraSilentHours > 0 {
|
||||
h.InfraSilentHours = c.HealthThresholds.InfraSilentHours
|
||||
if c.HealthThresholds.InfraSilentMs > 0 {
|
||||
h.InfraSilentMs = c.HealthThresholds.InfraSilentMs
|
||||
}
|
||||
if c.HealthThresholds.NodeDegradedHours > 0 {
|
||||
h.NodeDegradedHours = c.HealthThresholds.NodeDegradedHours
|
||||
if c.HealthThresholds.NodeDegradedMs > 0 {
|
||||
h.NodeDegradedMs = c.HealthThresholds.NodeDegradedMs
|
||||
}
|
||||
if c.HealthThresholds.NodeSilentHours > 0 {
|
||||
h.NodeSilentHours = c.HealthThresholds.NodeSilentHours
|
||||
if c.HealthThresholds.NodeSilentMs > 0 {
|
||||
h.NodeSilentMs = c.HealthThresholds.NodeSilentMs
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// GetHealthMs returns degraded/silent thresholds in ms for a given role.
|
||||
// GetHealthMs returns degraded/silent thresholds for a given role.
|
||||
func (h HealthThresholds) GetHealthMs(role string) (degradedMs, silentMs int) {
|
||||
const hourMs = 3600000
|
||||
if role == "repeater" || role == "room" {
|
||||
return int(h.InfraDegradedHours * hourMs), int(h.InfraSilentHours * hourMs)
|
||||
}
|
||||
return int(h.NodeDegradedHours * hourMs), int(h.NodeSilentHours * hourMs)
|
||||
}
|
||||
|
||||
// ToClientMs returns the thresholds as ms for the frontend.
|
||||
func (h HealthThresholds) ToClientMs() map[string]int {
|
||||
const hourMs = 3600000
|
||||
return map[string]int{
|
||||
"infraDegradedMs": int(h.InfraDegradedHours * hourMs),
|
||||
"infraSilentMs": int(h.InfraSilentHours * hourMs),
|
||||
"nodeDegradedMs": int(h.NodeDegradedHours * hourMs),
|
||||
"nodeSilentMs": int(h.NodeSilentHours * hourMs),
|
||||
return h.InfraDegradedMs, h.InfraSilentMs
|
||||
}
|
||||
return h.NodeDegradedMs, h.NodeSilentMs
|
||||
}
|
||||
|
||||
func (c *Config) ResolveDBPath(baseDir string) string {
|
||||
|
||||
@@ -23,10 +23,10 @@ func TestLoadConfigValidJSON(t *testing.T) {
|
||||
"SJC": "San Jose",
|
||||
},
|
||||
"healthThresholds": map[string]interface{}{
|
||||
"infraDegradedHours": 2,
|
||||
"infraSilentHours": 4,
|
||||
"nodeDegradedHours": 0.5,
|
||||
"nodeSilentHours": 2,
|
||||
"infraDegradedMs": 100000,
|
||||
"infraSilentMs": 200000,
|
||||
"nodeDegradedMs": 50000,
|
||||
"nodeSilentMs": 100000,
|
||||
},
|
||||
"liveMap": map[string]interface{}{
|
||||
"propagationBufferMs": 3000,
|
||||
@@ -178,68 +178,68 @@ func TestGetHealthThresholdsDefaults(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
ht := cfg.GetHealthThresholds()
|
||||
|
||||
if ht.InfraDegradedHours != 24 {
|
||||
t.Errorf("expected 24, got %v", ht.InfraDegradedHours)
|
||||
if ht.InfraDegradedMs != 86400000 {
|
||||
t.Errorf("expected 86400000, got %d", ht.InfraDegradedMs)
|
||||
}
|
||||
if ht.InfraSilentHours != 72 {
|
||||
t.Errorf("expected 72, got %v", ht.InfraSilentHours)
|
||||
if ht.InfraSilentMs != 259200000 {
|
||||
t.Errorf("expected 259200000, got %d", ht.InfraSilentMs)
|
||||
}
|
||||
if ht.NodeDegradedHours != 1 {
|
||||
t.Errorf("expected 1, got %v", ht.NodeDegradedHours)
|
||||
if ht.NodeDegradedMs != 3600000 {
|
||||
t.Errorf("expected 3600000, got %d", ht.NodeDegradedMs)
|
||||
}
|
||||
if ht.NodeSilentHours != 24 {
|
||||
t.Errorf("expected 24, got %v", ht.NodeSilentHours)
|
||||
if ht.NodeSilentMs != 86400000 {
|
||||
t.Errorf("expected 86400000, got %d", ht.NodeSilentMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthThresholdsCustom(t *testing.T) {
|
||||
cfg := &Config{
|
||||
HealthThresholds: &HealthThresholds{
|
||||
InfraDegradedHours: 2,
|
||||
InfraSilentHours: 4,
|
||||
NodeDegradedHours: 0.5,
|
||||
NodeSilentHours: 2,
|
||||
InfraDegradedMs: 100000,
|
||||
InfraSilentMs: 200000,
|
||||
NodeDegradedMs: 50000,
|
||||
NodeSilentMs: 100000,
|
||||
},
|
||||
}
|
||||
ht := cfg.GetHealthThresholds()
|
||||
|
||||
if ht.InfraDegradedHours != 2 {
|
||||
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
|
||||
if ht.InfraDegradedMs != 100000 {
|
||||
t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
|
||||
}
|
||||
if ht.InfraSilentHours != 4 {
|
||||
t.Errorf("expected 4, got %v", ht.InfraSilentHours)
|
||||
if ht.InfraSilentMs != 200000 {
|
||||
t.Errorf("expected 200000, got %d", ht.InfraSilentMs)
|
||||
}
|
||||
if ht.NodeDegradedHours != 0.5 {
|
||||
t.Errorf("expected 0.5, got %v", ht.NodeDegradedHours)
|
||||
if ht.NodeDegradedMs != 50000 {
|
||||
t.Errorf("expected 50000, got %d", ht.NodeDegradedMs)
|
||||
}
|
||||
if ht.NodeSilentHours != 2 {
|
||||
t.Errorf("expected 2, got %v", ht.NodeSilentHours)
|
||||
if ht.NodeSilentMs != 100000 {
|
||||
t.Errorf("expected 100000, got %d", ht.NodeSilentMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthThresholdsPartialCustom(t *testing.T) {
|
||||
cfg := &Config{
|
||||
HealthThresholds: &HealthThresholds{
|
||||
InfraDegradedHours: 2,
|
||||
InfraDegradedMs: 100000,
|
||||
// Others left as zero → should use defaults
|
||||
},
|
||||
}
|
||||
ht := cfg.GetHealthThresholds()
|
||||
|
||||
if ht.InfraDegradedHours != 2 {
|
||||
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
|
||||
if ht.InfraDegradedMs != 100000 {
|
||||
t.Errorf("expected 100000, got %d", ht.InfraDegradedMs)
|
||||
}
|
||||
if ht.InfraSilentHours != 72 {
|
||||
t.Errorf("expected default 72, got %v", ht.InfraSilentHours)
|
||||
if ht.InfraSilentMs != 259200000 {
|
||||
t.Errorf("expected default 259200000, got %d", ht.InfraSilentMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthMs(t *testing.T) {
|
||||
ht := HealthThresholds{
|
||||
InfraDegradedHours: 24,
|
||||
InfraSilentHours: 72,
|
||||
NodeDegradedHours: 1,
|
||||
NodeSilentHours: 24,
|
||||
InfraDegradedMs: 86400000,
|
||||
InfraSilentMs: 259200000,
|
||||
NodeDegradedMs: 3600000,
|
||||
NodeSilentMs: 86400000,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -513,10 +513,10 @@ func TestGetNetworkStatus(t *testing.T) {
|
||||
seedTestData(t, db)
|
||||
|
||||
ht := HealthThresholds{
|
||||
InfraDegradedHours: 24,
|
||||
InfraSilentHours: 72,
|
||||
NodeDegradedHours: 1,
|
||||
NodeSilentHours: 24,
|
||||
InfraDegradedMs: 86400000,
|
||||
InfraSilentMs: 259200000,
|
||||
NodeDegradedMs: 3600000,
|
||||
NodeSilentMs: 86400000,
|
||||
}
|
||||
result, err := db.GetNetworkStatus(ht)
|
||||
if err != nil {
|
||||
@@ -1050,10 +1050,10 @@ func TestGetNetworkStatusDateFormats(t *testing.T) {
|
||||
VALUES ('node4444', 'NodeBad', 'sensor', 'not-a-date')`)
|
||||
|
||||
ht := HealthThresholds{
|
||||
InfraDegradedHours: 24,
|
||||
InfraSilentHours: 72,
|
||||
NodeDegradedHours: 1,
|
||||
NodeSilentHours: 24,
|
||||
InfraDegradedMs: 86400000,
|
||||
InfraSilentMs: 259200000,
|
||||
NodeDegradedMs: 3600000,
|
||||
NodeSilentMs: 86400000,
|
||||
}
|
||||
result, err := db.GetNetworkStatus(ht)
|
||||
if err != nil {
|
||||
|
||||
@@ -213,7 +213,7 @@ func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, ClientConfigResponse{
|
||||
Roles: s.cfg.Roles,
|
||||
HealthThresholds: s.cfg.GetHealthThresholds().ToClientMs(),
|
||||
HealthThresholds: s.cfg.HealthThresholds,
|
||||
Tiles: s.cfg.Tiles,
|
||||
SnrThresholds: s.cfg.SnrThresholds,
|
||||
DistThresholds: s.cfg.DistThresholds,
|
||||
|
||||
@@ -98,13 +98,6 @@
|
||||
"#bookclub",
|
||||
"#shtf"
|
||||
],
|
||||
"healthThresholds": {
|
||||
"infraDegradedHours": 24,
|
||||
"infraSilentHours": 72,
|
||||
"nodeDegradedHours": 1,
|
||||
"nodeSilentHours": 24,
|
||||
"_comment": "How long (hours) before nodes show as degraded/silent. 'infra' = repeaters & rooms, 'node' = companions & others."
|
||||
},
|
||||
"defaultRegion": "SJC",
|
||||
"mapDefaults": {
|
||||
"center": [
|
||||
|
||||
@@ -89,8 +89,7 @@
|
||||
|
||||
function getStatusTooltip(role, status) {
|
||||
const isInfra = role === 'repeater' || role === 'room';
|
||||
const threshMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs;
|
||||
const threshold = threshMs >= 3600000 ? Math.round(threshMs / 3600000) + 'h' : Math.round(threshMs / 60000) + 'm';
|
||||
const threshold = isInfra ? '72h' : '24h';
|
||||
if (status === 'active') {
|
||||
return 'Active \u2014 heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : '');
|
||||
}
|
||||
|
||||
@@ -18,16 +18,10 @@ async function collectCoverage() {
|
||||
page.setDefaultTimeout(10000);
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
// Helper: navigate via hash (SPA — no full page reload needed after initial load)
|
||||
async function navHash(hash, wait = 150) {
|
||||
await page.evaluate((h) => { location.hash = h; }, hash);
|
||||
await new Promise(r => setTimeout(r, wait));
|
||||
}
|
||||
|
||||
// Helper: safe click — 500ms timeout (elements exist immediately or not at all)
|
||||
// Helper: safe click
|
||||
async function safeClick(selector, timeout) {
|
||||
try {
|
||||
await page.click(selector, { timeout: timeout || 500 });
|
||||
await page.click(selector, { timeout: timeout || 3000 });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -126,7 +120,7 @@ async function collectCoverage() {
|
||||
// NODES PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Nodes page...');
|
||||
await navHash('#/nodes');
|
||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Sort by EVERY column
|
||||
for (const col of ['name', 'public_key', 'role', 'last_seen', 'advert_count']) {
|
||||
@@ -162,7 +156,7 @@ async function collectCoverage() {
|
||||
}
|
||||
|
||||
// In side pane — click detail/analytics links
|
||||
await safeClick('a[href*="/nodes/"]');
|
||||
await safeClick('a[href*="/nodes/"]', 2000);
|
||||
// Click fav star
|
||||
await clickAll('.fav-star', 2);
|
||||
|
||||
@@ -174,7 +168,7 @@ async function collectCoverage() {
|
||||
try {
|
||||
const firstNodeKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim());
|
||||
if (firstNodeKey) {
|
||||
await navHash('#/nodes/' + firstNodeKey);
|
||||
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Click tabs on detail page
|
||||
await clickAll('.tab-btn, [data-tab]', 10);
|
||||
@@ -197,7 +191,7 @@ async function collectCoverage() {
|
||||
try {
|
||||
const firstKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim()).catch(() => null);
|
||||
if (firstKey) {
|
||||
await navHash('#/nodes/' + firstKey + '?scroll=paths');
|
||||
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -205,7 +199,7 @@ async function collectCoverage() {
|
||||
// PACKETS PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Packets page...');
|
||||
await navHash('#/packets');
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Open filter bar
|
||||
await safeClick('#filterToggleBtn');
|
||||
@@ -291,13 +285,13 @@ async function collectCoverage() {
|
||||
} catch {}
|
||||
|
||||
// Navigate to specific packet by hash
|
||||
await navHash('#/packets/deadbeef');
|
||||
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// MAP PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Map page...');
|
||||
await navHash('#/map');
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Toggle controls panel
|
||||
await safeClick('#mapControlsToggle');
|
||||
@@ -351,7 +345,7 @@ async function collectCoverage() {
|
||||
// ANALYTICS PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Analytics page...');
|
||||
await navHash('#/analytics');
|
||||
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Click EVERY analytics tab
|
||||
const analyticsTabs = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance'];
|
||||
@@ -387,12 +381,9 @@ async function collectCoverage() {
|
||||
await clickAll('.analytics-table th', 8);
|
||||
} catch {}
|
||||
|
||||
// Deep-link to each analytics tab via hash (avoid full page.goto)
|
||||
// Deep-link to each analytics tab via URL
|
||||
for (const tab of analyticsTabs) {
|
||||
try {
|
||||
await page.evaluate((t) => { location.hash = '#/analytics?tab=' + t; }, tab);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
} catch {}
|
||||
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
|
||||
// Region filter on analytics
|
||||
@@ -405,7 +396,7 @@ async function collectCoverage() {
|
||||
// CUSTOMIZE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Customizer...');
|
||||
await navHash('#/home');
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await safeClick('#customizeToggle');
|
||||
|
||||
// Click EVERY customizer tab
|
||||
@@ -512,7 +503,7 @@ async function collectCoverage() {
|
||||
// CHANNELS PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Channels page...');
|
||||
await navHash('#/channels');
|
||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
// Click channel rows/items
|
||||
await clickAll('.channel-item, .channel-row, .channel-card', 3);
|
||||
await clickAll('table tbody tr', 3);
|
||||
@@ -521,7 +512,7 @@ async function collectCoverage() {
|
||||
try {
|
||||
const channelHash = await page.$eval('table tbody tr td:first-child', el => el.textContent.trim()).catch(() => null);
|
||||
if (channelHash) {
|
||||
await navHash('#/channels/' + channelHash);
|
||||
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -529,7 +520,7 @@ async function collectCoverage() {
|
||||
// LIVE PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Live page...');
|
||||
await navHash('#/live');
|
||||
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// VCR controls
|
||||
await safeClick('#vcrPauseBtn');
|
||||
@@ -612,14 +603,14 @@ async function collectCoverage() {
|
||||
// TRACES PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Traces page...');
|
||||
await navHash('#/traces');
|
||||
await page.goto(`${BASE}/#/traces`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await clickAll('table tbody tr', 3);
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// OBSERVERS PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Observers page...');
|
||||
await navHash('#/observers');
|
||||
await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
// Click observer rows
|
||||
const obsRows = await page.$$('table tbody tr, .observer-card, .observer-row');
|
||||
for (let i = 0; i < Math.min(obsRows.length, 3); i++) {
|
||||
@@ -640,7 +631,7 @@ async function collectCoverage() {
|
||||
// PERF PAGE
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Perf page...');
|
||||
await navHash('#/perf');
|
||||
await page.goto(`${BASE}/#/perf`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await safeClick('#perfRefresh');
|
||||
await safeClick('#perfReset');
|
||||
|
||||
@@ -650,14 +641,14 @@ async function collectCoverage() {
|
||||
console.log(' [coverage] App.js — router + global...');
|
||||
|
||||
// Navigate to bad route to trigger error/404
|
||||
await navHash('#/nonexistent-route');
|
||||
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
|
||||
// Navigate to every route via hash (50ms is enough for SPA hash routing)
|
||||
// Navigate to every route via hash
|
||||
const allRoutes = ['home', 'nodes', 'packets', 'map', 'live', 'channels', 'traces', 'observers', 'analytics', 'perf'];
|
||||
for (const route of allRoutes) {
|
||||
try {
|
||||
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -723,11 +714,10 @@ async function collectCoverage() {
|
||||
await page.evaluate(() => { if (window.apiPerf) window.apiPerf(); });
|
||||
} catch {}
|
||||
|
||||
// Exercise utility functions + packet filter parser in one evaluate call
|
||||
console.log(' [coverage] Utility functions + packet filter...');
|
||||
// Exercise utility functions
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
// Utility functions
|
||||
// timeAgo with various inputs
|
||||
if (typeof timeAgo === 'function') {
|
||||
timeAgo(null);
|
||||
timeAgo(new Date().toISOString());
|
||||
@@ -735,11 +725,13 @@ async function collectCoverage() {
|
||||
timeAgo(new Date(Date.now() - 3600000).toISOString());
|
||||
timeAgo(new Date(Date.now() - 86400000 * 2).toISOString());
|
||||
}
|
||||
// truncate
|
||||
if (typeof truncate === 'function') {
|
||||
truncate('hello world', 5);
|
||||
truncate(null, 5);
|
||||
truncate('hi', 10);
|
||||
}
|
||||
// routeTypeName, payloadTypeName, payloadTypeColor
|
||||
if (typeof routeTypeName === 'function') {
|
||||
for (let i = 0; i <= 4; i++) routeTypeName(i);
|
||||
}
|
||||
@@ -749,14 +741,23 @@ async function collectCoverage() {
|
||||
if (typeof payloadTypeColor === 'function') {
|
||||
for (let i = 0; i <= 15; i++) payloadTypeColor(i);
|
||||
}
|
||||
// invalidateApiCache
|
||||
if (typeof invalidateApiCache === 'function') {
|
||||
invalidateApiCache();
|
||||
invalidateApiCache('/test');
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// Packet filter parser
|
||||
// ══════════════════════════════════════════════
|
||||
// PACKET FILTER — exercise the filter parser
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Packet filter parser...');
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
if (window.PacketFilter && window.PacketFilter.compile) {
|
||||
const PF = window.PacketFilter;
|
||||
// Valid expressions
|
||||
const exprs = [
|
||||
'type == ADVERT', 'type == GRP_TXT', 'type != ACK',
|
||||
'snr > 0', 'snr < -5', 'snr >= 10', 'snr <= 3',
|
||||
@@ -772,6 +773,7 @@ async function collectCoverage() {
|
||||
for (const e of exprs) {
|
||||
try { PF.compile(e); } catch {}
|
||||
}
|
||||
// Bad expressions
|
||||
const bad = ['@@@', '== ==', '(((', 'type ==', ''];
|
||||
for (const e of bad) {
|
||||
try { PF.compile(e); } catch {}
|
||||
@@ -785,24 +787,29 @@ async function collectCoverage() {
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Region filter...');
|
||||
try {
|
||||
// Open region filter on nodes page (use hash nav, already visited)
|
||||
await page.evaluate(() => { location.hash = '#/nodes'; });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
// Open region filter on nodes page
|
||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await safeClick('#nodesRegionFilter');
|
||||
await clickAll('#nodesRegionFilter input[type="checkbox"]', 3);
|
||||
} catch {}
|
||||
|
||||
// Region filter on packets
|
||||
try {
|
||||
await page.evaluate(() => { location.hash = '#/packets'; });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||
await safeClick('#packetsRegionFilter');
|
||||
await clickAll('#packetsRegionFilter input[type="checkbox"]', 3);
|
||||
} catch {}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// FINAL — extract coverage (all routes already visited above)
|
||||
// FINAL — navigate through all routes once more
|
||||
// ══════════════════════════════════════════════
|
||||
console.log(' [coverage] Final route sweep...');
|
||||
for (const route of allRoutes) {
|
||||
try {
|
||||
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Extract coverage
|
||||
const coverage = await page.evaluate(() => window.__coverage__);
|
||||
|
||||
@@ -36,19 +36,18 @@ function loadThemeFile(themePaths) {
|
||||
function buildHealthConfig(config) {
|
||||
const _ht = (config && config.healthThresholds) || {};
|
||||
return {
|
||||
infraDegraded: _ht.infraDegradedHours || 24,
|
||||
infraSilent: _ht.infraSilentHours || 72,
|
||||
nodeDegraded: _ht.nodeDegradedHours || 1,
|
||||
nodeSilent: _ht.nodeSilentHours || 24
|
||||
infraDegradedMs: _ht.infraDegradedMs || 86400000,
|
||||
infraSilentMs: _ht.infraSilentMs || 259200000,
|
||||
nodeDegradedMs: _ht.nodeDegradedMs || 3600000,
|
||||
nodeSilentMs: _ht.nodeSilentMs || 86400000
|
||||
};
|
||||
}
|
||||
|
||||
function getHealthMs(role, HEALTH) {
|
||||
const H = 3600000;
|
||||
const isInfra = role === 'repeater' || role === 'room';
|
||||
return {
|
||||
degradedMs: (isInfra ? HEALTH.infraDegraded : HEALTH.nodeDegraded) * H,
|
||||
silentMs: (isInfra ? HEALTH.infraSilent : HEALTH.nodeSilent) * H
|
||||
degradedMs: isInfra ? HEALTH.infraDegradedMs : HEALTH.nodeDegradedMs,
|
||||
silentMs: isInfra ? HEALTH.infraSilentMs : HEALTH.nodeSilentMs
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -307,12 +307,7 @@ app.get('/api/config/cache', (req, res) => {
|
||||
app.get('/api/config/client', (req, res) => {
|
||||
res.json({
|
||||
roles: config.roles || null,
|
||||
healthThresholds: {
|
||||
infraDegradedMs: HEALTH.infraDegraded * 3600000,
|
||||
infraSilentMs: HEALTH.infraSilent * 3600000,
|
||||
nodeDegradedMs: HEALTH.nodeDegraded * 3600000,
|
||||
nodeSilentMs: HEALTH.nodeSilent * 3600000
|
||||
},
|
||||
healthThresholds: config.healthThresholds || null,
|
||||
tiles: config.tiles || null,
|
||||
snrThresholds: config.snrThresholds || null,
|
||||
distThresholds: config.distThresholds || null,
|
||||
|
||||
@@ -10,13 +10,21 @@ const GO_BASE = process.env.GO_BASE_URL || ''; // e.g. https://analyzer.00id.ne
|
||||
const results = [];
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
results.push({ name, pass: true });
|
||||
console.log(` \u2705 ${name}`);
|
||||
} catch (err) {
|
||||
results.push({ name, pass: false, error: err.message });
|
||||
console.log(` \u274c ${name}: ${err.message}`);
|
||||
const MAX_RETRIES = 2;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await fn();
|
||||
results.push({ name, pass: true });
|
||||
console.log(` \u2705 ${name}${attempt > 1 ? ` (retry ${attempt - 1})` : ''}`);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.log(` \u26a0\ufe0f ${name}: ${err.message} (retrying...)`);
|
||||
continue;
|
||||
}
|
||||
results.push({ name, pass: false, error: err.message });
|
||||
console.log(` \u274c ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +332,9 @@ async function run() {
|
||||
|
||||
// Test: Packets groupByHash toggle changes view
|
||||
await test('Packets groupByHash toggle works', async () => {
|
||||
await page.waitForSelector('table tbody tr');
|
||||
// Fresh navigation to ensure clean state
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
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)
|
||||
@@ -534,6 +544,11 @@ async function run() {
|
||||
});
|
||||
|
||||
await test('Compare page runs comparison', async () => {
|
||||
// Wait for dropdowns to be populated (may still be loading from previous test)
|
||||
await page.waitForFunction(() => {
|
||||
const selA = document.getElementById('compareObsA');
|
||||
return selA && selA.options.length > 2;
|
||||
}, { timeout: 10000 });
|
||||
const options = await page.$$eval('#compareObsA option', opts =>
|
||||
opts.filter(o => o.value).map(o => o.value)
|
||||
);
|
||||
@@ -555,6 +570,12 @@ async function run() {
|
||||
|
||||
// Test: Compare results show shared/unique breakdown (#129)
|
||||
await test('Compare results show shared/unique cards', async () => {
|
||||
// Wait for comparison results to fully render (depends on previous test)
|
||||
await page.waitForFunction(() => {
|
||||
return document.querySelector('.compare-card-both') &&
|
||||
document.querySelector('.compare-card-a') &&
|
||||
document.querySelector('.compare-card-b');
|
||||
}, { timeout: 10000 });
|
||||
// Results should be visible from previous test
|
||||
const cardBoth = await page.$('.compare-card-both');
|
||||
assert(cardBoth, 'Should have "shared" card (.compare-card-both)');
|
||||
@@ -577,6 +598,11 @@ async function run() {
|
||||
|
||||
// Test: Compare "both" tab shows table with shared packets
|
||||
await test('Compare both tab shows shared packets table', async () => {
|
||||
// Ensure compare results are present
|
||||
await page.waitForFunction(() => {
|
||||
const c = document.getElementById('compareContent');
|
||||
return c && c.textContent.trim().length > 20;
|
||||
}, { timeout: 10000 });
|
||||
const bothTab = await page.$('[data-cview="both"]');
|
||||
assert(bothTab, '"both" tab button not found');
|
||||
await bothTab.click();
|
||||
@@ -800,7 +826,11 @@ async function run() {
|
||||
// Check for summary stats
|
||||
const summary = await page.$('.obs-summary');
|
||||
assert(summary, 'Observer summary stats not found');
|
||||
// Verify table has rows
|
||||
// Wait for table rows to populate
|
||||
await page.waitForFunction(() => {
|
||||
const rows = document.querySelectorAll('#obsTable tbody tr');
|
||||
return rows.length > 0;
|
||||
}, { timeout: 10000 });
|
||||
const rows = await page.$$('#obsTable tbody tr');
|
||||
assert(rows.length > 0, `Expected >=1 observer rows, got ${rows.length}`);
|
||||
});
|
||||
@@ -840,7 +870,17 @@ async function run() {
|
||||
assert(content.length > 10, 'Perf content should still be present after refresh');
|
||||
});
|
||||
|
||||
|
||||
// Test: Node.js perf page shows Event Loop metrics (not Go Runtime)
|
||||
await test('Perf page shows Event Loop on Node server', async () => {
|
||||
const perfText = await page.$eval('#perfContent', el => el.textContent);
|
||||
// Node.js server should show Event Loop metrics
|
||||
const hasEventLoop = perfText.includes('Event Loop') || perfText.includes('event loop');
|
||||
const hasMemory = perfText.includes('Memory') || perfText.includes('RSS');
|
||||
assert(hasEventLoop || hasMemory, 'Node perf page should show Event Loop or Memory metrics');
|
||||
// Should NOT show Go Runtime section on Node.js server
|
||||
const hasGoRuntime = perfText.includes('Go Runtime');
|
||||
assert(!hasGoRuntime, 'Node perf page should NOT show Go Runtime section');
|
||||
});
|
||||
|
||||
// Test: Go perf page shows Go Runtime section (goroutines, GC)
|
||||
// NOTE: This test requires GO_BASE_URL pointing to Go staging (port 82)
|
||||
|
||||
@@ -59,17 +59,17 @@ console.log('\nloadThemeFile:');
|
||||
console.log('\nbuildHealthConfig:');
|
||||
{
|
||||
const h = helpers.buildHealthConfig({});
|
||||
assert(h.infraDegraded === 24, 'default infraDegraded');
|
||||
assert(h.infraSilent === 72, 'default infraSilent');
|
||||
assert(h.nodeDegraded === 1, 'default nodeDegraded');
|
||||
assert(h.nodeSilent === 24, 'default nodeSilent');
|
||||
assert(h.infraDegradedMs === 86400000, 'default infraDegradedMs');
|
||||
assert(h.infraSilentMs === 259200000, 'default infraSilentMs');
|
||||
assert(h.nodeDegradedMs === 3600000, 'default nodeDegradedMs');
|
||||
assert(h.nodeSilentMs === 86400000, 'default nodeSilentMs');
|
||||
|
||||
const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedHours: 2 } });
|
||||
assert(h2.infraDegraded === 2, 'custom infraDegraded');
|
||||
assert(h2.nodeDegraded === 1, 'other defaults preserved');
|
||||
const h2 = helpers.buildHealthConfig({ healthThresholds: { infraDegradedMs: 1000 } });
|
||||
assert(h2.infraDegradedMs === 1000, 'custom infraDegradedMs');
|
||||
assert(h2.nodeDegradedMs === 3600000, 'other defaults preserved');
|
||||
|
||||
const h3 = helpers.buildHealthConfig(null);
|
||||
assert(h3.infraDegraded === 24, 'handles null config');
|
||||
assert(h3.infraDegradedMs === 86400000, 'handles null config');
|
||||
}
|
||||
|
||||
// --- getHealthMs ---
|
||||
@@ -78,21 +78,21 @@ console.log('\ngetHealthMs:');
|
||||
const HEALTH = helpers.buildHealthConfig({});
|
||||
|
||||
const rep = helpers.getHealthMs('repeater', HEALTH);
|
||||
assert(rep.degradedMs === 24 * 3600000, 'repeater uses infra degraded');
|
||||
assert(rep.silentMs === 72 * 3600000, 'repeater uses infra silent');
|
||||
assert(rep.degradedMs === 86400000, 'repeater uses infra degraded');
|
||||
assert(rep.silentMs === 259200000, 'repeater uses infra silent');
|
||||
|
||||
const room = helpers.getHealthMs('room', HEALTH);
|
||||
assert(room.degradedMs === 24 * 3600000, 'room uses infra degraded');
|
||||
assert(room.degradedMs === 86400000, 'room uses infra degraded');
|
||||
|
||||
const comp = helpers.getHealthMs('companion', HEALTH);
|
||||
assert(comp.degradedMs === 1 * 3600000, 'companion uses node degraded');
|
||||
assert(comp.silentMs === 24 * 3600000, 'companion uses node silent');
|
||||
assert(comp.degradedMs === 3600000, 'companion uses node degraded');
|
||||
assert(comp.silentMs === 86400000, 'companion uses node silent');
|
||||
|
||||
const sensor = helpers.getHealthMs('sensor', HEALTH);
|
||||
assert(sensor.degradedMs === 1 * 3600000, 'sensor uses node degraded');
|
||||
assert(sensor.degradedMs === 3600000, 'sensor uses node degraded');
|
||||
|
||||
const undef = helpers.getHealthMs(undefined, HEALTH);
|
||||
assert(undef.degradedMs === 1 * 3600000, 'undefined role uses node degraded');
|
||||
assert(undef.degradedMs === 3600000, 'undefined role uses node degraded');
|
||||
}
|
||||
|
||||
// --- isHashSizeFlipFlop ---
|
||||
|
||||
228
tools/seed-test-data.js
Normal file
228
tools/seed-test-data.js
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Seed synthetic test data into a running CoreScope server.
|
||||
* Usage: node tools/seed-test-data.js [baseUrl]
|
||||
* Default: http://localhost:13581
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const BASE = process.argv[2] || process.env.BASE_URL || 'http://localhost:13581';
|
||||
|
||||
const OBSERVERS = [
|
||||
{ id: 'E2E-SJC-1', iata: 'SJC' },
|
||||
{ id: 'E2E-SFO-2', iata: 'SFO' },
|
||||
{ id: 'E2E-OAK-3', iata: 'OAK' },
|
||||
];
|
||||
|
||||
const NODE_NAMES = [
|
||||
'TestNode Alpha', 'TestNode Beta', 'TestNode Gamma', 'TestNode Delta',
|
||||
'TestNode Epsilon', 'TestNode Zeta', 'TestNode Eta', 'TestNode Theta',
|
||||
];
|
||||
|
||||
function rand(a, b) { return Math.random() * (b - a) + a; }
|
||||
function randInt(a, b) { return Math.floor(rand(a, b + 1)); }
|
||||
function pick(a) { return a[randInt(0, a.length - 1)]; }
|
||||
function randomBytes(n) { return crypto.randomBytes(n); }
|
||||
function pubkeyFor(name) { return crypto.createHash('sha256').update(name).digest(); }
|
||||
|
||||
function encodeHeader(routeType, payloadType, ver = 0) {
|
||||
return (routeType & 0x03) | ((payloadType & 0x0F) << 2) | ((ver & 0x03) << 6);
|
||||
}
|
||||
|
||||
function buildPath(hopCount, hashSize = 2) {
|
||||
const pathByte = ((hashSize - 1) << 6) | (hopCount & 0x3F);
|
||||
const hops = crypto.randomBytes(hashSize * hopCount);
|
||||
return { pathByte, hops };
|
||||
}
|
||||
|
||||
function buildAdvert(name, role) {
|
||||
const pubKey = pubkeyFor(name);
|
||||
const ts = Buffer.alloc(4); ts.writeUInt32LE(Math.floor(Date.now() / 1000));
|
||||
const sig = randomBytes(64);
|
||||
let flags = 0x80 | 0x10; // hasName + hasLocation
|
||||
if (role === 'repeater') flags |= 0x02;
|
||||
else if (role === 'room') flags |= 0x04;
|
||||
else if (role === 'sensor') flags |= 0x08;
|
||||
else flags |= 0x01;
|
||||
const nameBuf = Buffer.from(name, 'utf8');
|
||||
const appdata = Buffer.alloc(9 + nameBuf.length);
|
||||
appdata[0] = flags;
|
||||
appdata.writeInt32LE(Math.round(37.34 * 1e6), 1);
|
||||
appdata.writeInt32LE(Math.round(-121.89 * 1e6), 5);
|
||||
nameBuf.copy(appdata, 9);
|
||||
const payload = Buffer.concat([pubKey, ts, sig, appdata]);
|
||||
const header = encodeHeader(1, 0x04, 0); // FLOOD + ADVERT
|
||||
const { pathByte, hops } = buildPath(randInt(0, 3));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||
}
|
||||
|
||||
function buildGrpTxt(channelHash = 0) {
|
||||
const mac = randomBytes(2);
|
||||
const enc = randomBytes(randInt(10, 40));
|
||||
const payload = Buffer.concat([Buffer.from([channelHash]), mac, enc]);
|
||||
const header = encodeHeader(1, 0x05, 0); // FLOOD + GRP_TXT
|
||||
const { pathByte, hops } = buildPath(randInt(0, 3));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a properly encrypted GRP_TXT packet that decrypts to a CHAN message.
|
||||
* Uses #LongFast channel key from channel-rainbow.json.
|
||||
*/
|
||||
function buildEncryptedGrpTxt(sender, message) {
|
||||
try {
|
||||
const CryptoJS = require('crypto-js');
|
||||
const { ChannelCrypto } = require('@michaelhart/meshcore-decoder/dist/crypto/channel-crypto');
|
||||
|
||||
const channelKey = '2cc3d22840e086105ad73443da2cacb8'; // #LongFast
|
||||
const text = `${sender}: ${message}`;
|
||||
const buf = Buffer.alloc(5 + text.length + 1);
|
||||
buf.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
|
||||
buf[4] = 0;
|
||||
buf.write(text + '\0', 5, 'utf8');
|
||||
|
||||
const padded = Buffer.alloc(Math.ceil(buf.length / 16) * 16);
|
||||
buf.copy(padded);
|
||||
|
||||
const keyWords = CryptoJS.enc.Hex.parse(channelKey);
|
||||
const plaintextWords = CryptoJS.enc.Hex.parse(padded.toString('hex'));
|
||||
const encrypted = CryptoJS.AES.encrypt(plaintextWords, keyWords, {
|
||||
mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.NoPadding
|
||||
});
|
||||
const cipherHex = encrypted.ciphertext.toString(CryptoJS.enc.Hex);
|
||||
|
||||
const channelSecret = Buffer.alloc(32);
|
||||
Buffer.from(channelKey, 'hex').copy(channelSecret);
|
||||
const mac = CryptoJS.HmacSHA256(
|
||||
CryptoJS.enc.Hex.parse(cipherHex),
|
||||
CryptoJS.enc.Hex.parse(channelSecret.toString('hex'))
|
||||
);
|
||||
const macHex = mac.toString(CryptoJS.enc.Hex).substring(0, 4);
|
||||
|
||||
const chHash = ChannelCrypto.calculateChannelHash('#LongFast');
|
||||
const grpPayload = Buffer.from(
|
||||
chHash.toString(16).padStart(2, '0') + macHex + cipherHex, 'hex'
|
||||
);
|
||||
|
||||
const header = encodeHeader(1, 0x05, 0);
|
||||
const { pathByte, hops } = buildPath(randInt(0, 2));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, grpPayload]);
|
||||
} catch (e) {
|
||||
// Fallback to unencrypted if crypto libs unavailable
|
||||
return buildGrpTxt(0);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAck() {
|
||||
const payload = randomBytes(18);
|
||||
const header = encodeHeader(2, 0x03, 0);
|
||||
const { pathByte, hops } = buildPath(randInt(0, 2));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||
}
|
||||
|
||||
function buildTxtMsg() {
|
||||
const payload = Buffer.concat([randomBytes(6), randomBytes(6), randomBytes(4), randomBytes(20)]);
|
||||
const header = encodeHeader(2, 0x02, 0);
|
||||
const { pathByte, hops } = buildPath(randInt(0, 2));
|
||||
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||
}
|
||||
|
||||
function computeContentHash(hex) {
|
||||
return crypto.createHash('sha256').update(hex.toUpperCase()).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const r = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: r.status, data: await r.json() };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Seeding test data into ${BASE}...`);
|
||||
|
||||
const packets = [];
|
||||
|
||||
// 1. ADVERTs for each node (creates nodes with location for map)
|
||||
const roles = ['repeater', 'repeater', 'room', 'companion', 'repeater', 'companion', 'sensor', 'repeater'];
|
||||
for (let i = 0; i < NODE_NAMES.length; i++) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildAdvert(NODE_NAMES[i], roles[i]).toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: 5.0, rssi: -80 });
|
||||
// Send same advert from multiple observers for compare page
|
||||
for (const otherObs of OBSERVERS) {
|
||||
if (otherObs.id !== obs.id) {
|
||||
packets.push({ hex, observer: otherObs.id, region: otherObs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Encrypted GRP_TXT packets (creates channel messages for channels page)
|
||||
const chatMessages = [
|
||||
['Alice', 'Hello everyone!'], ['Bob', 'Hey Alice!'], ['Charlie', 'Good morning'],
|
||||
['Alice', 'How is the mesh today?'], ['Bob', 'Looking great, 8 nodes online'],
|
||||
['Charlie', 'I just set up a new repeater'], ['Alice', 'Nice! Where is it?'],
|
||||
['Bob', 'Signal looks strong from here'], ['Charlie', 'On top of the hill'],
|
||||
['Alice', 'Perfect location!'],
|
||||
];
|
||||
for (const [sender, message] of chatMessages) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildEncryptedGrpTxt(sender, message).toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
|
||||
// 3. Unencrypted GRP_TXT packets (won't create channel entries but add packet variety)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildGrpTxt(randInt(0, 3)).toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
|
||||
// 3. ACK packets
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildAck().toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
|
||||
// 4. TXT_MSG packets
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const obs = pick(OBSERVERS);
|
||||
const hex = buildTxtMsg().toString('hex').toUpperCase();
|
||||
const hash = computeContentHash(hex);
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||
}
|
||||
|
||||
// 5. Extra packets with shared hashes (for trace/compare)
|
||||
for (let t = 0; t < 5; t++) {
|
||||
const hex = buildGrpTxt(0).toString('hex').toUpperCase();
|
||||
const traceHash = computeContentHash(hex);
|
||||
for (const obs of OBSERVERS) {
|
||||
packets.push({ hex, observer: obs.id, region: obs.iata, hash: traceHash, snr: 5, rssi: -80 });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Injecting ${packets.length} packets...`);
|
||||
let ok = 0, fail = 0;
|
||||
for (const pkt of packets) {
|
||||
const r = await post('/api/packets', pkt);
|
||||
if (r.status === 200) ok++;
|
||||
else { fail++; if (fail <= 3) console.error(' Inject fail:', r.data); }
|
||||
}
|
||||
console.log(`Done: ${ok} ok, ${fail} fail`);
|
||||
|
||||
if (fail > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
Reference in New Issue
Block a user