#!/usr/bin/env node /** * GigLez UI verification — drives a real headless browser through the full * user journey against the deployed SPA, asserting the things the API layer * cannot: the page loads clean, the Leaflet map renders, and an upload done * *through the actual DOM* returns a device match and increments the store. * * Usage: * BASE=http://localhost:8000 node scripts/verify_ui.mjs * * Requires Node Playwright (present: 1.58.x). Exits non-zero on any failure. * Screenshots are written to ./verify-shots/. */ import { chromium } from 'playwright'; import { writeFileSync, mkdirSync } from 'node:fs'; const BASE = process.env.BASE || 'http://localhost:8000'; const SHOTS = 'verify-shots'; mkdirSync(SHOTS, { recursive: true }); const fails = []; const ok = (cond, msg) => { console.log(`${cond ? 'PASS' : 'FAIL'} ${msg}`); if (!cond) fails.push(msg); }; // A minimal self-contained RAW .sub (no dependency on the big dataset). const SAMPLE_SUB = [ 'Filetype: Flipper SubGhz RAW File', 'Version: 1', 'Frequency: 433920000', 'Preset: FuriHalSubGhzPresetOok650Async', 'Protocol: RAW', 'RAW_Data: 350 -350 350 -700 700 -350 350 -350 700 -700 350 -350 350 -700 700 -350 350 -700 350 -350 700 -350 350 -700 350 -350 350 -700 700 -700 350 -350', ].join('\n'); const consoleErrors = []; const failedRequests = []; const browser = await chromium.launch(); const ctx = await browser.newContext(); const page = await ctx.newPage(); page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); }); page.on('requestfailed', (r) => failedRequests.push(`${r.url()} (${r.failure()?.errorText})`)); try { // ---- Stage 2: load ---- const resp = await page.goto(BASE, { waitUntil: 'networkidle', timeout: 30000 }); ok(resp && resp.ok(), `GET / returned ${resp && resp.status()}`); await page.screenshot({ path: `${SHOTS}/1-loaded.png`, fullPage: true }); // ---- Stage 3: map rendered ---- await page.waitForSelector('#map', { timeout: 10000 }); const leafletUp = await page.locator('#map .leaflet-container').count(); ok(leafletUp > 0, 'Leaflet map initialised (.leaflet-container present)'); const baselineTxt = await page.locator('#total-captures').innerText().catch(() => '0'); const baseline = parseInt(baselineTxt, 10) || 0; console.log(` baseline #total-captures = ${baseline}`); // ---- Stage 4: switch to Upload, fill the real form ---- await page.click('a[href="#upload"]').catch(() => {}); await page.waitForSelector('#upload-section.section.active, #upload-section', { timeout: 5000 }); await page.setInputFiles('#file-input', { name: 'sample.sub', mimeType: 'application/octet-stream', buffer: Buffer.from(SAMPLE_SUB), }); await page.fill('#default-lat', '47.6062'); await page.fill('#default-lon', '-122.3321'); await page.screenshot({ path: `${SHOTS}/2-upload-ready.png`, fullPage: true }); // The upload button is a global onclick="uploadFiles()". await page.click('button[onclick="uploadFiles()"]'); // ---- Stage 5: results panel shows a match ---- await page.waitForSelector('#upload-results', { state: 'visible', timeout: 20000 }); const resultsTxt = (await page.locator('#upload-results').innerText()).trim(); ok(resultsTxt.length > 0, 'upload results panel is populated'); ok(/success|processed|device|match|categ/i.test(resultsTxt), 'results mention a processed/device match'); await page.screenshot({ path: `${SHOTS}/3-results.png`, fullPage: true }); // ---- Stage 6/8: store incremented (checked via the API the UI uses) ---- const total = await page.evaluate(async () => { const r = await fetch('/api/v1/query/captures?limit=1000'); return (await r.json()).total; }); ok(total >= baseline + 1, `capture total incremented (${baseline} -> ${total})`); // ---- clean-page assertions ---- ok(consoleErrors.length === 0, `no console errors (saw ${consoleErrors.length})`); if (consoleErrors.length) consoleErrors.slice(0, 5).forEach((e) => console.log(' console:', e)); // CDN/tile requests may legitimately fail if offline — report but treat local assets strictly. const localFailed = failedRequests.filter((u) => u.includes('/static/') || u.includes(BASE)); ok(localFailed.length === 0, `no failed LOCAL asset requests (saw ${localFailed.length})`); if (failedRequests.length) failedRequests.slice(0, 8).forEach((u) => console.log(' reqfail:', u)); } catch (err) { ok(false, `unhandled error: ${err.message}`); await page.screenshot({ path: `${SHOTS}/error.png`, fullPage: true }).catch(() => {}); } finally { await browser.close(); } writeFileSync(`${SHOTS}/summary.json`, JSON.stringify({ fails, consoleErrors, failedRequests }, null, 2)); console.log('\n' + (fails.length ? `FAILED (${fails.length}): ${fails.join(' | ')}` : 'ALL CHECKS PASSED')); process.exit(fails.length ? 1 : 0);