Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
T
sjg 5e9dae02c7
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m6s
CI / frontend (pull_request) Successful in 5m17s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m26s
Complete managed account lifecycle
2026-08-11 07:49:53 +02:00

596 lines
23 KiB
JavaScript

// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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) => `<CALL:${qso.call.length}>${qso.call}<EOR>\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 };
}