Spaces:
Configuration error
Configuration error
| 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; | |
| }); | |