// SPDX-FileCopyrightText: 2026 Stan Grams // // SPDX-License-Identifier: GPL-2.0-or-later // The static server the browser tests load the real web assets from. It was // inline in browser-smoke.mjs until a second browser test needed a rig with a // spectrum: the interesting layout lives in the spectrum area, and none of it // could be exercised while the only fixture served a CAT-only rig. import { readFile } from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const webDir = path.resolve(frontendDir, "../assets/web"); const generatedDir = path.join(webDir, "generated"); // A realistic decoder registry. Serving an empty one hid most of the // application from this test: the decoder sub-tabs, their panels, the decode // toggles and the bookmark decoder checkboxes are all built from it, so with // no decoders only three of thirteen sub-tabs existed and none of the decoder // UI was ever constructed. const DECODER_REGISTRY = [ { id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"] }, { id: "ft4", label: "FT4", activation: "toggle", active_modes: ["USB"] }, { id: "ft2", label: "FT2", activation: "toggle", active_modes: ["USB"] }, { id: "wspr", label: "WSPR", activation: "toggle", active_modes: ["USB"] }, { id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] }, { id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] }, { id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] }, { id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] }, { id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] }, { id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] }, { id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] }, ].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true })); const CONTENT_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], [".js", "application/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], [".png", "image/png"], [".woff2", "font/woff2"], ]); // The history endpoint answers in CBOR (see api/decoder.rs), and the worker // that reads it takes the body as CBOR unconditionally. Serving JSON here left // every run exercising the client's retry path instead of its history path. function encodeCbor(value) { const chunks = []; const head = (major, length) => { if (length < 24) return Buffer.from([(major << 5) | length]); if (length < 0x100) return Buffer.from([(major << 5) | 24, length]); if (length < 0x10000) { const buffer = Buffer.alloc(3); buffer[0] = (major << 5) | 25; buffer.writeUInt16BE(length, 1); return buffer; } if (length < 0x1_0000_0000) { const buffer = Buffer.alloc(5); buffer[0] = (major << 5) | 26; buffer.writeUInt32BE(length, 1); return buffer; } // Timestamps are past 2^32 milliseconds, so the 64-bit form is needed. const buffer = Buffer.alloc(9); buffer[0] = (major << 5) | 27; buffer.writeBigUInt64BE(BigInt(length), 1); return buffer; }; const write = (item) => { if (item === null || item === undefined) { chunks.push(Buffer.from([0xf6])); return; } if (typeof item === "boolean") { chunks.push(Buffer.from([item ? 0xf5 : 0xf4])); return; } if (typeof item === "number") { if (Number.isInteger(item) && item >= 0) { chunks.push(head(0, item)); return; } if (Number.isInteger(item) && item < 0) { chunks.push(head(1, -item - 1)); return; } const buffer = Buffer.alloc(9); buffer[0] = 0xfb; buffer.writeDoubleBE(item, 1); chunks.push(buffer); return; } if (typeof item === "string") { const bytes = Buffer.from(item, "utf8"); chunks.push(head(3, bytes.length), bytes); return; } if (Array.isArray(item)) { chunks.push(head(4, item.length)); item.forEach(write); return; } const entries = Object.entries(item); chunks.push(head(5, entries.length)); for (const [key, entryValue] of entries) { const keyBytes = Buffer.from(key, "utf8"); chunks.push(head(3, keyBytes.length), keyBytes); write(entryValue); } }; write(value); return Buffer.concat(chunks); } const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"]; function assetPath(urlPath) { // Every tab route has its own index handler on the server (see api/assets.rs), // so a deep link or a refresh serves the SPA shell, not a 404. if (!path.extname(urlPath)) return path.join(webDir, "index.html"); if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath); const generated = path.join(generatedDir, path.basename(urlPath)); if (urlPath.endsWith(".js")) return generated; return path.join(webDir, urlPath); } /** * Starts the fixture server. * * `spectrum` turns the rig into an SDR: `filter_controls` is what gates the * spectrum panel, and frames are pushed on the /spectrum stream from a centre * frequency the test moves with `setCenterHz` to simulate tuning across bands. */ export async function startWebFixture({ spectrum = false, tx = false, meterDb = -70, decodes = [], mode = "FM", history = {}, bookmarks = [], bandplan = {}, bandplanEnabled = false, bandplanUnauthorizedFirst = false, } = {}) { const rigItems = ["rig-a", "rig-b"].map((remote) => ({ remote, display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture", manufacturer: "Smoke", model: "Fixture", supported_modes: ["FM"], tx, filter_controls: spectrum, initialized: true, latitude: null, longitude: null, })); const rigsResponse = { rigs: rigItems, active_remote: "rig-a" }; const selectedRigs = []; const state = { centerHz: 7074000 }; let bandplanServed = false; const status = { info: { manufacturer: "Smoke", model: "Fixture", revision: "1", access: { Tcp: { addr: "127.0.0.1:0" } }, capabilities: { min_freq_step_hz: 1, supported_bands: [], supported_modes: ["LSB", "USB", "CW", "CWR", "AM", "SAM", "WFM", "FM", "AIS", "VDES", "DIG", "PKT"], num_vfos: 1, lock: false, lockable: tx, attenuator: false, preamp: false, rit: false, rpt: false, split: false, tx, tx_limit: tx, vfo_switch: false, filter_controls: spectrum, signal_meter: spectrum, }, }, status: { freq: { hz: 100_000_000 }, mode, tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null }, // Reported only by SDR backends, and what makes the client show the // squelch control at all. filter: spectrum ? { bandwidth_hz: 12_000, sdr_squelch_enabled: false, sdr_squelch_threshold_db: -95, sdr_agc_enabled: false, } : null, band: null, enabled: true, initialized: true, cw_auto: false, cw_wpm: 20, cw_tone_hz: 700, aprs_decode_enabled: false, hf_aprs_decode_enabled: false, cw_decode_enabled: false, ft8_decode_enabled: false, ft4_decode_enabled: false, ft2_decode_enabled: false, wspr_decode_enabled: false, lrpt_decode_enabled: false, wefax_decode_enabled: false, recorder_enabled: false, clients: 1, rigctl_clients: 0, audio_clients: 0, active_remote: "rig-a", remotes: ["rig-a", "rig-b"], show_sdr_gain_control: false, initial_map_zoom: 10, spectrum_coverage_margin_hz: 50_000, spectrum_usable_span_ratio: 0.92, bandplan_enabled: bandplanEnabled, bandplan_region: "iaru1", decode_history_retention_min: 1440, server_connected: true, }; const jsonRoutes = new Map([ ["/auth/session", { authenticated: true, role: "control", auth_disabled: true }], ["/decoders", DECODER_REGISTRY], ["/rigs", rigsResponse], ["/status", status], ["/bookmarks", bookmarks], ["/bandplan.json", bandplan], ["/api/recorder/status", []], ["/api/recorder/files", []], ]); // Flat i8 bins: the shape does not matter, only that frames arrive so the // page has a spectrum range to place bookmarks and allocations against. const spectrumBins = Buffer.alloc(512, 200); const spectrumB64 = spectrumBins.toString("base64"); const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (url.pathname === "/select_rig" && request.method === "POST") { const remote = url.searchParams.get("remote"); if (remote) { rigsResponse.active_remote = remote; status.active_remote = remote; selectedRigs.push(remote); } response.writeHead(200).end(); return; } // Rejects the first band plan request the way the server did before it was // classified as a public asset: the page asks for it at startup, which can // land before the session exists. if (bandplanUnauthorizedFirst && url.pathname === "/bandplan.json" && !bandplanServed) { bandplanServed = true; response.writeHead(401).end(); return; } if (jsonRoutes.has(url.pathname)) { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify(jsonRoutes.get(url.pathname))); return; } // 200 means "audio is configured": the client hides the whole audio row — // and the squelch control with it — when this 404s. if (url.pathname === "/audio") { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ sample_rate: 48_000, channels: 1 })); return; } if (spectrum && url.pathname === "/spectrum") { response.writeHead(200, { "cache-control": "no-cache", connection: "keep-alive", "content-type": "text/event-stream", }); const timer = setInterval(() => { response.write(`event: b\ndata: ${state.centerHz},192000,${spectrumB64}\n\n`); }, 100); request.on("close", () => clearInterval(timer)); return; } // The meter streams like the server's does: the squelch reads its noise // level from here, so a static snapshot would leave it nothing to measure. if (url.pathname === "/meter") { response.writeHead(200, { "cache-control": "no-cache", connection: "keep-alive", "content-type": "text/event-stream", }); const timer = setInterval(() => { response.write(`data: ${JSON.stringify({ sig: meterDb })}\n\n`); }, 120); request.on("close", () => clearInterval(timer)); return; } // Decodes arrive on this stream in the server's own shape: a routing // "type" naming the decoder, snake_case fields inside. The mini views, the // map markers and the history all hang off it, and serving nothing left // every one of them untested. if (url.pathname === "/decode/history") { const payload = Object.fromEntries(HISTORY_GROUPS.map((group) => [group, history[group] ?? []])); response.writeHead(200, { "content-type": "application/cbor" }); response.end(encodeCbor(payload)); return; } if (url.pathname === "/decode") { response.writeHead(200, { "cache-control": "no-cache", connection: "keep-alive", "content-type": "text/event-stream", }); response.write(": decode stream\n\n"); // Repeats: a live decoder keeps producing, and the views that collapse // by station or vessel need more than one frame to behave like they do // in front of a radio. let sent = 0; const timer = setInterval(() => { if (!decodes.length) return; const decode = decodes[sent++ % decodes.length]; // Stamped as they leave: the client prunes anything older than the // retention window, so a fixed epoch would be dropped on arrival. response.write(`data: ${JSON.stringify({ ts_ms: Date.now(), ...decode })}\n\n`); }, 400); request.on("close", () => clearInterval(timer)); return; } if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) { response.writeHead(200, { "cache-control": "no-cache", connection: "keep-alive", "content-type": "text/event-stream", }); response.write(": browser smoke stream\n\n"); return; } try { const file = assetPath(url.pathname); const bytes = await readFile(file); response.writeHead(200, { "content-type": CONTENT_TYPES.get(path.extname(file)) ?? "application/octet-stream", }); response.end(bytes); } catch { response.writeHead(404).end(); } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address !== "object") throw new Error("fixture server has no port"); return { origin: `http://127.0.0.1:${address.port}`, selectedRigs, rigsResponse, /** Moves the spectrum centre, i.e. tunes the fixture rig to another band. */ setCenterHz(hz) { state.centerHz = hz; }, close() { return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); }, }; } /** Launches headless Chromium and records any uncaught page error. */ export async function startBrowser(chromium) { const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? "/usr/bin/chromium"; const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] }); const page = await browser.newPage(); const runtimeErrors = []; page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message)); return { browser, page, runtimeErrors }; }