From 4ca850fdd07b8345c03f07af84fd36ecf25428c7 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:42:35 -0700 Subject: [PATCH] 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> --- test-e2e-playwright.js | 396 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 367 insertions(+), 29 deletions(-) diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index 8e9778c6..993dd4b1 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -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