// 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: "sstv", label: "SSTV", activation: "toggle", active_modes: ["USB", "FM"] }, { 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, // Which decoders a background channel can run, as the server's own registry // has it: the background decode panel offers a bookmark only if one of these // can decode it, so marking them all false left that panel with nothing. background_decode: ["ft8", "ft4", "ft2", "wspr", "ais", "aprs", "hf-aprs"].includes(decoder.id), 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", "sstv"]; 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 = [], backgroundDecode = null, logbook = [], bandplan = {}, bandplanEnabled = false, bandplanUnauthorizedFirst = false, satPasses = null, authSession = { authenticated: true, roles: ["read", "control", "write", "administrator"], auth_disabled: true }, users = [], } = {}) { 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, sstv_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", authSession], ["/auth/users", users], ["/decoders", DECODER_REGISTRY], ["/rigs", rigsResponse], ["/status", status], ["/bookmarks", bookmarks], ["/bandplan.json", bandplan], ["/api/recorder/status", []], ["/api/recorder/files", []], ["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }], ]); // 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"); // Setting a control means the next status carries the new value, the way a // real server echoes what it applied. if (url.pathname === "/set_sdr_squelch") { const enabled = url.searchParams.get("enabled") === "true"; const threshold = Number(url.searchParams.get("threshold_db")); if (status.filter) { status.filter.sdr_squelch_enabled = enabled; if (Number.isFinite(threshold)) status.filter.sdr_squelch_threshold_db = threshold; } response.writeHead(200).end(); return; } if (url.pathname === "/set_freq") { const hz = Number(url.searchParams.get("hz")); if (Number.isFinite(hz) && hz > 0) status.status.freq = { hz: Math.round(hz) }; response.writeHead(200).end(); return; } if (url.pathname === "/set_mode") { const next = (url.searchParams.get("mode") || "").toUpperCase(); if (next) status.status.mode = next; response.writeHead(200).end(); return; } if (url.pathname === "/set_bandwidth") { const hz = Number(url.searchParams.get("hz")); if (Number.isFinite(hz) && hz > 0 && status.filter) status.filter.bandwidth_hz = Math.round(hz); response.writeHead(200).end(); return; } // The logbook: an in-memory station log, enough for the panel to open an // entry, write it, read it back and export it. if (url.pathname.startsWith("/api/logbook")) { const tail = url.pathname.slice("/api/logbook".length); if (tail === "/prefill") { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ started_at: new Date().toISOString(), epoch_ms: Date.now(), freq_hz: status.status.freq.hz, band: "20m", mode: url.searchParams.get("decoder") === "ft8" ? "FT8" : "SSB", submode: url.searchParams.get("decoder") === "ft8" ? null : "USB", rig_id: url.searchParams.get("remote") ?? "rig-a", my_rig: "Primary fixture", call: url.searchParams.get("call"), gridsquare: url.searchParams.get("gridsquare"), })); return; } if (tail.startsWith("/worked/")) { const call = decodeURIComponent(tail.slice("/worked/".length)).toUpperCase(); const worked = logbook .filter((qso) => qso.call === call) .map((qso) => ({ band: qso.band, mode: qso.mode })); response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ call, worked })); return; } if (tail === "/statistics") { const bands = new Map(); for (const qso of logbook) { const entry = bands.get(qso.band) ?? { band: qso.band, contacts: 0, stations: 0, confirmed: 0, calls: new Set() }; entry.contacts += 1; entry.calls.add(qso.call); if (qso.confirmed || qso.qsl_rcvd === "Y" || qso.lotw_qsl_rcvd === "Y") entry.confirmed += 1; bands.set(qso.band, entry); } const list = [...bands.values()].map(({ calls, ...rest }) => ({ ...rest, stations: calls.size })); response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ contacts: logbook.length, confirmed: list.reduce((total, band) => total + band.confirmed, 0), bands: list, })); return; } if (tail === "/export.cbr") { const contest = url.searchParams.get("contest"); const entered = contest ? logbook.filter((qso) => qso.contest_id === contest) : logbook; response.writeHead(200, { "content-type": "text/plain" }); response.end(`START-OF-LOG: 3.0\nCONTEST: ${contest ?? ""}\n` + entered.map((qso) => `QSO: 14200 PH ${qso.call}\n`).join("") + "END-OF-LOG:\n"); return; } if (request.method === "PUT") { const raw = await new Promise((resolve) => { let text = ""; request.on("data", (chunk) => { text += chunk; }); request.on("end", () => resolve(text)); }); const input = JSON.parse(raw); const id = tail.replace("/", ""); const held = logbook.find((qso) => qso.id === id); if (held) { Object.assign(held, input, { id, confirmed: input.qsl_rcvd === "Y" }); } response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify(held ?? {})); return; } if (tail === "/export.adi") { const body = logbook .map((qso) => `${qso.call}\n`) .join(""); response.writeHead(200, { "content-type": "text/plain" }); response.end(body); return; } if (request.method === "POST" && tail === "") { const raw = await new Promise((resolve) => { let text = ""; request.on("data", (chunk) => { text += chunk; }); request.on("end", () => resolve(text)); }); const input = JSON.parse(raw); const qso = { id: `qso-${String(logbook.length + 1)}`, started_at: input.started_at ?? new Date().toISOString(), call: String(input.call).toUpperCase(), freq_hz: input.freq_hz, band: "20m", mode: input.mode, submode: input.submode, rst_sent: input.rst_sent, rst_rcvd: input.rst_rcvd, gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null, my_rig: input.my_rig, operator: input.operator, contest_id: input.contest_id ?? null, stx: input.stx ?? null, srx: input.srx ?? null, confirmed: input.qsl_rcvd === "Y" || input.lotw_qsl_rcvd === "Y", }; logbook.unshift(qso); response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify(qso)); return; } if (request.method === "DELETE") { const id = tail.replace("/", ""); const index = logbook.findIndex((qso) => qso.id === id); if (index >= 0) logbook.splice(index, 1); response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ deleted: index >= 0 })); return; } const wanted = (url.searchParams.get("call") ?? "").toUpperCase(); const matching = wanted ? logbook.filter((qso) => qso.call.includes(wanted)) : logbook; response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ total: logbook.length, qsos: matching })); return; } // Background decode: the panel reads its config, its status, and the // bookmark list, and writes the config back. if (url.pathname.startsWith("/background-decode/")) { const isStatus = url.pathname.endsWith("/status"); if (isStatus) { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify(backgroundDecode?.status ?? { entries: [] })); return; } if (request.method === "PUT") { const body = await new Promise((resolve) => { let raw = ""; request.on("data", (chunk) => { raw += chunk; }); request.on("end", () => resolve(raw)); }); try { const parsed = JSON.parse(body); if (backgroundDecode) backgroundDecode.config = parsed; } catch { /* leave the config as it was */ } } if (request.method === "DELETE" && backgroundDecode) { backgroundDecode.config = { remote: "rig-a", enabled: false, bookmark_ids: [] }; } response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify(backgroundDecode?.config ?? { remote: "rig-a", enabled: false, bookmark_ids: [] })); return; } 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; } // The real server pushes rig state here every second or so; serving an // open-but-silent stream meant nothing in the client's state-update path // was ever exercised. if (url.pathname === "/events") { response.writeHead(200, { "cache-control": "no-cache", connection: "keep-alive", "content-type": "text/event-stream", }); // Varying, as a real one is: the client skips a frame identical to the // last, so a repeated payload exercises none of the state-update path. // The variation rides on the S-meter rather than the dial — a rig that // wandered a kilohertz every frame could never be told to tune. const frame = () => JSON.stringify({ ...status, status: { ...status.status, rx: { ...status.status.rx, sig: meterDb + ((Date.now() % 3) - 1) } }, }); response.write(`data: ${frame()}\n\n`); const timer = setInterval(() => { response.write(`data: ${frame()}\n\n`); }, 700); 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 }; }