The picker and the header showed each rig's lowercase id instead of its configured name. applyRigList takes the names as a parameter defaulted to an empty map, and the state-update path passes only the rig ids — names come from /rigs, not from a state frame — so that call landed on the default and the body, which treats "an object" as "here are the names", cleared them. One frame after load the names were gone for the rest of the session. Omitted now means no news rather than no names. The fixture is why this was invisible: it pushed an identical status payload every tick and the client skips a frame equal to the last, so render never ran and neither did the call that did the damage. Its event stream varies between frames now, as a real one does. Which immediately caught a second fault: state frames arrive continuously, and one sent before the server applied a new squelch threshold snapped the line back to where it had just been dragged from. A local change outranks the echo for two seconds, the same idea as the optimistic frequency guard beside it. The fixture also records what /set_sdr_squelch sets and reports it back afterwards — the drag test had been passing against a server that ignored the write. Signed-off-by: Stan Grams <sjg@haxx.space>
408 lines
15 KiB
JavaScript
408 lines
15 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");
|
|
// 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 === "/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.
|
|
const frame = () => JSON.stringify({
|
|
...status,
|
|
status: { ...status.status, freq: { hz: 100_000_000 + (Date.now() % 1000) } },
|
|
});
|
|
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 };
|
|
}
|