diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index aa545ada..1dee405d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -51,6 +51,10 @@ jobs: run: npm run lint - name: 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 run: npm run verify-generated diff --git a/container/Containerfile b/container/Containerfile index 1e5b62ca..86f4f292 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -23,7 +23,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl xz-utils git sudo pipx \ 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/* # Node.js (JS-based actions need node in PATH under the host executor). diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json index b9470a7a..8b062dd4 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json @@ -13,6 +13,7 @@ "esbuild": "0.25.12", "eslint": "9.39.2", "globals": "16.5.0", + "playwright-core": "1.62.1", "typescript": "5.9.3", "typescript-eslint": "8.51.0" }, @@ -1763,6 +1764,19 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json index 2b8ca171..ccb5e9f0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "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" }, "devDependencies": { @@ -20,6 +21,7 @@ "esbuild": "0.25.12", "eslint": "9.39.2", "globals": "16.5.0", + "playwright-core": "1.62.1", "typescript": "5.9.3", "typescript-eslint": "8.51.0" } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs new file mode 100644 index 00000000..bd242766 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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())); +}