From 1f256cbb68abbf7eeed011469af114c2e204525c Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Mon, 3 Aug 2026 21:26:06 +0200 Subject: [PATCH] [refactor](trx-frontend-http): extract the browser test fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser-smoke.mjs carried its static server inline, which made it the only browser test that could exist: a second one would have had to copy 180 lines of routes to change a single capability flag. The server moves to tests/web-fixture.mjs behind startWebFixture(), with the rig's spectrum support, bookmarks and band plan as options. Serving a rig with a spectrum matters because that is where the layout actually lives — the panel, the strips above it and the waterfall are all gated on filter_controls, and the existing fixture reports a CAT-only rig, so none of it has ever been rendered under test. No change to what the smoke test checks. Signed-off-by: Stan Grams --- .../frontend/tests/browser-smoke.mjs | 189 +------------- .../frontend/tests/web-fixture.mjs | 238 ++++++++++++++++++ 2 files changed, 246 insertions(+), 181 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs index 46287af3..95a55a65 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs @@ -3,191 +3,18 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import http from "node:http"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { chromium } from "playwright-core"; +import { startBrowser, startWebFixture } from "./web-fixture.mjs"; // page.evaluate callbacks run in the browser, not in this Node process. /* global document, getComputedStyle */ -const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const webDir = path.resolve(frontendDir, "../assets/web"); -const generatedDir = path.join(webDir, "generated"); -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: false, - filter_controls: false, - initialized: true, - latitude: null, - longitude: null, -})); -const rigsResponse = { rigs: rigItems, active_remote: "rig-a" }; -const selectedRigs = []; - -// 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 decoderRegistry = [ - { 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 jsonRoutes = new Map([ - ["/auth/session", { authenticated: true, role: "control", auth_disabled: true }], - ["/decoders", decoderRegistry], - ["/rigs", rigsResponse], - ["/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: ["FM"], - num_vfos: 1, - lock: false, - lockable: false, - attenuator: false, - preamp: false, - rit: false, - rpt: false, - split: false, - tx: false, - tx_limit: false, - vfo_switch: false, - filter_controls: false, - signal_meter: false, - }, - }, - 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: false, - bandplan_region: "iaru1", - decode_history_retention_min: 1440, - server_connected: true, - }], - ["/bandplan.json", {}], - ["/api/recorder/status", []], - ["/api/recorder/files", []], -]); - -const contentTypes = 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); -} - -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; - jsonRoutes.get("/status").active_remote = remote; - selectedRigs.push(remote); - } - response.writeHead(200).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 (["/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": contentTypes.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(); -assert(address && typeof address === "object"); - -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)); +const fixture = await startWebFixture(); +const { selectedRigs } = fixture; +const { browser, page, runtimeErrors } = await startBrowser(chromium); try { - await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "domcontentloaded" }); + await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(500); assert.deepEqual(runtimeErrors, []); await page.locator("#content").waitFor({ state: "visible" }); @@ -281,7 +108,7 @@ try { // a test that asked whether the tab was displayed saw "none" for every tab // and lit Tools up on every refresh of every page. for (const [route, tab, toolsLit] of [["/map", "map", false], ["/about", "about", true]]) { - await page.goto(`http://127.0.0.1:${address.port}${route}`, { waitUntil: "domcontentloaded" }); + await page.goto(`${fixture.origin}${route}`, { waitUntil: "domcontentloaded" }); await page.locator(`#tab-${tab}`).waitFor({ state: "visible" }); const marked = await page.evaluate(() => ({ actives: [...document.querySelectorAll(".tab-bar .tab.active")].map((t) => t.dataset.tab || t.id), @@ -290,7 +117,7 @@ try { assert.ok(marked.actives.includes(tab), `${route} marks ${JSON.stringify(marked.actives)}`); assert.equal(marked.tools, toolsLit, `${route}: Tools active is ${marked.tools}`); } - await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "domcontentloaded" }); + await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" }); await page.locator("#tab-main").waitFor({ state: "visible" }); assert.deepEqual(runtimeErrors, []); @@ -344,5 +171,5 @@ try { } finally { await browser.close(); - await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + await fixture.close(); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs new file mode 100644 index 00000000..b3180a30 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs @@ -0,0 +1,238 @@ +// 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, + bookmarks = [], + bandplan = {}, + bandplanEnabled = 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: false, + filter_controls: spectrum, + initialized: true, + latitude: null, + longitude: null, + })); + const rigsResponse = { rigs: rigItems, active_remote: "rig-a" }; + const selectedRigs = []; + const state = { centerHz: 7074000 }; + + 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: ["FM"], + num_vfos: 1, + lock: false, + lockable: false, + attenuator: false, + preamp: false, + rit: false, + rpt: false, + split: false, + tx: false, + tx_limit: false, + 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; + } + 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 }; +}