Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs
T
sjg c1899229a0
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m15s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 3s
[fix](trx-frontend-http): stop the decode history replay giving up at 20s
Reloading a second time sometimes showed history the first load did not,
and the safety valve is why: it called one function that both released
the buffered live decodes and tore the history worker down, so any load
where the replay had not finished inside twenty seconds — a large
backlog, a cold cache, a slow link — dropped whatever had not arrived,
without a word.  A reload got another go at it, and the second one is
faster because everything is cached by then.

Those are two separate things now.  At the timeout the live decodes are
released so the panels are not held back, the replay carries on, and the
progress says so.  The fallback's error path retries once and then says
"Decode history unavailable" rather than leaving the operator to guess
whether there was anything to see.

The progress is no longer a scrim.  It was fixed to the whole viewport
with a wash over the page — the waterfall, the decode panels, all of it —
for the length of the replay, which is exactly when there is something
worth watching.  It is a corner card with a bar: indeterminate while the
payload is on the wire, then filling as N of M messages replay.

None of this was reachable from a test.  /decode/history answers in CBOR
and the worker reads the body as CBOR unconditionally, but the fixture
served JSON, so every browser run had been exercising the client's retry
path and never its history path.  It encodes CBOR now, including the
64-bit form the millisecond timestamps need, and decode-flow serves 1200
records and holds the client to restoring all of them on the first load,
showing progress while it does, and never covering the page with it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:14:13 +02:00

374 lines
14 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: "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 };
}