// 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"], ]); 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, 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: "FM", tx_en: false, vfo: null, tx: null, rx: null, lock: 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; } if (url.pathname === "/audio") { response.writeHead(404).end(); 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; } 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 }; }