Compare commits
3
Commits
92fbdb692c
...
23cc0db1fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23cc0db1fa | ||
|
|
657f952a2c | ||
|
|
1f256cbb68 |
@@ -818,6 +818,8 @@ function elementById(id) {
|
|||||||
return needed <= bar.clientWidth;
|
return needed <= bar.clientWidth;
|
||||||
};
|
};
|
||||||
const reflowOverflow = () => {
|
const reflowOverflow = () => {
|
||||||
|
const nav = document.querySelector(".tab-bar-nav");
|
||||||
|
nav?.classList.remove("nav-icons-only");
|
||||||
overflowOrder.forEach((selector) => {
|
overflowOrder.forEach((selector) => {
|
||||||
const element = menu.querySelector(selector);
|
const element = menu.querySelector(selector);
|
||||||
if (element) actions.insertBefore(element, wrap);
|
if (element) actions.insertBefore(element, wrap);
|
||||||
@@ -830,6 +832,7 @@ function elementById(id) {
|
|||||||
wrap.hidden = false;
|
wrap.hidden = false;
|
||||||
menu.appendChild(element);
|
menu.appendChild(element);
|
||||||
}
|
}
|
||||||
|
if (nav && !barFits()) nav.classList.add("nav-icons-only");
|
||||||
wrap.hidden = menu.children.length === 0;
|
wrap.hidden = menu.children.length === 0;
|
||||||
if (wrap.hidden) closeMenu();
|
if (wrap.hidden) closeMenu();
|
||||||
};
|
};
|
||||||
@@ -8588,7 +8591,8 @@ function updateBookmarkAxis(range) {
|
|||||||
updateSideBookmarkStack(leftSideEl, leftBookmarks, colorMap);
|
updateSideBookmarkStack(leftSideEl, leftBookmarks, colorMap);
|
||||||
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
|
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
|
||||||
const hasVisible = visBookmarks.length > 0;
|
const hasVisible = visBookmarks.length > 0;
|
||||||
axisEl.classList.toggle("bm-axis-visible", hasVisible);
|
axisEl.classList.add("bm-axis-visible");
|
||||||
|
axisEl.classList.toggle("bm-axis-empty", !hasVisible);
|
||||||
if (!hasVisible) {
|
if (!hasVisible) {
|
||||||
if (axisEl.dataset.bmKey) {
|
if (axisEl.dataset.bmKey) {
|
||||||
axisEl.replaceChildren();
|
axisEl.replaceChildren();
|
||||||
@@ -9383,24 +9387,28 @@ function bandplanVisibleSegments(region, loHz, hiHz) {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
function _hideBandplanStrip() {
|
function _clearBandplanStrip(reserveSpace) {
|
||||||
if (!bandplanStripEl) return;
|
if (!bandplanStripEl) return;
|
||||||
bandplanStripEl.classList.remove("bp-visible");
|
if (bandplanCacheKey) {
|
||||||
bandplanStripEl.replaceChildren();
|
bandplanStripEl.replaceChildren();
|
||||||
bandplanCacheKey = "";
|
bandplanCacheKey = "";
|
||||||
|
}
|
||||||
|
bandplanStripEl.classList.toggle("bp-visible", reserveSpace);
|
||||||
|
bandplanStripEl.classList.toggle("bp-empty", reserveSpace);
|
||||||
}
|
}
|
||||||
function updateBandplanStrip(range) {
|
function updateBandplanStrip(range) {
|
||||||
if (!bandplanStripEl) return;
|
if (!bandplanStripEl) return;
|
||||||
if (!range || bandplanRegion === "off" || !bandplanData) {
|
if (!range || bandplanRegion === "off" || !bandplanData) {
|
||||||
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
|
_clearBandplanStrip(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
|
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
|
_clearBandplanStrip(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bandplanStripEl.classList.add("bp-visible");
|
bandplanStripEl.classList.add("bp-visible");
|
||||||
|
bandplanStripEl.classList.remove("bp-empty");
|
||||||
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" + segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
|
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" + segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
|
||||||
const stripW = bandplanStripEl.clientWidth || 1;
|
const stripW = bandplanStripEl.clientWidth || 1;
|
||||||
if (bandplanCacheKey !== newKey) {
|
if (bandplanCacheKey !== newKey) {
|
||||||
|
|||||||
@@ -1562,11 +1562,17 @@ small { color: var(--text-muted); }
|
|||||||
}
|
}
|
||||||
.tab-bar-nav .tab { flex: 0 0 auto; }
|
.tab-bar-nav .tab { flex: 0 0 auto; }
|
||||||
/* Icons before scrolling: every tab already carries one, and four icons plus
|
/* Icons before scrolling: every tab already carries one, and four icons plus
|
||||||
* More always fit, so the strip never has to hide a destination. */
|
* More always fit, so the strip never has to hide a destination. Applied by
|
||||||
@media (max-width: 1360px) and (min-width: 701px) {
|
* measurement (ui-core's reflowOverflow) rather than at a viewport width: how
|
||||||
.tab-bar-nav .tab .tab-label { display: none; }
|
* much fits depends on the rig name and on how wide the platform renders the
|
||||||
.tab-bar-nav .tab .tab-icon, .tab-bar-nav .tab .tab-more-icon { display: block; }
|
* labels, so the same width fits on one machine and clips on another.
|
||||||
.tab-bar-nav .tab { padding: 0.5rem 0.6rem; }
|
* Bounded below 761px, where the strip becomes the bottom nav and keeps its
|
||||||
|
* labels under the icons. */
|
||||||
|
@media (min-width: 761px) {
|
||||||
|
.tab-bar-nav.nav-icons-only .tab .tab-label { display: none; }
|
||||||
|
.tab-bar-nav.nav-icons-only .tab .tab-icon,
|
||||||
|
.tab-bar-nav.nav-icons-only .tab .tab-more-icon { display: block; }
|
||||||
|
.tab-bar-nav.nav-icons-only .tab { padding: 0.45rem 0.55rem; }
|
||||||
}
|
}
|
||||||
/* The selected destination is boxed, not underlined — the same treatment the
|
/* The selected destination is boxed, not underlined — the same treatment the
|
||||||
mobile bottom nav already used, so one navigation model reads the same at
|
mobile bottom nav already used, so one navigation model reads the same at
|
||||||
@@ -3967,6 +3973,11 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-bottom-color: var(--border-light, rgba(255,255,255,0.06));
|
border-bottom-color: var(--border-light, rgba(255,255,255,0.06));
|
||||||
}
|
}
|
||||||
|
/* No allocations in range. The height stays reserved — collapsing it moved
|
||||||
|
every element below by 18px each time tuning left a band. */
|
||||||
|
#spectrum-bandplan-strip.bp-empty {
|
||||||
|
background: color-mix(in srgb, var(--border) 22%, transparent);
|
||||||
|
}
|
||||||
.bp-segment {
|
.bp-segment {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -4057,6 +4068,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
/* The rail sits over the top of the overview plot, so it must not swallow
|
||||||
|
drags and clicks meant for it — only the chips themselves are targets. */
|
||||||
|
#spectrum-bookmark-axis { pointer-events: none; }
|
||||||
|
#spectrum-bookmark-axis .spectrum-bookmark-chip { pointer-events: auto; }
|
||||||
|
/* Nothing bookmarked in range: the rail keeps its place but stays blank —
|
||||||
|
no caption over the plot. */
|
||||||
.spectrum-bookmark-chip {
|
.spectrum-bookmark-chip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"test:browser": "node tests/browser-smoke.mjs",
|
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs",
|
||||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -7761,7 +7761,12 @@ function updateBookmarkAxis(range: SpectrumRange) {
|
|||||||
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
|
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
|
||||||
|
|
||||||
const hasVisible = visBookmarks.length > 0;
|
const hasVisible = visBookmarks.length > 0;
|
||||||
axisEl.classList.toggle("bm-axis-visible", hasVisible);
|
// The rail is kept up with nothing in range — blank, no caption — so tuning
|
||||||
|
// across bands only swaps its contents rather than taking the strip itself
|
||||||
|
// away. This function only runs with a spectrum range in hand, so rigs
|
||||||
|
// without a spectrum never get it.
|
||||||
|
axisEl.classList.add("bm-axis-visible");
|
||||||
|
axisEl.classList.toggle("bm-axis-empty", !hasVisible);
|
||||||
|
|
||||||
if (!hasVisible) {
|
if (!hasVisible) {
|
||||||
if (axisEl.dataset.bmKey) { axisEl.replaceChildren(); axisEl.dataset.bmKey = ""; }
|
if (axisEl.dataset.bmKey) { axisEl.replaceChildren(); axisEl.dataset.bmKey = ""; }
|
||||||
@@ -8640,27 +8645,36 @@ function bandplanVisibleSegments(region: string, loHz: number, hiHz: number): Vi
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _hideBandplanStrip() {
|
// Empties the strip. `reserveSpace` keeps its height: the strip is in flow, so
|
||||||
|
// collapsing it on a range with no allocations shifted the whole page down by
|
||||||
|
// its height every time tuning crossed out of a band. Space stays reserved
|
||||||
|
// whenever a band plan could be drawn at all, and is only given back when the
|
||||||
|
// feature is off, has no data, or there is no spectrum to annotate.
|
||||||
|
function _clearBandplanStrip(reserveSpace: boolean) {
|
||||||
if (!bandplanStripEl) return;
|
if (!bandplanStripEl) return;
|
||||||
bandplanStripEl.classList.remove("bp-visible");
|
if (bandplanCacheKey) {
|
||||||
bandplanStripEl.replaceChildren();
|
bandplanStripEl.replaceChildren();
|
||||||
bandplanCacheKey = "";
|
bandplanCacheKey = "";
|
||||||
|
}
|
||||||
|
bandplanStripEl.classList.toggle("bp-visible", reserveSpace);
|
||||||
|
bandplanStripEl.classList.toggle("bp-empty", reserveSpace);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBandplanStrip(range: SpectrumRange | null) {
|
function updateBandplanStrip(range: SpectrumRange | null) {
|
||||||
if (!bandplanStripEl) return;
|
if (!bandplanStripEl) return;
|
||||||
if (!range || bandplanRegion === "off" || !bandplanData) {
|
if (!range || bandplanRegion === "off" || !bandplanData) {
|
||||||
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
|
_clearBandplanStrip(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
|
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
|
_clearBandplanStrip(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bandplanStripEl.classList.add("bp-visible");
|
bandplanStripEl.classList.add("bp-visible");
|
||||||
|
bandplanStripEl.classList.remove("bp-empty");
|
||||||
|
|
||||||
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" +
|
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" +
|
||||||
segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
|
segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
|
||||||
|
|||||||
@@ -422,6 +422,10 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
return needed <= bar.clientWidth;
|
return needed <= bar.clientWidth;
|
||||||
};
|
};
|
||||||
const reflowOverflow = () => {
|
const reflowOverflow = () => {
|
||||||
|
const nav = document.querySelector<HTMLElement>(".tab-bar-nav");
|
||||||
|
// Measure from the roomiest state every time, so the decision is a
|
||||||
|
// function of the current widths alone and cannot ratchet.
|
||||||
|
nav?.classList.remove("nav-icons-only");
|
||||||
overflowOrder.forEach((selector) => {
|
overflowOrder.forEach((selector) => {
|
||||||
const element = menu.querySelector<HTMLElement>(selector);
|
const element = menu.querySelector<HTMLElement>(selector);
|
||||||
if (element) actions.insertBefore(element, wrap);
|
if (element) actions.insertBefore(element, wrap);
|
||||||
@@ -434,6 +438,12 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
wrap.hidden = false;
|
wrap.hidden = false;
|
||||||
menu.appendChild(element);
|
menu.appendChild(element);
|
||||||
}
|
}
|
||||||
|
// Last resort, once every movable control is already in the menu: drop
|
||||||
|
// the tabs to their icons. Without it the nav — which may shrink below
|
||||||
|
// its content — keeps its tabs at full width and runs them under the
|
||||||
|
// controls, so the destinations nearest the controls become unclickable.
|
||||||
|
// Icon widths are fixed, so this always buys back the labels' width.
|
||||||
|
if (nav && !barFits()) nav.classList.add("nav-icons-only");
|
||||||
wrap.hidden = menu.children.length === 0;
|
wrap.hidden = menu.children.length === 0;
|
||||||
if (wrap.hidden) closeMenu();
|
if (wrap.hidden) closeMenu();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,191 +3,18 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
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 { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
// page.evaluate callbacks run in the browser, not in this Node process.
|
// page.evaluate callbacks run in the browser, not in this Node process.
|
||||||
/* global document, getComputedStyle */
|
/* global document, getComputedStyle */
|
||||||
|
|
||||||
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
const fixture = await startWebFixture();
|
||||||
const webDir = path.resolve(frontendDir, "../assets/web");
|
const { selectedRigs } = fixture;
|
||||||
const generatedDir = path.join(webDir, "generated");
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
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));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "domcontentloaded" });
|
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
assert.deepEqual(runtimeErrors, []);
|
assert.deepEqual(runtimeErrors, []);
|
||||||
await page.locator("#content").waitFor({ state: "visible" });
|
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
|
// a test that asked whether the tab was displayed saw "none" for every tab
|
||||||
// and lit Tools up on every refresh of every page.
|
// and lit Tools up on every refresh of every page.
|
||||||
for (const [route, tab, toolsLit] of [["/map", "map", false], ["/about", "about", true]]) {
|
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" });
|
await page.locator(`#tab-${tab}`).waitFor({ state: "visible" });
|
||||||
const marked = await page.evaluate(() => ({
|
const marked = await page.evaluate(() => ({
|
||||||
actives: [...document.querySelectorAll(".tab-bar .tab.active")].map((t) => t.dataset.tab || t.id),
|
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.ok(marked.actives.includes(tab), `${route} marks ${JSON.stringify(marked.actives)}`);
|
||||||
assert.equal(marked.tools, toolsLit, `${route}: Tools active is ${marked.tools}`);
|
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" });
|
await page.locator("#tab-main").waitFor({ state: "visible" });
|
||||||
assert.deepEqual(runtimeErrors, []);
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
|
||||||
@@ -342,7 +169,35 @@ try {
|
|||||||
assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
|
assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
|
||||||
assert.ok(menu.onTop, "menu is painted underneath the page");
|
assert.ok(menu.onTop, "menu is painted underneath the page");
|
||||||
|
|
||||||
|
// Wider text than this machine renders. The checks above passed on macOS
|
||||||
|
// while CI, whose system font is wider, put the tab strip 9px into the
|
||||||
|
// controls: the bar had run out of controls to move into the overflow menu
|
||||||
|
// and the tabs kept their full width anyway. Scaling the bar's own text
|
||||||
|
// reproduces that on any machine. The tabs carry their font size themselves,
|
||||||
|
// so the parent size alone does not cascade to them.
|
||||||
|
await page.addStyleTag({ content: `
|
||||||
|
.tab-bar { font-size: 160%; }
|
||||||
|
.tab-bar .tab, .tab-bar select, .tab-bar button { font-size: 1.52rem; }
|
||||||
|
` });
|
||||||
|
for (const width of [1440, 1280, 1100]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const crowded = await page.evaluate(() => {
|
||||||
|
const nav = document.querySelector(".tab-bar-nav");
|
||||||
|
const actions = document.querySelector(".top-bar-actions");
|
||||||
|
const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null);
|
||||||
|
return {
|
||||||
|
overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right))
|
||||||
|
- actions.getBoundingClientRect().left),
|
||||||
|
iconsOnly: nav.classList.contains("nav-icons-only"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.ok(crowded.overlap <= 0,
|
||||||
|
`with wider text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
|
||||||
|
assert.equal(crowded.iconsOnly, true, `the strip kept its labels at ${width}px with no room for them`);
|
||||||
|
}
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
await fixture.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// Geometry of the spectrum area while tuning. Everything above the spectrum is
|
||||||
|
// driven by what happens to be in the visible range — band plan allocations,
|
||||||
|
// bookmarks — and the strips that show them used to appear and disappear with
|
||||||
|
// it, so tuning across a band edge moved the whole page under the operator's
|
||||||
|
// cursor. Nothing else in the suite serves a rig with a spectrum.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document */
|
||||||
|
|
||||||
|
const BOOKMARKS = [
|
||||||
|
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
|
||||||
|
{ id: "b2", name: "40m CW", freq_hz: 7030000, mode: "CW", category: "", comment: "", locator: "" },
|
||||||
|
];
|
||||||
|
const BANDPLAN = {
|
||||||
|
iaru1: {
|
||||||
|
bands: [{
|
||||||
|
name: "40m",
|
||||||
|
low_hz: 7000000,
|
||||||
|
high_hz: 7200000,
|
||||||
|
segments: [
|
||||||
|
{ low_hz: 7000000, high_hz: 7040000, mode: "CW", label: "CW" },
|
||||||
|
{ low_hz: 7040000, high_hz: 7200000, mode: "All", label: "All modes" },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// 40m has both bookmarks and allocations; 20m has neither.
|
||||||
|
const BAND_WITH_CONTENT = 7074000;
|
||||||
|
const BAND_WITHOUT_CONTENT = 14074000;
|
||||||
|
|
||||||
|
const fixture = await startWebFixture({
|
||||||
|
spectrum: true,
|
||||||
|
bookmarks: BOOKMARKS,
|
||||||
|
bandplan: BANDPLAN,
|
||||||
|
bandplanEnabled: true,
|
||||||
|
});
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
function readGeometry() {
|
||||||
|
const top = (selector) => {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
return el ? Math.round(el.getBoundingClientRect().top) : null;
|
||||||
|
};
|
||||||
|
const axis = document.getElementById("spectrum-bookmark-axis");
|
||||||
|
const strip = document.getElementById("spectrum-bandplan-strip");
|
||||||
|
return {
|
||||||
|
chips: axis.querySelectorAll(".spectrum-bookmark-chip").length,
|
||||||
|
axisEmpty: axis.classList.contains("bm-axis-empty"),
|
||||||
|
stripReserved: strip.classList.contains("bp-visible"),
|
||||||
|
stripEmpty: strip.classList.contains("bp-empty"),
|
||||||
|
overviewTop: top(".overview-strip"),
|
||||||
|
spectrumTop: top("#spectrum-panel"),
|
||||||
|
controlsTop: top(".controls-row"),
|
||||||
|
footerTop: top(".footer"),
|
||||||
|
docHeight: document.documentElement.scrollHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutOf = (geometry) => ({
|
||||||
|
overviewTop: geometry.overviewTop,
|
||||||
|
spectrumTop: geometry.spectrumTop,
|
||||||
|
controlsTop: geometry.controlsTop,
|
||||||
|
footerTop: geometry.footerTop,
|
||||||
|
docHeight: geometry.docHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function tuneTo(hz) {
|
||||||
|
fixture.setCenterHz(hz);
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
return page.evaluate(readGeometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 950 });
|
||||||
|
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#content").waitFor({ state: "visible" });
|
||||||
|
await page.locator("#spectrum-panel").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const populated = await tuneTo(BAND_WITH_CONTENT);
|
||||||
|
assert.equal(populated.chips, BOOKMARKS.length, `expected both bookmarks, saw ${populated.chips}`);
|
||||||
|
assert.equal(populated.axisEmpty, false, "bookmark rail claims to be empty with chips in it");
|
||||||
|
assert.equal(populated.stripReserved, true, "band plan strip is missing on a band with allocations");
|
||||||
|
assert.equal(populated.stripEmpty, false, "band plan strip claims to be empty with segments in it");
|
||||||
|
|
||||||
|
const bare = await tuneTo(BAND_WITHOUT_CONTENT);
|
||||||
|
assert.equal(bare.chips, 0, `expected no bookmarks on ${BAND_WITHOUT_CONTENT}Hz, saw ${bare.chips}`);
|
||||||
|
assert.equal(bare.axisEmpty, true, "bookmark rail should show its placeholder");
|
||||||
|
assert.equal(bare.stripReserved, true, "band plan strip gave its height back");
|
||||||
|
assert.equal(bare.stripEmpty, true, "band plan strip should be marked empty");
|
||||||
|
|
||||||
|
// The point of both placeholders: tuning must not move anything.
|
||||||
|
assert.deepEqual(layoutOf(bare), layoutOf(populated),
|
||||||
|
`tuning off the band moved the page: ${JSON.stringify(populated)} -> ${JSON.stringify(bare)}`);
|
||||||
|
|
||||||
|
const back = await tuneTo(BAND_WITH_CONTENT);
|
||||||
|
assert.equal(back.chips, BOOKMARKS.length, "bookmarks did not come back");
|
||||||
|
assert.deepEqual(layoutOf(back), layoutOf(populated), "tuning back moved the page");
|
||||||
|
|
||||||
|
// The rail covers the top of the overview, so only the chips may take
|
||||||
|
// pointer events — the rest has to fall through to the plot behind it.
|
||||||
|
const hits = await page.evaluate(() => {
|
||||||
|
const chip = document.querySelector("#spectrum-bookmark-axis .spectrum-bookmark-chip");
|
||||||
|
const chipRect = chip.getBoundingClientRect();
|
||||||
|
const axisRect = document.getElementById("spectrum-bookmark-axis").getBoundingClientRect();
|
||||||
|
const onChip = document.elementFromPoint(chipRect.left + chipRect.width / 2, chipRect.top + chipRect.height / 2);
|
||||||
|
const besideChip = document.elementFromPoint(axisRect.right - 30, axisRect.top + 10);
|
||||||
|
return {
|
||||||
|
chip: onChip?.closest(".spectrum-bookmark-chip") ? "chip" : (onChip?.id || onChip?.tagName),
|
||||||
|
besideChip: besideChip?.id || besideChip?.tagName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(hits.chip, "chip", `chip is not clickable, hit ${hits.chip}`);
|
||||||
|
assert.equal(hits.besideChip, "overview-canvas", `rail swallows events, hit ${hits.besideChip}`);
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// 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"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user