test: add end-to-end UI verification harness for deploy artifact

Adds VERIFICATION.md + scripts/verify_ui.mjs, a Playwright-driven check of
the full user journey against the deployed SPA: page loads clean, Leaflet
map renders, a real DOM upload (.sub + GPS) returns a device match, and the
capture store increments. Grounded in real element IDs and live endpoints.

In-sandbox this session covered the code-correctness layers (L1-substance
parse->match->category, L3 save/load round-trip, L4 BinRAW + per-device
catalog category regressions). The runtime/transport layers still require a
live listening server and must be run from a normal shell:
  - L0            docker compose up --build, container healthy
  - L1 HTTP       curl/jq /health + static assets 200
  - L2            Playwright browser journey (scripts/verify_ui.mjs)
  - L3 restart    container up->down->up, capture count unchanged (volume)

Also ignores trained model binaries (models/*.onnx, *.pt) and the
verify-shots/ screenshot output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-20 22:30:30 -07:00
parent 41850b5892
commit 58ba021de1
3 changed files with 416 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
#!/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);