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
596 lines
23 KiB
JavaScript
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 };
|
|
}
|