test: add frontend browser startup smoke coverage

This commit is contained in:
sjg
2026-08-01 20:19:27 +02:00
parent ee59d8efdd
commit 8aeca613aa
5 changed files with 123 additions and 1 deletions
+4
View File
@@ -51,6 +51,10 @@ jobs:
run: npm run lint run: npm run lint
- name: Test - name: Test
run: npm test run: npm test
- name: Install browser smoke dependency
run: command -v chromium >/dev/null || (sudo apt-get update && sudo apt-get install -y --no-install-recommends chromium)
- name: Browser smoke test
run: npm run test:browser
- name: Verify generated assets - name: Verify generated assets
run: npm run verify-generated run: npm run verify-generated
+1 -1
View File
@@ -23,7 +23,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils git sudo pipx \ ca-certificates curl xz-utils git sudo pipx \
build-essential pkg-config cmake clang libclang-dev \ build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev \ libopus-dev libasound2-dev libsoapysdr-dev chromium \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Node.js (JS-based actions need node in PATH under the host executor). # Node.js (JS-based actions need node in PATH under the host executor).
@@ -13,6 +13,7 @@
"esbuild": "0.25.12", "esbuild": "0.25.12",
"eslint": "9.39.2", "eslint": "9.39.2",
"globals": "16.5.0", "globals": "16.5.0",
"playwright-core": "1.62.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.51.0" "typescript-eslint": "8.51.0"
}, },
@@ -1763,6 +1764,19 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -12,6 +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",
"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": {
@@ -20,6 +21,7 @@
"esbuild": "0.25.12", "esbuild": "0.25.12",
"eslint": "9.39.2", "eslint": "9.39.2",
"globals": "16.5.0", "globals": "16.5.0",
"playwright-core": "1.62.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.51.0" "typescript-eslint": "8.51.0"
} }
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
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";
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", []],
["/rigs", { rigs: [], active_remote: null }],
["/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) {
if (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 (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.message));
try {
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "networkidle" });
await page.locator("#content").waitFor({ state: "visible" });
assert.equal(await page.locator("#auth-gate").isVisible(), false);
assert.equal(await page.locator("#tab-main").isVisible(), true);
await page.locator('.tab[data-tab="about"]').click();
await page.locator("#tab-about").waitFor({ state: "visible" });
assert.equal(new URL(page.url()).pathname, "/about");
await page.goBack();
await page.locator("#tab-main").waitFor({ state: "visible" });
assert.equal(new URL(page.url()).pathname, "/");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}