58ba021de1
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>
106 lines
4.8 KiB
JavaScript
106 lines
4.8 KiB
JavaScript
#!/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);
|