Files
meshcore-analyzer/test-e2e-playwright.js
Kpa-clawbot 8e6fc9602f fix: stabilize Playwright packets test with explicit time window (#348)
## Summary

Fixes the Playwright CI regression on master where the "Packets page
loads with filter" test times out after 15 seconds waiting for able
tbody tr to appear.

## Root Cause

Three packets tests used an bout:blank round-trip pattern to force a
full page reload:

`
page.goto(BASE) → set localStorage → page.goto('about:blank') →
page.goto(BASE/#/packets)
`

This cross-origin round-trip through bout:blank causes the SPA's config
fetch and router to not fire reliably in CI's headless Chromium, leaving
the page uninitialized past the 15-second timeout.

## Fix

Replace the bout:blank pattern with page.reload() in all three affected
tests:

`
page.goto(BASE/#/packets)  →  set localStorage  →  page.reload()
`

This stays on the same origin throughout. Playwright handles same-origin
reloads predictably — the page fully re-initializes, the IIFE re-reads
localStorage, and loadPackets() uses the correct time window.

## Tests affected

| Test | Change |
|------|--------|
| Packets page loads with filter | bout:blank → page.reload() |
| Packets initial fetch honors persisted time window | bout:blank →
page.reload() |
| Packets groupByHash toggle works | bout:blank → page.reload() |

## Validation

- All 318 unit tests pass (packet-filter: 62, aging: 29, frontend: 227)
- No public/ files changed — no cache buster needed
- Single file changed: est-e2e-playwright.js (9 insertions, 15
deletions)

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:27:51 -07:00

1044 lines
51 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Playwright E2E tests — proof of concept
* Runs against prod (analyzer.00id.net), read-only.
* Usage: node test-e2e-playwright.js
*/
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
const GO_BASE = process.env.GO_BASE_URL || ''; // e.g. https://analyzer.00id.net:82
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}`);
console.log(`\nFail-fast: stopping after first failure.`);
process.exit(1);
}
}
function assert(condition, msg) {
if (!condition) throw new Error(msg || 'Assertion failed');
}
async function run() {
console.log('Launching Chromium...');
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
});
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(10000);
console.log(`\nRunning E2E tests against ${BASE}\n`);
// --- Group: Home page (tests 1, 6, 7) ---
// Test 1: Home page loads
await test('Home page loads', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const title = await page.title();
assert(title.toLowerCase().includes('corescope'), `Title "${title}" doesn't contain CoreScope`);
const nav = await page.$('nav, .navbar, .nav, [class*="nav"]');
assert(nav, 'Nav bar not found');
});
// Test 6: Theme customizer opens (reuses home page from test 1)
await test('Theme customizer opens', async () => {
// Look for palette/customize button
const btn = await page.$('button[title*="ustom" i], button[aria-label*="theme" i], [class*="customize"], button:has-text("\ud83c\udfa8")');
if (!btn) {
// Try finding by emoji content
const allButtons = await page.$$('button');
let found = false;
for (const b of allButtons) {
const text = await b.textContent();
if (text.includes('\ud83c\udfa8')) {
await b.click();
found = true;
break;
}
}
assert(found, 'Could not find theme customizer button');
} else {
await btn.click();
}
await page.waitForFunction(() => {
const html = document.body.innerHTML;
return html.includes('preset') || html.includes('Preset') || html.includes('theme') || html.includes('Theme');
});
const html = await page.content();
const hasCustomizer = html.includes('preset') || html.includes('Preset') || html.includes('theme') || html.includes('Theme');
assert(hasCustomizer, 'Customizer panel not found after clicking');
});
await test('Customizer open does not overwrite server home config without edits', async () => {
// TODO: requires running server with full customize/home wiring
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
await page.evaluate(() => {
localStorage.removeItem('meshcore-user-theme');
window.SITE_CONFIG = window.SITE_CONFIG || {};
window.SITE_CONFIG.home = {
heroTitle: 'Server Hero (E2E)',
heroSubtitle: 'Server Subtitle (E2E)',
steps: [{ emoji: 'S', title: 'Server Step', description: 'server' }],
checklist: [{ question: 'Server Q', answer: 'Server A' }],
footerLinks: [{ label: 'Server Link', url: '#/server' }]
};
});
const before = await page.evaluate(() => JSON.stringify(window.SITE_CONFIG && window.SITE_CONFIG.home));
const btn = await page.$('#customizeToggle, button[title*="ustom" i], [class*="customize"]');
if (!btn) {
console.log(' ⏭️ Customizer toggle not found — TODO: requires running server');
return;
}
await btn.click();
await page.waitForTimeout(200);
const after = await page.evaluate(() => JSON.stringify(window.SITE_CONFIG && window.SITE_CONFIG.home));
assert(after === before, 'Opening customizer should not mutate server home config');
});
await test('Home customization persists through page refresh', async () => {
// TODO: requires running server with full customize/home wiring
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const toggleSelector = '#customizeToggle, button[title*="ustom" i], button[aria-label*="theme" i], [class*="customize"]';
const btn = await page.$(toggleSelector);
if (!btn) {
console.log(' ⏭️ Customizer toggle not found — TODO: requires running server');
return;
}
const editedHero = 'Persisted Hero From Playwright';
await page.click(toggleSelector);
const homeTab = page.locator('.cust-tab[data-tab="home"]');
await homeTab.waitFor({ state: 'visible', timeout: 10000 });
await homeTab.click();
const heroInput = page.locator('#cust-heroTitle');
if (await heroInput.count() === 0) {
console.log(' ⏭️ #cust-heroTitle not found — TODO: requires running server');
return;
}
await heroInput.waitFor({ state: 'visible', timeout: 10000 });
await heroInput.fill(editedHero);
await page.waitForTimeout(700); // autoSave debounce is 500ms
await page.reload({ waitUntil: 'domcontentloaded' });
const persistedHero = await page.evaluate(() => {
try {
const saved = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
return saved && saved.home ? saved.home.heroTitle : '';
} catch {
return '';
}
});
assert(persistedHero === editedHero, `Expected persisted hero "${editedHero}" but got "${persistedHero}"`);
});
// Test 7: Dark mode toggle (fresh navigation \u2014 customizer panel may be open)
await test('Dark mode toggle', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('nav, .navbar, .nav, [class*="nav"]');
const themeBefore = await page.$eval('html', el => el.getAttribute('data-theme'));
// Find toggle button
const allButtons = await page.$$('button');
let toggled = false;
for (const b of allButtons) {
const text = await b.textContent();
if (text.includes('\u2600') || text.includes('\ud83c\udf19') || text.includes('\ud83c\udf11') || text.includes('\ud83c\udf15')) {
await b.click();
toggled = true;
break;
}
}
assert(toggled, 'Could not find dark mode toggle button');
await page.waitForFunction(
(before) => document.documentElement.getAttribute('data-theme') !== before,
themeBefore
);
const themeAfter = await page.$eval('html', el => el.getAttribute('data-theme'));
assert(themeBefore !== themeAfter, `Theme didn't change: before=${themeBefore}, after=${themeAfter}`);
});
// Test: Stats bar shows version/commit badge
await test('Stats bar shows version and commit badge', async () => {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
// Wait for stats to load (fetched from /api/stats)
await page.waitForFunction(() => {
const stats = document.getElementById('navStats');
return stats && stats.textContent.trim().length > 5;
}, { timeout: 10000 });
const navStats = await page.$('#navStats');
assert(navStats, 'Nav stats bar (#navStats) not found');
// Check if stats API exposes version info
const hasVersionData = await page.evaluate(async () => {
try {
const res = await fetch('/api/stats');
const data = await res.json();
return !!(data.version || data.commit || data.engine);
} catch { return false; }
});
if (!hasVersionData) {
console.log(' ⏭️ Server does not expose version/commit in /api/stats — badge test skipped');
return;
}
// Version badge should appear when data is available
await page.waitForFunction(() => !!document.querySelector('.version-badge'), { timeout: 5000 });
const badgeText = await page.$eval('.version-badge', el => el.textContent.trim());
assert(badgeText.length > 3, `Version badge should have content but got "${badgeText}"`);
const hasCommitHash = /[0-9a-f]{7}/i.test(badgeText);
assert(hasCommitHash, `Version badge should contain a commit hash, got "${badgeText}"`);
const engineBadge = await page.$('.engine-badge');
assert(engineBadge, 'Engine badge (.engine-badge) not found');
const engineText = await page.$eval('.engine-badge', el => el.textContent.trim().toLowerCase());
assert(engineText.includes('node') || engineText.includes('go'), `Engine should contain "node" or "go", got "${engineText}"`);
});
// --- Group: Nodes page (tests 2, 5) ---
// Test 2: Nodes page loads with data
await test('Nodes page loads with data', async () => {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
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');
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');
// Click first row
const firstRow = await page.$('table tbody tr');
assert(firstRow, 'No node rows found');
await firstRow.click();
// Wait for detail pane to appear
await page.waitForSelector('.node-detail');
const html = await page.content();
// Check for status indicator
const hasStatus = html.includes('\ud83d\udfe2') || html.includes('\u26aa') || html.includes('status') || html.includes('Active') || html.includes('Stale');
assert(hasStatus, 'No status indicator found in node detail');
});
// Test: Nodes page has WebSocket auto-update listener (#131)
await test('Nodes page has WebSocket auto-update', async () => {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr');
// 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');
// Wait for WS connection and verify liveDot shows connected state
try {
await page.waitForFunction(() => {
const dot = document.getElementById('liveDot');
return dot && dot.classList.contains('connected');
}, { timeout: 5000 });
} catch (_) {
// WS may not connect against remote — liveDot existence is sufficient
}
});
// --- Group: Map page (tests 3, 9, 10, 13, 16) ---
// Test 3: Map page loads with markers
await test('Map page loads with markers', async () => {
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.leaflet-container');
await page.waitForSelector('.leaflet-tile-loaded');
// Wait for markers/overlays to render (may not exist with empty DB)
try {
await page.waitForSelector('.leaflet-marker-icon, .leaflet-interactive, circle, .marker-cluster, .leaflet-marker-pane > *, .leaflet-overlay-pane svg path, .leaflet-overlay-pane svg circle', { timeout: 3000 });
} catch (_) {
// No markers with empty DB \u2014 assertion below handles it
}
const markers = await page.$$('.leaflet-marker-icon, .leaflet-interactive, circle, .marker-cluster, .leaflet-marker-pane > *, .leaflet-overlay-pane svg path, .leaflet-overlay-pane svg circle');
assert(markers.length > 0, 'No map markers/overlays found');
});
// Test 9: Map heat checkbox persists in localStorage (reuses map page)
await test('Map heat checkbox persists in localStorage', async () => {
await page.waitForSelector('#mcHeatmap');
// Uncheck first to ensure clean state
await page.evaluate(() => localStorage.removeItem('meshcore-map-heatmap'));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#mcHeatmap');
let checked = await page.$eval('#mcHeatmap', el => el.checked);
assert(!checked, 'Heat checkbox should be unchecked by default');
// Check it
await page.click('#mcHeatmap');
const stored = await page.evaluate(() => localStorage.getItem('meshcore-map-heatmap'));
assert(stored === 'true', `localStorage should be "true" but got "${stored}"`);
// Reload and verify persisted — wait for async map init to restore state
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const el = document.getElementById('mcHeatmap');
return el && el.checked;
}, { timeout: 10000 });
checked = await page.$eval('#mcHeatmap', el => el.checked);
assert(checked, 'Heat checkbox should be checked after reload');
// Clean up
await page.evaluate(() => localStorage.removeItem('meshcore-map-heatmap'));
});
// Test 10: Map heat checkbox is not disabled (unless matrix mode)
await test('Map heat checkbox is clickable', async () => {
await page.waitForSelector('#mcHeatmap');
const disabled = await page.$eval('#mcHeatmap', el => el.disabled);
assert(!disabled, 'Heat checkbox should not be disabled');
// Click and verify state changes
const before = await page.$eval('#mcHeatmap', el => el.checked);
await page.click('#mcHeatmap');
const after = await page.$eval('#mcHeatmap', el => el.checked);
assert(before !== after, 'Heat checkbox state should toggle on click');
});
// Test 13: Heatmap opacity stored in localStorage (reuses map page)
await test('Heatmap opacity persists in localStorage', async () => {
await page.evaluate(() => localStorage.setItem('meshcore-heatmap-opacity', '0.5'));
// Enable heat to trigger layer creation with saved opacity
await page.evaluate(() => localStorage.setItem('meshcore-map-heatmap', 'true'));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#mcHeatmap');
const opacity = await page.evaluate(() => localStorage.getItem('meshcore-heatmap-opacity'));
assert(opacity === '0.5', `Opacity should persist as "0.5" but got "${opacity}"`);
// Verify the canvas element has the opacity applied (if heat layer exists)
const canvasOpacity = await page.evaluate(() => {
if (window._meshcoreHeatLayer && window._meshcoreHeatLayer._canvas) {
return window._meshcoreHeatLayer._canvas.style.opacity;
}
return null; // no heat layer (no node data) \u2014 skip
});
if (canvasOpacity !== null) {
assert(canvasOpacity === '0.5', `Canvas opacity should be "0.5" but got "${canvasOpacity}"`);
}
// Clean up
await page.evaluate(() => {
localStorage.removeItem('meshcore-heatmap-opacity');
localStorage.removeItem('meshcore-map-heatmap');
});
});
// Test 16: Map re-renders markers on resize (decollision recalculates)
await test('Map re-renders on resize', async () => {
await page.waitForSelector('.leaflet-container');
// Wait for markers (may not exist with empty DB)
try {
await page.waitForSelector('.leaflet-marker-icon, .leaflet-interactive', { timeout: 3000 });
} catch (_) {
// No markers with empty DB
}
// Count markers before resize
const beforeCount = await page.$$eval('.leaflet-marker-icon, .leaflet-interactive', els => els.length);
// Resize viewport
await page.setViewportSize({ width: 600, height: 400 });
// Wait for Leaflet to process resize
await page.waitForFunction(() => {
const c = document.querySelector('.leaflet-container');
return c && c.offsetWidth <= 600;
});
// Markers should still be present after resize (re-rendered, not lost)
const afterCount = await page.$$eval('.leaflet-marker-icon, .leaflet-interactive', els => els.length);
assert(afterCount > 0, `Should have markers after resize, got ${afterCount}`);
// Restore
await page.setViewportSize({ width: 1280, height: 720 });
});
// --- Group: Packets page (test 4) ---
// Test 4: Packets page loads with filter
await test('Packets page loads with filter', async () => {
// Ensure desktop viewport and broad time window so fixture timestamps are included.
await page.setViewportSize({ width: 1280, height: 720 });
// Set time window BEFORE packets.js IIFE re-executes (525600 min ≈ 1 year).
// Navigate to the packets URL then reload — avoids about:blank cross-origin issues
// that can prevent the SPA from fully initializing within the timeout.
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
await page.reload({ waitUntil: 'load' });
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');
assert(filterInput, 'Packet filter input not found');
await filterInput.fill('type == ADVERT');
// 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');
assert(rowsAfter.length > 0, 'No packets after filtering');
});
await test('Packets initial fetch honors persisted time window', async () => {
// Set persisted time window to 60 min and reload so the IIFE reads it
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '60'));
const packetsRequestPromise = page.waitForRequest((req) => {
try {
const parsed = new URL(req.url());
return parsed.pathname === '/api/packets' && parsed.searchParams.has('since');
} catch {
return false;
}
}, { timeout: 10000 });
// Full reload on the packets page — scripts re-execute, IIFE reads localStorage
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('#fTimeWindow', { timeout: 10000 });
const timeWindowValue = await page.$eval('#fTimeWindow', (el) => el.value);
assert(timeWindowValue === '60', `Expected time window dropdown to restore 60, got ${timeWindowValue}`);
const req = await packetsRequestPromise;
const parsed = new URL(req.url());
const since = parsed.searchParams.get('since');
assert(since, 'Expected since query parameter on initial packets request');
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
assert(deltaMin > 45 && deltaMin < 75, `Expected ~60 minute window, got ${deltaMin.toFixed(2)} minutes`);
});
// Test: Packet detail pane hidden on fresh load
await test('Packets detail pane hidden on fresh load', async () => {
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#pktRight', { state: 'attached' });
const isEmpty = await page.$eval('#pktRight', el => el.classList.contains('empty'));
assert(isEmpty, 'Detail pane should have "empty" class on fresh load');
});
// Test: Packets groupByHash toggle changes view
await test('Packets groupByHash toggle works', async () => {
// 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', { timeout: 15000 });
const groupBtn = await page.$('#fGroup');
assert(groupBtn, 'Group by hash button (#fGroup) not found');
// Check initial state (default is grouped/active)
const initialActive = await page.$eval('#fGroup', el => el.classList.contains('active'));
// Click to toggle
await groupBtn.click();
await page.waitForFunction((wasActive) => {
const btn = document.getElementById('fGroup');
return btn && btn.classList.contains('active') !== wasActive;
}, 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');
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();
await page.waitForFunction((prev) => {
const btn = document.getElementById('fGroup');
return btn && btn.classList.contains('active') !== prev;
}, afterFirst, { timeout: 5000 });
const afterSecond = await page.$eval('#fGroup', el => el.classList.contains('active'));
assert(afterSecond === initialActive, 'Group button should return to initial state after second click');
});
// Test: Clicking a packet row opens detail pane
// SKIPPED: flaky test — see https://github.com/Kpa-clawbot/CoreScope/issues/257
console.log(' ⏭️ Packets clicking row shows detail pane (SKIPPED — flaky)');
/*await test('Packets clicking row shows detail pane', async () => {
// Fresh navigation to avoid stale row references from previous test
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
// Wait for table rows AND initial API data to settle
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
await page.waitForLoadState('networkidle');
const firstRow = await page.$('table tbody tr[data-action]');
assert(firstRow, 'No clickable packet rows found');
// Click the row and wait for the /packets/{hash} API response
const [response] = await Promise.all([
page.waitForResponse(resp => resp.url().includes('/packets/') && resp.status() === 200, { timeout: 15000 }),
firstRow.click(),
]);
assert(response, 'API response for packet detail not received');
await page.waitForFunction(() => {
const panel = document.getElementById('pktRight');
return panel && !panel.classList.contains('empty');
}, { timeout: 15000 });
const panelVisible = await page.$eval('#pktRight', el => !el.classList.contains('empty'));
assert(panelVisible, 'Detail pane should open after clicking a row');
const content = await page.$eval('#pktRight', el => el.textContent.trim());
assert(content.length > 0, 'Detail pane should have content');
});
// Test: Packet detail pane dismiss button (Issue #125)
await test('Packet detail pane closes on ✕ click', async () => {
// Ensure we're on packets page with detail pane open
const pktRight = await page.$('#pktRight');
if (!pktRight) {
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
await page.waitForLoadState('networkidle');
}
const panelOpen = await page.$eval('#pktRight', el => !el.classList.contains('empty'));
if (!panelOpen) {
const firstRow = await page.$('table tbody tr[data-action]');
if (!firstRow) { console.log(' ⏭️ Skipped (no clickable rows)'); return; }
await Promise.all([
page.waitForResponse(resp => resp.url().includes('/packets/') && resp.status() === 200, { timeout: 15000 }),
firstRow.click(),
]);
await page.waitForFunction(() => {
const panel = document.getElementById('pktRight');
return panel && !panel.classList.contains('empty');
}, { timeout: 15000 });
}
const closeBtn = await page.$('#pktRight .panel-close-btn');
assert(closeBtn, 'Close button (✕) not found in detail pane');
await closeBtn.click();
await page.waitForFunction(() => {
const panel = document.getElementById('pktRight');
return panel && panel.classList.contains('empty');
}, { timeout: 3000 });
const panelHidden = await page.$eval('#pktRight', el => el.classList.contains('empty'));
assert(panelHidden, 'Detail pane should be hidden after clicking ✕');
});*/
console.log(' ⏭️ Packet detail pane closes on ✕ click (SKIPPED — depends on flaky test above)');
// Test: GRP_TXT packet detail shows Channel Hash (#123)
await test('GRP_TXT packet detail shows Channel Hash', async () => {
// Find an undecrypted GRP_TXT packet via API (only these show Channel Hash)
const hash = await page.evaluate(async () => {
try {
const res = await fetch('/api/packets?limit=500');
const data = await res.json();
for (const p of (data.packets || [])) {
try {
const d = JSON.parse(p.decoded_json || '{}');
if (d.type === 'GRP_TXT' && !d.text && d.channelHash != null) return p.hash;
} catch {}
}
} catch {}
return null;
});
if (!hash) { console.log(' ⏭️ Skipped (no undecrypted GRP_TXT packets found)'); return; }
await page.goto(`${BASE}/#/packets/${hash}`, { waitUntil: 'domcontentloaded' });
// Wait for detail to render with actual content (not "Loading…")
await page.waitForFunction(() => {
const panel = document.getElementById('pktRight');
if (!panel || panel.classList.contains('empty')) return false;
const text = panel.textContent;
return text.length > 50 && !text.includes('Loading');
}, { timeout: 8000 });
const detailHtml = await page.$eval('#pktRight', el => el.innerHTML);
const hasChannelHash = detailHtml.includes('Channel Hash') || detailHtml.includes('Ch 0x');
assert(hasChannelHash, 'Undecrypted GRP_TXT detail should show "Channel Hash"');
});
// --- Group: Analytics page (test 8 + sub-tabs) ---
// Test 8: Analytics page loads with overview
await test('Analytics page loads', async () => {
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#analyticsTabs');
const tabs = await page.$$('#analyticsTabs .tab-btn');
assert(tabs.length >= 8, `Expected >=8 analytics tabs, got ${tabs.length}`);
// Overview tab should be active by default and show stat cards
await page.waitForSelector('#analyticsContent .stat-card', { timeout: 8000 });
const cards = await page.$$('#analyticsContent .stat-card');
assert(cards.length >= 3, `Expected >=3 overview stat cards, got ${cards.length}`);
});
// Analytics sub-tab tests
await test('Analytics RF tab renders content', async () => {
await page.click('[data-tab="rf"]');
await page.waitForSelector('#analyticsContent .analytics-table, #analyticsContent svg', { timeout: 8000 });
const hasTables = await page.$$eval('#analyticsContent .analytics-table', els => els.length);
const hasSvg = await page.$$eval('#analyticsContent svg', els => els.length);
assert(hasTables > 0 || hasSvg > 0, 'RF tab should render tables or SVG charts');
});
await test('Analytics Topology tab renders content', async () => {
await page.click('[data-tab="topology"]');
await page.waitForFunction(() => {
const c = document.getElementById('analyticsContent');
return c && (c.querySelector('.repeater-list') || c.querySelector('.analytics-card') || c.querySelector('.reach-rings'));
}, { timeout: 8000 });
const hasContent = await page.$$eval('#analyticsContent .analytics-card, #analyticsContent .repeater-list', els => els.length);
assert(hasContent > 0, 'Topology tab should render cards or repeater list');
});
await test('Analytics Channels tab renders content', async () => {
await page.click('[data-tab="channels"]');
await page.waitForFunction(() => {
const c = document.getElementById('analyticsContent');
return c && c.textContent.trim().length > 10;
}, { timeout: 8000 });
const content = await page.$eval('#analyticsContent', el => el.textContent.trim());
assert(content.length > 10, 'Channels tab should render content');
});
await test('Analytics Hash Stats tab renders content', async () => {
await page.click('[data-tab="hashsizes"]');
await page.waitForSelector('#analyticsContent .hash-bar-row, #analyticsContent .analytics-table', { timeout: 8000 });
const content = await page.$eval('#analyticsContent', el => el.textContent.trim());
assert(content.length > 10, 'Hash Stats tab should render content');
});
await test('Analytics Hash Issues tab renders content', async () => {
await page.click('[data-tab="collisions"]');
await page.waitForFunction(() => {
const c = document.getElementById('analyticsContent');
return c && (c.querySelector('#hashMatrix') || c.querySelector('#inconsistentHashSection') || c.textContent.trim().length > 20);
}, { timeout: 8000 });
const hasContent = await page.$('#analyticsContent #hashMatrix, #analyticsContent #inconsistentHashSection');
const text = await page.$eval('#analyticsContent', el => el.textContent.trim());
assert(hasContent || text.length > 20, 'Hash Issues tab should render content');
});
await test('Analytics Route Patterns tab renders content', async () => {
await page.click('[data-tab="subpaths"]');
await page.waitForFunction(() => {
const c = document.getElementById('analyticsContent');
return c && (c.querySelector('.subpath-layout') || c.textContent.trim().length > 20);
}, { timeout: 8000 });
const content = await page.$eval('#analyticsContent', el => el.textContent.trim());
assert(content.length > 10, 'Route Patterns tab should render content');
});
await test('Analytics Distance tab renders content', async () => {
await page.click('[data-tab="distance"]');
await page.waitForFunction(() => {
const c = document.getElementById('analyticsContent');
return c && (c.querySelector('.stat-card') || c.querySelector('.data-table') || c.textContent.trim().length > 20);
}, { timeout: 8000 });
const content = await page.$eval('#analyticsContent', el => el.textContent.trim());
assert(content.length > 10, 'Distance tab should render content');
});
// --- Group: Compare page ---
await test('Compare page loads with observer dropdowns', async () => {
await page.goto(`${BASE}/#/compare`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const selA = document.getElementById('compareObsA');
return selA && selA.options.length > 1;
}, { timeout: 10000 });
const optionsA = await page.$$eval('#compareObsA option', opts => opts.length);
const optionsB = await page.$$eval('#compareObsB option', opts => opts.length);
assert(optionsA > 1, `Observer A dropdown should have options, got ${optionsA}`);
assert(optionsB > 1, `Observer B dropdown should have options, got ${optionsB}`);
});
await test('Compare page runs comparison', async () => {
const options = await page.$$eval('#compareObsA option', opts =>
opts.filter(o => o.value).map(o => o.value)
);
assert(options.length >= 2, `Need >=2 observers, got ${options.length}`);
await page.selectOption('#compareObsA', options[0]);
await page.selectOption('#compareObsB', options[1]);
await page.waitForFunction(() => {
const btn = document.getElementById('compareBtn');
return btn && !btn.disabled;
}, { timeout: 3000 });
await page.click('#compareBtn');
await page.waitForFunction(() => {
const c = document.getElementById('compareContent');
return c && c.textContent.trim().length > 20;
}, { timeout: 15000 });
const hasResults = await page.$eval('#compareContent', el => el.textContent.trim().length > 0);
assert(hasResults, 'Comparison should produce results');
});
// Test: Compare results show shared/unique breakdown (#129)
await test('Compare results show shared/unique cards', async () => {
// Results should be visible from previous test
const cardBoth = await page.$('.compare-card-both');
assert(cardBoth, 'Should have "shared" card (.compare-card-both)');
const cardA = await page.$('.compare-card-a');
assert(cardA, 'Should have "only A" card (.compare-card-a)');
const cardB = await page.$('.compare-card-b');
assert(cardB, 'Should have "only B" card (.compare-card-b)');
// Verify counts are rendered (may be locale-formatted with commas)
const counts = await page.$$eval('.compare-card-count', els => els.map(e => e.textContent.trim()));
assert(counts.length >= 3, `Expected >=3 summary counts, got ${counts.length}`);
counts.forEach((c, i) => {
assert(/^[\d,]+$/.test(c), `Count ${i} should be a number but got "${c}"`);
});
// Verify tab buttons exist for both/onlyA/onlyB
const tabs = await page.$$eval('[data-cview]', els => els.map(e => e.getAttribute('data-cview')));
assert(tabs.includes('both'), 'Should have "both" tab');
assert(tabs.includes('onlyA'), 'Should have "onlyA" tab');
assert(tabs.includes('onlyB'), 'Should have "onlyB" tab');
});
// Test: Compare "both" tab shows table with shared packets
await test('Compare both tab shows shared packets table', async () => {
const bothTab = await page.$('[data-cview="both"]');
assert(bothTab, '"both" tab button not found');
await bothTab.click();
// Table renders inside #compareDetail (not #compareContent)
await page.waitForFunction(() => {
const d = document.getElementById('compareDetail');
return d && (d.querySelector('.compare-table') || d.textContent.trim().length > 5);
}, { timeout: 5000 });
const table = await page.$('#compareDetail .compare-table');
if (table) {
// Verify table has expected columns (Hash, Time, Type)
const headers = await page.$$eval('#compareDetail .compare-table th', els => els.map(e => e.textContent.trim()));
assert(headers.some(h => h.includes('Hash') || h.includes('hash')), 'Table should have Hash column');
assert(headers.some(h => h.includes('Type') || h.includes('type')), 'Table should have Type column');
} else {
// No shared packets — should show "No packets" message
const text = await page.$eval('#compareDetail', el => el.textContent.trim());
assert(text.includes('No packets') || text.length > 0, 'Should show message or table');
}
});
// --- Group: Live page ---
// Test: Live page loads with map and stats
await test('Live page loads with map and stats', async () => {
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#liveMap');
// Verify key page elements exist
const hasMap = await page.$('#liveMap');
assert(hasMap, 'Live page should have map element');
const hasHeader = await page.$('#liveHeader, .live-header');
assert(hasHeader, 'Live page should have header');
// Check stats elements exist
const pktCount = await page.$('#livePktCount');
assert(pktCount, 'Live page should have packet count element');
const nodeCount = await page.$('#liveNodeCount');
assert(nodeCount, 'Live page should have node count element');
});
// Test: Live page WebSocket connects
await test('Live page WebSocket connects', async () => {
// Check for live beacon indicator (shows page is in live mode)
const hasBeacon = await page.$('.live-beacon');
assert(hasBeacon, 'Live page should have beacon indicator');
// Check VCR mode indicator shows LIVE
const vcrMode = await page.$('#vcrMode, #vcrLcdMode');
assert(vcrMode, 'Live page should have VCR mode indicator');
// Verify WebSocket is connected by checking for the ws object
const wsConnected = await page.evaluate(() => {
// The live page creates a WebSocket - check if it exists
// Look for any WebSocket instances or connection indicators
const beacon = document.querySelector('.live-beacon');
const vcrDot = document.querySelector('.vcr-live-dot');
return !!(beacon || vcrDot);
});
assert(wsConnected, 'WebSocket connection indicators should be present');
});
// Test 11: Live page heat checkbox disabled by matrix/ghosts mode
await test('Live heat disabled when ghosts mode active', async () => {
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
// Wait for live init to complete (Leaflet tiles load after map creation + loadNodes)
await page.waitForSelector('.leaflet-tile-loaded', { timeout: 15000 });
await page.waitForSelector('#liveHeatToggle');
// Enable matrix mode if not already
const matrixEl = await page.$('#liveMatrixToggle');
if (matrixEl) {
await page.evaluate(() => {
const mt = document.getElementById('liveMatrixToggle');
if (mt && !mt.checked) mt.click();
});
await page.waitForFunction(() => {
const heat = document.getElementById('liveHeatToggle');
return heat && heat.disabled;
});
const heatDisabled = await page.$eval('#liveHeatToggle', el => el.disabled);
assert(heatDisabled, 'Heat should be disabled when ghosts/matrix is on');
// Turn off matrix
await page.evaluate(() => {
const mt = document.getElementById('liveMatrixToggle');
if (mt && mt.checked) mt.click();
});
await page.waitForFunction(() => {
const heat = document.getElementById('liveHeatToggle');
return heat && !heat.disabled;
});
const heatEnabled = await page.$eval('#liveHeatToggle', el => !el.disabled);
assert(heatEnabled, 'Heat should be re-enabled when ghosts/matrix is off');
}
});
// Test 12: Live page heat checkbox persists across reload (reuses live page)
await test('Live heat checkbox persists in localStorage', async () => {
await page.waitForSelector('#liveHeatToggle');
// Clear state and set to 'false' so we can verify persistence after reload
await page.evaluate(() => localStorage.setItem('meshcore-live-heatmap', 'false'));
await page.reload({ waitUntil: 'domcontentloaded' });
// Wait for async init to read localStorage and uncheck the toggle
await page.waitForFunction(() => {
const el = document.getElementById('liveHeatToggle');
return el && !el.checked;
}, { timeout: 10000 });
const afterReload = await page.$eval('#liveHeatToggle', el => el.checked);
assert(!afterReload, 'Live heat checkbox should stay unchecked after reload');
// Set to 'true' and verify that also persists
await page.evaluate(() => localStorage.setItem('meshcore-live-heatmap', 'true'));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const el = document.getElementById('liveHeatToggle');
return el && el.checked;
}, { timeout: 10000 });
const afterReload2 = await page.$eval('#liveHeatToggle', el => el.checked);
assert(afterReload2, 'Live heat checkbox should stay checked after reload');
// Clean up
await page.evaluate(() => localStorage.removeItem('meshcore-live-heatmap'));
});
// --- Group: No navigation needed (tests 14, 15) ---
// Test 14: Live heatmap opacity stored in localStorage
await test('Live heatmap opacity persists in localStorage', async () => {
// Verify localStorage key works (no page load needed \u2014 reuse current page)
await page.evaluate(() => localStorage.setItem('meshcore-live-heatmap-opacity', '0.6'));
const opacity = await page.evaluate(() => localStorage.getItem('meshcore-live-heatmap-opacity'));
assert(opacity === '0.6', `Live opacity should persist as "0.6" but got "${opacity}"`);
await page.evaluate(() => localStorage.removeItem('meshcore-live-heatmap-opacity'));
});
// Test 15: Customizer has separate Map and Live opacity sliders
await test('Customizer has separate map and live opacity sliders', async () => {
// Verify by checking JS source \u2014 avoids heavy page reloads that crash ARM chromium
const custJs = await page.evaluate(async () => {
const res = await fetch('/customize.js?_=' + Date.now());
return res.text();
});
assert(custJs.includes('custHeatOpacity'), 'customize.js should have map opacity slider (custHeatOpacity)');
assert(custJs.includes('custLiveHeatOpacity'), 'customize.js should have live opacity slider (custLiveHeatOpacity)');
assert(custJs.includes('meshcore-heatmap-opacity'), 'customize.js should use meshcore-heatmap-opacity key');
assert(custJs.includes('meshcore-live-heatmap-opacity'), 'customize.js should use meshcore-live-heatmap-opacity key');
// Verify labels are distinct
assert(custJs.includes('Nodes Map') || custJs.includes('nodes map') || custJs.includes('\ud83d\uddfa'), 'Map slider should have map-related label');
assert(custJs.includes('Live Map') || custJs.includes('live map') || custJs.includes('\ud83d\udce1'), 'Live slider should have live-related label');
});
// --- Group: Channels page ---
await test('Channels page loads with channel list', async () => {
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#chList', { timeout: 8000 });
// Channels are fetched async — wait for items to render
await page.waitForFunction(() => {
const list = document.getElementById('chList');
return list && list.querySelectorAll('.ch-item').length > 0;
}, { timeout: 15000 });
const items = await page.$$('#chList .ch-item');
assert(items.length > 0, `Expected >=1 channel items, got ${items.length}`);
// Verify channel items have names
const names = await page.$$eval('#chList .ch-item-name', els => els.map(e => e.textContent.trim()));
assert(names.length > 0, 'Channel items should have names');
assert(names[0].length > 0, 'First channel name should not be empty');
});
await test('Channels clicking channel shows messages', async () => {
await page.waitForFunction(() => {
const list = document.getElementById('chList');
return list && list.querySelectorAll('.ch-item').length > 0;
}, { timeout: 10000 });
const firstItem = await page.$('#chList .ch-item');
assert(firstItem, 'No channel items to click');
await firstItem.click();
await page.waitForFunction(() => {
const msgs = document.getElementById('chMessages');
return msgs && msgs.children.length > 0;
}, { timeout: 10000 });
const msgCount = await page.$$eval('#chMessages > *', els => els.length);
assert(msgCount > 0, `Expected messages after clicking channel, got ${msgCount}`);
// Verify header updated with channel name
const header = await page.$eval('#chHeader', el => el.textContent.trim());
assert(header.length > 0, 'Channel header should show channel name');
});
// --- Group: Traces page ---
await test('Traces page loads with search input', async () => {
await page.goto(`${BASE}/#/traces`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#traceHashInput', { timeout: 8000 });
const input = await page.$('#traceHashInput');
assert(input, 'Trace hash input not found');
const btn = await page.$('#traceBtn');
assert(btn, 'Trace button not found');
});
await test('Traces search returns results for valid hash', async () => {
// First get a real packet hash from the packets API
const hash = await page.evaluate(async () => {
const res = await fetch('/api/packets?limit=1');
const data = await res.json();
if (data.packets && data.packets.length > 0) return data.packets[0].hash;
if (Array.isArray(data) && data.length > 0) return data[0].hash;
return null;
});
if (!hash) { console.log(' Skipped (no packets available)'); return; }
await page.fill('#traceHashInput', hash);
await page.click('#traceBtn');
await page.waitForFunction(() => {
const r = document.getElementById('traceResults');
return r && r.textContent.trim().length > 10;
}, { timeout: 10000 });
const content = await page.$eval('#traceResults', el => el.textContent.trim());
assert(content.length > 10, 'Trace results should have content');
});
// --- Group: Observers page ---
await test('Observers page loads with table', async () => {
await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#obsTable', { timeout: 8000 });
const table = await page.$('#obsTable');
assert(table, 'Observers table not found');
// Check for summary stats
const summary = await page.$('.obs-summary');
assert(summary, 'Observer summary stats not found');
// Verify table has rows
const rows = await page.$$('#obsTable tbody tr');
assert(rows.length > 0, `Expected >=1 observer rows, got ${rows.length}`);
});
await test('Observers table shows health indicators', async () => {
const dots = await page.$$('#obsTable .health-dot');
assert(dots.length > 0, 'Observer rows should have health status dots');
// Verify at least one row has an observer name
const firstCell = await page.$eval('#obsTable tbody tr td', el => el.textContent.trim());
assert(firstCell.length > 0, 'Observer name cell should not be empty');
});
// --- Group: Perf page ---
await test('Perf page loads with metrics', async () => {
await page.goto(`${BASE}/#/perf`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#perfContent', { timeout: 8000 });
// Wait for perf cards to render (fetches /api/perf and /api/health)
await page.waitForFunction(() => {
const c = document.getElementById('perfContent');
return c && (c.querySelector('.perf-card') || c.querySelector('.perf-table') || c.textContent.trim().length > 20);
}, { timeout: 10000 });
const content = await page.$eval('#perfContent', el => el.textContent.trim());
assert(content.length > 10, 'Perf page should show metrics content');
});
await test('Perf page has refresh button', async () => {
const refreshBtn = await page.$('#perfRefresh');
assert(refreshBtn, 'Perf refresh button not found');
// Click refresh and verify content updates (no errors)
await refreshBtn.click();
await page.waitForFunction(() => {
const c = document.getElementById('perfContent');
return c && c.textContent.trim().length > 10;
}, { timeout: 8000 });
const content = await page.$eval('#perfContent', el => el.textContent.trim());
assert(content.length > 10, 'Perf content should still be present after refresh');
});
// Test: Go perf page shows Go Runtime section (goroutines, GC)
// NOTE: This test requires GO_BASE_URL pointing to Go staging (port 82)
if (GO_BASE) {
await test('Go perf page shows Go Runtime metrics', async () => {
await page.goto(`${GO_BASE}/#/perf`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#perfContent', { timeout: 8000 });
await page.waitForFunction(() => {
const c = document.getElementById('perfContent');
return c && c.textContent.trim().length > 20;
}, { timeout: 10000 });
const perfText = await page.$eval('#perfContent', el => el.textContent);
assert(perfText.includes('Go Runtime'), 'Go perf page should show "Go Runtime" section');
assert(perfText.includes('Goroutines') || perfText.includes('goroutines'),
'Go perf page should show Goroutines metric');
assert(perfText.includes('GC') || perfText.includes('Heap'),
'Go perf page should show GC or Heap metrics');
// Should NOT show Event Loop on Go server
const hasEventLoop = perfText.includes('Event Loop');
assert(!hasEventLoop, 'Go perf page should NOT show Event Loop section');
});
} else {
console.log(' Go perf test skipped (set GO_BASE_URL for Go staging, e.g. port 82)');
}
// --- Group: Audio Lab page ---
await test('Audio Lab page loads with controls', async () => {
await page.goto(`${BASE}/#/audio-lab`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#alabSidebar', { timeout: 8000 });
// Verify core controls exist
const playBtn = await page.$('#alabPlay');
assert(playBtn, 'Audio Lab play button not found');
const voiceSelect = await page.$('#alabVoice');
assert(voiceSelect, 'Audio Lab voice selector not found');
const bpmSlider = await page.$('#alabBPM');
assert(bpmSlider, 'Audio Lab BPM slider not found');
const volSlider = await page.$('#alabVol');
assert(volSlider, 'Audio Lab volume slider not found');
});
await test('Audio Lab sidebar lists packets', async () => {
// Wait for packets to load from API
await page.waitForFunction(() => {
const sidebar = document.getElementById('alabSidebar');
return sidebar && sidebar.querySelectorAll('.alab-pkt').length > 0;
}, { timeout: 10000 });
const packets = await page.$$('#alabSidebar .alab-pkt');
assert(packets.length > 0, `Expected packets in sidebar, got ${packets.length}`);
// Verify type headers exist
const typeHeaders = await page.$$('#alabSidebar .alab-type-hdr');
assert(typeHeaders.length > 0, 'Should have packet type headers');
});
await test('Audio Lab clicking packet shows detail', async () => {
const firstPkt = await page.$('#alabSidebar .alab-pkt');
assert(firstPkt, 'No packets to click');
await firstPkt.click();
await page.waitForFunction(() => {
const detail = document.getElementById('alabDetail');
return detail && detail.textContent.trim().length > 10;
}, { timeout: 5000 });
const detail = await page.$eval('#alabDetail', el => el.textContent.trim());
assert(detail.length > 10, 'Packet detail should show content after click');
// Verify hex dump is present
const hexDump = await page.$('#alabHex');
assert(hexDump, 'Hex dump should be visible after selecting a packet');
});
// Extract frontend coverage if instrumented server is running
try {
const coverage = await page.evaluate(() => window.__coverage__);
if (coverage) {
const fs = require('fs');
const path = require('path');
const outDir = path.join(__dirname, '.nyc_output');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'e2e-coverage.json'), JSON.stringify(coverage));
console.log(`Frontend coverage from E2E: ${Object.keys(coverage).length} files`);
}
} catch {}
await browser.close();
// Summary
const passed = results.filter(r => r.pass).length;
const failed = results.filter(r => !r.pass).length;
console.log(`\n${passed}/${results.length} tests passed${failed ? `, ${failed} failed` : ''}`);
process.exit(failed > 0 ? 1 : 0);
}
run().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});