browser-speak / tools /run-ui-smoke.mjs
Mike0021's picture
Add worker network telemetry to browser evidence
d2ae80e verified
Raw
History Blame Contribute Delete
17.2 kB
#!/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;
});