test(e2e): add Playwright tests for all uncovered pages #144

Add 25 new E2E tests covering pages that previously had no Playwright coverage:

Packets page (4 tests):
- Detail pane hidden on fresh load
- groupByHash toggle
- Clicking row shows detail pane
- Detail pane close button

Analytics sub-tabs (7 tests):
- RF, Topology, Channels, Hash Stats, Hash Issues, Route Patterns, Distance

Compare page (2 tests):
- Observer dropdowns populate
- Running comparison produces results

Live page (2 tests):
- Page loads with map and stats
- WebSocket connection indicators

Channels page (2 tests):
- Channel list loads with items
- Clicking channel shows messages

Traces page (2 tests):
- Search input and button present
- Search returns results for valid hash

Observers page (2 tests):
- Table loads with rows
- Health indicators present

Perf page (2 tests):
- Metrics load
- Refresh button works

Audio Lab page (3 tests):
- Controls load (play, voice, BPM, volume)
- Sidebar lists packets by type
- Clicking packet shows detail and hex dump

Total: 42 tests (was 17). All new tests validated against analyzer.00id.net.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-03-27 10:42:35 -07:00
co-authored by Copilot
parent a6f713a09c
commit 4ca850fdd0
+367 -29
View File
@@ -253,34 +253,74 @@ async function run() {
assert(rowsAfter.length > 0, 'No packets after filtering');
});
// Test: Packet detail pane dismiss button (Issue #125)
await test('Packet detail pane closes on ✕ click', async () => {
// Reuse packets page from test 4 — check if any rows exist
const rows = await page.$$('table tbody tr');
if (rows.length === 0) {
console.log(' ⏭️ Skipped (no packets in DB)');
return; // skip gracefully on empty data
}
// Click first packet row to open detail pane
// 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');
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 () => {
await page.waitForSelector('table tbody tr');
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
await test('Packets clicking row shows detail pane', async () => {
await page.waitForSelector('table tbody tr[data-action]');
const firstRow = await page.$('table tbody tr[data-action]');
if (!firstRow) {
console.log(' ⏭️ Skipped (no clickable packet rows)');
return;
}
assert(firstRow, 'No clickable packet rows found');
await firstRow.click();
// Wait for detail pane to become visible (empty class removed)
await page.waitForFunction(() => {
const panel = document.getElementById('pktRight');
return panel && !panel.classList.contains('empty');
}, { timeout: 5000 });
// Verify detail pane is visible
const panelVisible = await page.$eval('#pktRight', el => !el.classList.contains('empty'));
assert(panelVisible, 'Detail pane should be visible after clicking a row');
// Click the close button (✕)
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 () => {
// Detail pane should be open from previous test
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 firstRow.click();
await page.waitForFunction(() => {
const panel = document.getElementById('pktRight');
return panel && !panel.classList.contains('empty');
}, { timeout: 5000 });
}
const closeBtn = await page.$('#pktRight .panel-close-btn');
assert(closeBtn, 'Close button (✕) not found in detail pane');
await closeBtn.click();
// Verify the detail pane is hidden (empty class restored)
await page.waitForFunction(() => {
const panel = document.getElementById('pktRight');
return panel && panel.classList.contains('empty');
@@ -289,22 +329,158 @@ async function run() {
assert(panelHidden, 'Detail pane should be hidden after clicking ✕');
});
// --- Group: Analytics page (test 8) ---
// --- Group: Analytics page (test 8 + sub-tabs) ---
// Test 8: Analytics page loads
// Test 8: Analytics page loads with overview
await test('Analytics page loads', async () => {
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const html = document.body.innerHTML.toLowerCase();
return html.includes('analytics') || html.includes('tab') || html.includes('chart') || html.includes('topology');
});
const html = await page.content();
// Check for any analytics content
const hasContent = html.includes('analytics') || html.includes('Analytics') || html.includes('tab') || html.includes('chart') || html.includes('topology');
assert(hasContent, 'Analytics page has no recognizable content');
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');
});
// --- 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');
});
// --- Group: Live page (tests 11, 12) ---
// Test 11: Live page heat checkbox disabled by matrix/ghosts mode
await test('Live heat disabled when ghosts mode active', async () => {
@@ -386,6 +562,168 @@ async function run() {
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');
});
// --- 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');
});
await browser.close();
// Summary