#!/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_EVIDENCE_EXPORT_JSON ?? `${tmpdir()}/browser-speak-evidence-export-smoke.json`, ); const profileDir = resolve( process.env.BROWSER_SPEAK_EVIDENCE_EXPORT_PROFILE_DIR ?? `${tmpdir()}/browser-speak-evidence-export-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"; async function main() { await ensureServer(); await mkdir(dirname(resultPath), { recursive: true }); await rm(profileDir, { recursive: true, force: true }); const browser = launchBrowser(9349, profileDir); let client = null; try { client = await connectToPage(9349, browser); await client.call("Page.enable"); await client.call("Runtime.enable"); await waitForBenchApi(client); const initialExport = await waitForHostMetadata(client); const pageScope = await client.evaluate("location.origin + location.pathname", pollTimeoutMs); const scope = `${pageScope}#${initialExport.hostMetadata?.hfSpaceCommit || "local"}`; const seed = seedStoragePayload(scope, initialExport.hostMetadata); await client.evaluate( `localStorage.setItem("browser-speak:benchmark-results:v1", ${JSON.stringify( JSON.stringify(seed), )})`, pollTimeoutMs, ); await client.call("Page.reload", { ignoreCache: true }); await waitForBenchApi(client); const restoredRows = await waitForValue( client, "window.browserSpeakBench.state().results.length", (value) => value === seed.results.length, 10000, ); await client.evaluate( `(() => { window.__browserSpeakDownloadClicks = []; HTMLAnchorElement.prototype.__browserSpeakOriginalClick ??= HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click = function patchedClick() { window.__browserSpeakDownloadClicks.push({ download: this.download, href: this.href, }); return true; }; return true; })()`, pollTimeoutMs, ); await client.evaluate( `window.browserSpeakBench.downloadResults({ prefix: "browser-speak-evidence", message: "Evidence JSON download smoke." })`, pollTimeoutMs, ); const clicks = await client.evaluate("window.__browserSpeakDownloadClicks", pollTimeoutMs); const exportPayload = await client.evaluate("window.browserSpeakBench.exportResults()", pollTimeoutMs); const exportSelfDescription = validateExportSelfDescription(exportPayload); await client.evaluate("window.browserSpeakBench.clearResults()", pollTimeoutMs); const clearedRows = await client.evaluate("window.browserSpeakBench.state().results.length", pollTimeoutMs); const persistedAfterClear = await client.evaluate( 'localStorage.getItem("browser-speak:benchmark-results:v1")', pollTimeoutMs, ); const download = Array.isArray(clicks) ? clicks.find((click) => /^browser-speak-evidence-.*\.json$/.test(click.download)) : null; const passed = restoredRows === seed.results.length && exportPayload?.results?.length === seed.results.length && exportSelfDescription.passed && Boolean(download) && String(download?.href ?? "").startsWith("blob:") && clearedRows === 0 && persistedAfterClear === null; const payload = { generatedAt: new Date().toISOString(), sourceFingerprint: await sourceFingerprint(), url, passed, config: { chrome, headless, protocolTimeoutMs, pollTimeoutMs, profileDir, extraChromeArgs: parseChromeArgs(), }, summary: { restoredRows, exportedRows: exportPayload?.results?.length ?? null, downloadName: download?.download ?? null, downloadHrefScheme: String(download?.href ?? "").split(":")[0] || null, schemaVersion: exportPayload?.schemaVersion ?? null, exportId: exportPayload?.exportId ?? null, evidenceGuidePresent: Boolean(exportPayload?.evidenceGuide), networkSummaryPresent: Boolean(exportPayload?.network), workerNetworkCapturedEvents: exportPayload?.network?.capturedEvents ?? null, benchmarkWorkerNetworkRequests: exportPayload?.network?.benchmarkRequests ?? null, benchmarkWorkerNetworkSuspects: exportPayload?.network?.benchmarkServerInferenceSuspects ?? null, selfDescription: exportSelfDescription, clearedRows, persistedAfterClear, }, clicks, exportMetadata: { generatedAt: exportPayload?.generatedAt ?? null, schemaVersion: exportPayload?.schemaVersion ?? null, exportId: exportPayload?.exportId ?? null, evidenceGuide: exportPayload?.evidenceGuide ?? null, hostMetadata: exportPayload?.hostMetadata ?? null, evidence: exportPayload?.evidence ?? null, resultCount: exportPayload?.results?.length ?? null, }, }; await writeFile(resultPath, `${JSON.stringify(payload, null, 2)}\n`); console.log(`Wrote evidence export smoke JSON: ${resultPath}`); console.log( passed ? `Evidence export smoke passed: restored ${restoredRows} row(s), download ${download.download}, clear removed saved rows.` : "Evidence export smoke failed.", ); if (!passed) process.exitCode = 1; await client.closeBrowser(); } catch (error) { await writeFailurePayload(error, browser).catch(() => {}); if (client) await client.closeBrowser().catch(() => {}); throw error; } finally { await stopBrowser(browser, profileDir); } } function seedStoragePayload(scope, hostMetadata = {}) { return { version: 1, scope, savedAt: new Date().toISOString(), hostMetadata: { page: hostMetadata.page ?? url, host: hostMetadata.host ?? new URL(url).host, hfSpaceCommit: hostMetadata.hfSpaceCommit ?? "", etag: hostMetadata.etag ?? "", fetchedAt: hostMetadata.fetchedAt ?? new Date().toISOString(), }, results: [ { id: 1, kind: "identity", startedAt: new Date().toISOString(), stack: { device: "wasm", llm: "HuggingFaceTB/SmolLM2-135M-Instruct", voice: "F2", ttsSteps: 2, environment: {}, }, prompt: "What app is this?", output: "This browser demo runs speech recognition, a local LLM, and Supertonic TTS client-side.", llmQualityPass: true, llmQualityScore: 4, llmQualityTotal: 4, error: "", }, ], }; } function validateExportSelfDescription(exportPayload) { const guide = exportPayload?.evidenceGuide ?? {}; const realMic = guide.requirements?.realMic ?? {}; const hardwareWebgpu = guide.requirements?.hardwareWebgpu ?? {}; const errors = []; if (!String(exportPayload?.schemaVersion ?? "").startsWith("browser-speak-benchmarks/")) { errors.push("missing schemaVersion"); } if (!String(exportPayload?.exportId ?? "").startsWith("browser-speak-")) { errors.push("missing exportId"); } if (!String(guide.auditCommand ?? "").includes("audit-browser-evidence.mjs")) { errors.push("missing audit command"); } if (realMic.requiredRows !== 3 || realMic.prompt !== "What app is this?") { errors.push("missing real-mic requirements"); } if (hardwareWebgpu.requiredRows !== 1) { errors.push("missing hardware-WebGPU requirements"); } if (!exportPayload?.network || typeof exportPayload.network.benchmarkServerInferenceSuspects !== "number") { errors.push("missing worker-network summary"); } return { passed: errors.length === 0, errors, }; } async function writeFailurePayload(error, browser = null) { const payload = { generatedAt: new Date().toISOString(), sourceFingerprint: await sourceFingerprint().catch(() => null), url, passed: false, error: error.stack ?? error.message ?? String(error), browserLog: browser?.browserLog ?? "", config: { chrome, headless, protocolTimeoutMs, pollTimeoutMs, profileDir, extraChromeArgs: parseChromeArgs(), }, }; await writeFile(resultPath, `${JSON.stringify(payload, null, 2)}\n`); console.log(`Wrote evidence export smoke failure JSON: ${resultPath}`); } 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", "--no-default-browser-check", "--no-first-run", `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, ...parseChromeArgs(), url, ], { 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 connectToPage(port, browser) { const deadline = Date.now() + 15000; let lastError = ""; while (Date.now() < deadline) { try { const targets = await fetch(`http://127.0.0.1:${port}/json`).then((response) => response.json()); const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl); if (page) return new CdpClient(page.webSocketDebuggerUrl, browser); } catch (error) { lastError = error.message; } await sleep(100); } throw new Error(`Could not connect to Chrome DevTools. ${lastError}`.trim()); } class CdpClient { constructor(wsUrl, browser) { this.ws = new WebSocket(wsUrl); this.browser = browser; this.nextId = 0; this.pending = new Map(); this.ready = new Promise((resolve, reject) => { this.ws.addEventListener("open", resolve, { once: true }); this.ws.addEventListener("error", () => reject(new Error("WebSocket connection failed")), { once: true }); }); this.ws.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (!message.id || !this.pending.has(message.id)) return; const pending = this.pending.get(message.id); this.pending.delete(message.id); clearTimeout(pending.timeout); if (message.error) pending.reject(new Error(message.error.message)); else pending.resolve(message.result); }); } async call(method, params = {}, timeoutMs = protocolTimeoutMs) { await this.ready; const id = ++this.nextId; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method} timed out after ${(timeoutMs / 1000).toFixed(0)} seconds.`)); }, timeoutMs); this.pending.set(id, { resolve, reject, timeout }); }); } async evaluate(expression, timeoutMs = protocolTimeoutMs) { const result = await this.call( "Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, }, timeoutMs, ); if (result.exceptionDetails) { throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); } return result.result.value; } async closeBrowser() { await this.call("Browser.close", {}, 5000).catch(() => {}); this.ws.close(); } } async function waitForBenchApi(client) { await waitForValue( client, "Boolean(window.browserSpeakBench?.downloadResults && window.browserSpeakBench?.clearResults)", Boolean, 15000, ); } async function waitForHostMetadata(client) { const needsHostedCommit = await client.evaluate('location.hostname.endsWith(".hf.space")', pollTimeoutMs); return waitForValue( client, "window.browserSpeakBench.exportResults()", (payload) => { const metadata = payload?.hostMetadata ?? {}; return Boolean(metadata.fetchedAt) && (!needsHostedCommit || String(metadata.hfSpaceCommit ?? "").length >= 7); }, 15000, ); } async function waitForValue(client, expression, predicate, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const value = await client.evaluate(expression, pollTimeoutMs).catch(() => undefined); if (predicate(value)) return value; await sleep(100); } throw new Error(`Timed out waiting for ${expression}`); } 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 < 10; attempt += 1) { try { await rm(profileDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); return; } catch (error) { if (attempt === 9) throw error; await sleep(200); } } } function parseChromeArgs() { return (process.env.BROWSER_SPEAK_CHROME_ARGS ?? "") .split(/\s+/) .map((arg) => arg.trim()) .filter(Boolean); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } main().catch((error) => { console.error(error.stack ?? error.message); process.exitCode = 1; });