#!/usr/bin/env node import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, resolve } from "node:path"; import { sourceFingerprint } from "./source-fingerprint.mjs"; const url = process.env.BROWSER_SPEAK_URL ?? "http://127.0.0.1:5174/"; const chrome = process.env.CHROME_BIN ?? (existsSync("/opt/google/chrome/chrome") ? "/opt/google/chrome/chrome" : existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "chromium"); const resultPath = resolve(process.env.BROWSER_SPEAK_UI_JSON ?? `${tmpdir()}/browser-speak-ui-smoke.json`); const screenshotDir = resolve(process.env.BROWSER_SPEAK_UI_SCREENSHOT_DIR ?? `${tmpdir()}/browser-speak-ui-smoke`); const profileDir = resolve(process.env.BROWSER_SPEAK_UI_PROFILE_DIR ?? `${tmpdir()}/browser-speak-ui-profile`); const protocolTimeoutMs = Number(process.env.BROWSER_SPEAK_CDP_TIMEOUT_MS ?? 60000); const pollTimeoutMs = Number(process.env.BROWSER_SPEAK_CDP_POLL_TIMEOUT_MS ?? 5000); const headless = process.env.BROWSER_SPEAK_HEADLESS !== "false"; const viewports = parseViewports( process.env.BROWSER_SPEAK_UI_VIEWPORTS ?? "desktop:1440x1200,mobile:390x1100", ); async function main() { await ensureServer(); await mkdir(dirname(resultPath), { recursive: true }); await mkdir(screenshotDir, { recursive: true }); await rm(profileDir, { recursive: true, force: true }); const browser = launchBrowser(9345, profileDir); const results = []; try { const client = await connectToPage(9345, browser); await client.call("Page.enable"); await client.call("Runtime.enable"); for (const viewport of viewports) { console.log(`Checking UI viewport: ${viewport.name} ${viewport.width}x${viewport.height}`); await client.call("Emulation.setDeviceMetricsOverride", { width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.deviceScaleFactor, mobile: viewport.mobile, }); await client.call("Page.navigate", { url }); await waitForUi(client); const audit = await client.evaluate(`(${auditUi.toString()})()`, pollTimeoutMs); const screenshotPath = resolve(screenshotDir, `${viewport.name}.png`); const screenshot = await client.call("Page.captureScreenshot", { format: "png", captureBeyondViewport: false, }); await writeFile(screenshotPath, Buffer.from(screenshot.data, "base64")); const errors = auditErrors(audit); results.push({ ...viewport, screenshotPath, ...audit, status: errors.length === 0 ? "pass" : "fail", errors, }); } const payload = { generatedAt: new Date().toISOString(), sourceFingerprint: await sourceFingerprint(), url, config: { chrome, headless, protocolTimeoutMs, pollTimeoutMs, screenshotDir, viewports, extraChromeArgs: parseChromeArgs(), }, results, passed: results.every((result) => result.status === "pass"), }; await writeFile(resultPath, `${JSON.stringify(payload, null, 2)}\n`); console.log(`Wrote UI smoke JSON: ${resultPath}`); console.log(`Wrote screenshots: ${screenshotDir}`); for (const result of results) { const suffix = result.errors.length > 0 ? ` (${result.errors.join("; ")})` : ""; console.log(`${result.name}: ${result.status}${suffix}`); } if (!payload.passed) process.exitCode = 1; await client.closeBrowser(); } finally { await stopBrowser(browser, profileDir); } } function auditUi() { const byId = (id) => document.getElementById(id); const required = [ "loadButton", "micButton", "stopButton", "deviceSelect", "runtimeStatus", "runtimeDeviceStatus", "runtimeDeviceDetail", "runtimeBuildStatus", "micValidationCard", "micValidationStatus", "micValidationDetail", "micValidationProgressBar", "gpuValidationCard", "gpuValidationStatus", "gpuValidationDetail", "gpuValidationProgressBar", "gpuBenchmarkButton", "evidenceCaptureButton", "partialTranscript", "finalTranscript", "llmOutput", "benchmarkSummary", "resultsBody", ]; const missing = required.filter((id) => !byId(id)); const runtime = { state: byId("runtimeStatus")?.dataset.state ?? "", label: byId("runtimeDeviceStatus")?.textContent?.trim() ?? "", detail: byId("runtimeDeviceDetail")?.textContent?.trim() ?? "", build: byId("runtimeBuildStatus")?.textContent?.trim() ?? "", }; const micValidation = { state: byId("micValidationCard")?.dataset.state ?? "", status: byId("micValidationStatus")?.textContent?.trim() ?? "", detail: byId("micValidationDetail")?.textContent?.trim() ?? "", progress: byId("micValidationProgressBar")?.style.width ?? "", }; const gpuValidation = { state: byId("gpuValidationCard")?.dataset.state ?? "", status: byId("gpuValidationStatus")?.textContent?.trim() ?? "", detail: byId("gpuValidationDetail")?.textContent?.trim() ?? "", progress: byId("gpuValidationProgressBar")?.style.width ?? "", }; const selectors = [ "button", "select", ".status-tile", ".runtime-status", ".build-status", ".mic-validation", ".badge", ".summary-item", ".settings label", ".metrics dl div", ]; const inspected = Array.from(document.querySelectorAll(selectors.join(","))).filter( (element) => !element.closest(".table-wrap"), ); const overflows = inspected .map((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); const text = element.textContent?.replace(/\s+/g, " ").trim() ?? ""; const allowsHorizontalScroll = ["auto", "scroll", "hidden", "clip"].includes(style.overflowX); return { selector: element.id ? `#${element.id}` : element.className ? `.${String(element.className).split(/\s+/)[0]}` : element.tagName.toLowerCase(), text: text.slice(0, 80), left: Math.round(rect.left), right: Math.round(rect.right), width: Math.round(rect.width), scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, offscreen: rect.left < -1 || rect.right > window.innerWidth + 1, clippedText: element.scrollWidth > element.clientWidth + 3 && !allowsHorizontalScroll, }; }) .filter((item) => item.offscreen || item.clippedText); const tiles = Array.from(document.querySelectorAll(".status-tile")).map((tile) => ({ id: tile.id, state: tile.dataset.state, text: tile.textContent?.replace(/\s+/g, " ").trim() ?? "", })); const requiredApiMethods = [ "state", "exportResults", "downloadResults", "clearResults", "loadStack", "unload", "runSuite", "runEvidenceCapture", "runMic", "runMicSeries", "runWebGpuEvidence", "webgpuInfo", ]; return { title: document.title, viewport: { width: window.innerWidth, height: window.innerHeight, documentWidth: document.documentElement.scrollWidth, documentHeight: document.documentElement.scrollHeight, }, automationApi: { installed: Boolean(window.browserSpeakBench), version: window.browserSpeakBench?.version ?? null, missingMethods: requiredApiMethods.filter((method) => typeof window.browserSpeakBench?.[method] !== "function"), }, controls: { loadButtonDisabled: byId("loadButton")?.disabled ?? null, micButtonDisabled: byId("micButton")?.disabled ?? null, stopButtonDisabled: byId("stopButton")?.disabled ?? null, gpuBenchmarkButtonDisabled: byId("gpuBenchmarkButton")?.disabled ?? null, evidenceCaptureButtonDisabled: byId("evidenceCaptureButton")?.disabled ?? null, deviceValue: byId("deviceSelect")?.value ?? "", }, runtime, micValidation, gpuValidation, tiles, missing, bodyHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 1, visibleOverflow: overflows, benchmarkEmptyText: byId("benchmarkSummary")?.textContent?.replace(/\s+/g, " ").trim() ?? "", }; } function auditErrors(audit) { const errors = []; if (audit.title !== "Local Voice LLM") errors.push(`unexpected title: ${audit.title}`); if (!audit.automationApi.installed || audit.automationApi.version !== 1) errors.push("automation API missing"); if (audit.automationApi.missingMethods?.length > 0) { errors.push(`automation API missing methods: ${audit.automationApi.missingMethods.join(", ")}`); } if (audit.missing.length > 0) errors.push(`missing elements: ${audit.missing.join(", ")}`); if (audit.tiles.length !== 4) errors.push(`expected 4 status tiles, found ${audit.tiles.length}`); if (audit.runtime.state === "checking" || !audit.runtime.label) errors.push("runtime status did not settle"); if (!audit.runtime.build || /pending/i.test(audit.runtime.build)) errors.push("runtime build status did not settle"); if (!/real-mic rows|models/i.test(audit.micValidation.status)) errors.push("real-mic validation status missing"); if (!/What app is this|Load a stack/i.test(audit.micValidation.detail)) errors.push("real-mic prompt detail missing"); if (!/WebGPU|adapter/i.test(audit.gpuValidation.status)) errors.push("hardware WebGPU validation status missing"); if (!/adapter|WebGPU|Hardware/i.test(audit.gpuValidation.detail)) errors.push("hardware WebGPU detail missing"); if (/Software WebGPU|No WebGPU/i.test(audit.gpuValidation.status) && audit.controls.gpuBenchmarkButtonDisabled !== true) { errors.push("WebGPU evidence button should be disabled without hardware WebGPU"); } if (audit.bodyHorizontalOverflow) errors.push("body has horizontal overflow"); if (audit.visibleOverflow.length > 0) { errors.push( `visible element overflow: ${audit.visibleOverflow .slice(0, 4) .map((item) => `${item.selector} ${item.text}`) .join(" | ")}`, ); } if (!/No benchmark runs yet/.test(audit.benchmarkEmptyText)) errors.push("benchmark empty state missing"); return errors; } async function waitForUi(client) { const deadline = Date.now() + 15000; while (Date.now() < deadline) { const ready = await client .evaluate(`(() => { const runtime = document.getElementById("runtimeStatus"); const build = document.getElementById("runtimeBuildStatus"); return document.readyState === "complete" && Boolean(window.browserSpeakBench?.version) && runtime && runtime.dataset.state !== "checking" && build && !/pending/i.test(build.textContent || ""); })()`, pollTimeoutMs) .catch(() => false); if (ready) return; await sleep(100); } throw new Error("UI did not become ready."); } async function ensureServer() { const response = await fetch(url).catch((error) => { throw new Error(`Could not reach ${url}: ${error.message}`); }); if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); } function launchBrowser(port, profileDir) { const child = spawn( chrome, [ ...(headless ? ["--headless=new"] : []), "--no-sandbox", "--disable-dev-shm-usage", "--disable-background-networking", "--disable-extensions", "--no-default-browser-check", "--no-first-run", "--autoplay-policy=no-user-gesture-required", "--enable-unsafe-webgpu", "--ignore-gpu-blocklist", `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, ...parseChromeArgs(), ], { stdio: ["ignore", "pipe", "pipe"] }, ); child.browserLog = ""; const appendLog = (chunk) => { child.browserLog = `${child.browserLog}${chunk}`; if (child.browserLog.length > 8000) child.browserLog = child.browserLog.slice(-8000); }; child.stdout.on("data", appendLog); child.stderr.on("data", appendLog); return child; } async function stopBrowser(child, profileDir) { if (child.exitCode == null) child.kill("SIGTERM"); await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 3000); }); if (child.exitCode == null) child.kill("SIGKILL"); for (let attempt = 0; attempt < 5; attempt += 1) { try { await rm(profileDir, { recursive: true, force: true }); return; } catch (error) { if (attempt === 4) { console.warn(`Could not remove ${profileDir}: ${error.message}`); return; } await sleep(500); } } } async function connectToPage(port, child) { const deadline = Date.now() + 60000; let lastError = null; while (Date.now() < deadline) { if (child.exitCode != null) { throw new Error(`Chrome exited before DevTools became available.\n${child.browserLog}`); } try { const version = await fetch(`http://127.0.0.1:${port}/json/version`).then((response) => response.json()); if (version.webSocketDebuggerUrl) { const page = await createPageTarget(port); return new CdpClient(page.webSocketDebuggerUrl); } } catch (error) { lastError = error; } await sleep(250); } throw new Error( `Could not connect to Chrome DevTools on port ${port}: ${lastError?.message ?? "unknown error"}\n${child.browserLog}`, ); } async function createPageTarget(port) { for (const method of ["PUT", "GET"]) { const response = await fetch(`http://127.0.0.1:${port}/json/new?about%3Ablank`, { method }).catch(() => null); if (response?.ok) { const target = await response.json(); if (target.webSocketDebuggerUrl) return target; } } const targets = await fetch(`http://127.0.0.1:${port}/json`).then((response) => response.json()); const page = targets.find((target) => target.type === "page"); if (page?.webSocketDebuggerUrl) return page; throw new Error("Could not create or find a page target."); } class CdpClient { constructor(webSocketUrl) { this.nextId = 1; this.pending = new Map(); this.socket = new WebSocket(webSocketUrl); this.opened = new Promise((resolve, reject) => { this.socket.onopen = resolve; this.socket.onerror = reject; this.socket.onmessage = (event) => this.onMessage(event); }); } onMessage(event) { const message = JSON.parse(event.data); if (!message.id || !this.pending.has(message.id)) return; const { resolve: onResolve, reject } = this.pending.get(message.id); this.pending.delete(message.id); if (message.error) reject(new Error(message.error.message)); else onResolve(message.result); } async call(method, params = {}, timeoutMs = protocolTimeoutMs) { await this.opened; const id = this.nextId++; this.socket.send(JSON.stringify({ id, method, params })); return new Promise((resolvePromise, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method} timed out after ${(timeoutMs / 1000).toFixed(0)} seconds.`)); }, timeoutMs); this.pending.set(id, { resolve: (value) => { clearTimeout(timer); resolvePromise(value); }, reject: (error) => { clearTimeout(timer); reject(error); }, }); }); } async evaluate(expression, timeoutMs = protocolTimeoutMs) { const result = await this.call( "Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, }, timeoutMs, ); if (result.exceptionDetails) throw new Error(formatException(result.exceptionDetails)); return result.result.value; } async closeBrowser() { await this.call("Browser.close", {}, 5000).catch(() => {}); try { this.socket.close(); } catch { // Ignore close races. } } } function formatException(exceptionDetails) { const exception = exceptionDetails.exception; return exception?.description ?? exception?.value ?? exceptionDetails.text ?? "Evaluation failed."; } function parseViewports(value) { return String(value ?? "") .split(",") .map((entry) => { const [namePart, sizePart] = entry.split(":"); const [width, height] = String(sizePart ?? "").split("x").map((part) => Number(part)); if (!namePart || !Number.isFinite(width) || !Number.isFinite(height)) { throw new Error(`Invalid viewport entry: ${entry}`); } return { name: namePart.trim(), width, height, mobile: width < 600, deviceScaleFactor: width < 600 ? 2 : 1, }; }); } function parseChromeArgs() { const raw = process.env.BROWSER_SPEAK_CHROME_ARGS ?? ""; return ( raw .match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?.map((arg) => arg.replace(/^["']|["']$/g, "")) ?? [] ); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } main().catch((error) => { console.error(error.stack ?? error.message); process.exitCode = 1; });