/** * 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}`); } } 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'); }); // 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 () => { await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('table tbody tr'); 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'); }); // 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 () => { 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 () => { // Fresh navigation to avoid stale row references from previous test await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 }); const firstRow = await page.$('table tbody tr[data-action]'); assert(firstRow, 'No clickable packet rows found'); await firstRow.click(); 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 }); } 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: 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 ✕'); }); // 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: Node.js perf page shows Event Loop metrics (not Go Runtime) await test('Perf page shows Event Loop on Node server', async () => { const perfText = await page.$eval('#perfContent', el => el.textContent); // Node.js server should show Event Loop metrics const hasEventLoop = perfText.includes('Event Loop') || perfText.includes('event loop'); const hasMemory = perfText.includes('Memory') || perfText.includes('RSS'); assert(hasEventLoop || hasMemory, 'Node perf page should show Event Loop or Memory metrics'); // Should NOT show Go Runtime section on Node.js server const hasGoRuntime = perfText.includes('Go Runtime'); assert(!hasGoRuntime, 'Node perf page should NOT show Go Runtime section'); }); // Test: Go perf page shows Go Runtime section (goroutines, GC) // NOTE: This test requires GO_BASE_URL pointing to Go staging (port 82) 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'); }); 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); });