Spaces:
Configuration error
Configuration error
| import { spawn } from "node:child_process"; | |
| import { readFile, writeFile, mkdir } from "node:fs/promises"; | |
| import { tmpdir } from "node:os"; | |
| import { dirname, resolve } from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| import { sourceFingerprint } from "./source-fingerprint.mjs"; | |
| const scriptDir = dirname(fileURLToPath(import.meta.url)); | |
| const resultPath = resolve(process.env.BROWSER_SPEAK_FINAL_JSON ?? `${tmpdir()}/browser-speak-final-validation.json`); | |
| const auditPath = resolve(process.env.BROWSER_SPEAK_AUDIT_JSON ?? `${tmpdir()}/browser-speak-validation-audit.json`); | |
| const soft = process.env.BROWSER_SPEAK_FINAL_SOFT === "true"; | |
| const skipLocal = process.env.BROWSER_SPEAK_FINAL_SKIP_LOCAL === "true"; | |
| const skipUi = skipLocal || process.env.BROWSER_SPEAK_FINAL_SKIP_UI === "true"; | |
| const skipEvidenceExport = skipLocal || process.env.BROWSER_SPEAK_FINAL_SKIP_EVIDENCE_EXPORT === "true"; | |
| const skipClientSide = skipLocal || process.env.BROWSER_SPEAK_FINAL_SKIP_CLIENT_SIDE === "true"; | |
| const skipLoopback = skipLocal || process.env.BROWSER_SPEAK_FINAL_SKIP_LOOPBACK === "true"; | |
| const skipWebgpu = process.env.BROWSER_SPEAK_FINAL_SKIP_WEBGPU === "true"; | |
| const realMicMode = parseRealMicMode(process.env.BROWSER_SPEAK_FINAL_REAL_MIC ?? "skip"); | |
| const envDefaults = { | |
| BROWSER_SPEAK_CLIENT_SIDE_REUSE_PROFILE: "true", | |
| BROWSER_SPEAK_LOCAL_REUSE_PROFILE: "true", | |
| BROWSER_SPEAK_LOAD_TIMEOUT_MS: "900000", | |
| BROWSER_SPEAK_TASK_TIMEOUT_MS: "240000", | |
| BROWSER_SPEAK_VOICE_PRELOAD_TIMEOUT_MS: "240000", | |
| }; | |
| const steps = [ | |
| { | |
| name: "UI smoke", | |
| script: "run-ui-smoke.mjs", | |
| artifactEnv: "BROWSER_SPEAK_UI_JSON", | |
| defaultArtifact: `${tmpdir()}/browser-speak-ui-smoke.json`, | |
| skipped: skipUi, | |
| skipReason: "disabled by BROWSER_SPEAK_FINAL_SKIP_UI or BROWSER_SPEAK_FINAL_SKIP_LOCAL", | |
| }, | |
| { | |
| name: "evidence export smoke", | |
| script: "run-evidence-export-smoke.mjs", | |
| artifactEnv: "BROWSER_SPEAK_EVIDENCE_EXPORT_JSON", | |
| defaultArtifact: `${tmpdir()}/browser-speak-evidence-export-smoke.json`, | |
| skipped: skipEvidenceExport, | |
| skipReason: "disabled by BROWSER_SPEAK_FINAL_SKIP_EVIDENCE_EXPORT or BROWSER_SPEAK_FINAL_SKIP_LOCAL", | |
| }, | |
| { | |
| name: "client-side/no-server smoke", | |
| script: "run-client-side-smoke.mjs", | |
| artifactEnv: "BROWSER_SPEAK_CLIENT_SIDE_JSON", | |
| defaultArtifact: `${tmpdir()}/browser-speak-client-side-smoke.json`, | |
| skipped: skipClientSide, | |
| skipReason: "disabled by BROWSER_SPEAK_FINAL_SKIP_CLIENT_SIDE or BROWSER_SPEAK_FINAL_SKIP_LOCAL", | |
| }, | |
| { | |
| name: "loopback stability", | |
| script: "run-loopback-series.mjs", | |
| artifactEnv: "BROWSER_SPEAK_LOOPBACK_JSON", | |
| defaultArtifact: `${tmpdir()}/browser-speak-loopback-series.json`, | |
| skipped: skipLoopback, | |
| skipReason: "disabled by BROWSER_SPEAK_FINAL_SKIP_LOOPBACK or BROWSER_SPEAK_FINAL_SKIP_LOCAL", | |
| }, | |
| { | |
| name: "hardware WebGPU benchmark", | |
| script: "run-webgpu-benchmark.mjs", | |
| artifactEnv: "BROWSER_SPEAK_WEBGPU_JSON", | |
| defaultArtifact: `${tmpdir()}/browser-speak-webgpu-results.json`, | |
| skipped: skipWebgpu, | |
| skipReason: "disabled by BROWSER_SPEAK_FINAL_SKIP_WEBGPU", | |
| }, | |
| { | |
| name: realMicMode === "dry-run" ? "real microphone preflight" : "real microphone validation", | |
| script: "run-real-mic-series.mjs", | |
| artifactEnv: "BROWSER_SPEAK_REAL_MIC_JSON", | |
| defaultArtifact: | |
| realMicMode === "dry-run" | |
| ? `${tmpdir()}/browser-speak-real-mic-series-dry-run.json` | |
| : `${tmpdir()}/browser-speak-real-mic-series.json`, | |
| skipped: realMicMode === "skip", | |
| skipReason: "set BROWSER_SPEAK_FINAL_REAL_MIC=true to collect rows, or dry-run for preflight only", | |
| env: realMicMode === "dry-run" ? { BROWSER_SPEAK_REAL_MIC_DRY_RUN: "true" } : {}, | |
| }, | |
| { | |
| name: "validation audit", | |
| script: "audit-validation.mjs", | |
| artifactEnv: "BROWSER_SPEAK_AUDIT_JSON", | |
| defaultArtifact: `${tmpdir()}/browser-speak-validation-audit.json`, | |
| env: soft ? { BROWSER_SPEAK_AUDIT_SOFT: "true" } : {}, | |
| }, | |
| ]; | |
| const commandResults = []; | |
| async function main() { | |
| await mkdir(dirname(resultPath), { recursive: true }); | |
| await writeSummary({ status: "running" }); | |
| for (const step of steps) { | |
| const result = await runStep(step); | |
| commandResults.push(result); | |
| await writeSummary({ status: "running" }); | |
| } | |
| const audit = await readJson(auditPath); | |
| const commandFailures = commandResults.filter((result) => result.commandStatus === "fail"); | |
| const auditPassed = audit?.passed === true; | |
| await writeSummary({ | |
| status: "complete", | |
| audit, | |
| commandFailures, | |
| auditPassed, | |
| }); | |
| console.log(`Wrote final validation JSON: ${resultPath}`); | |
| printFinalSummary(audit, commandFailures); | |
| if (commandFailures.length > 0 || (!soft && !auditPassed)) process.exitCode = 1; | |
| } | |
| async function runStep(step) { | |
| const artifactPath = resolve(process.env[step.artifactEnv] ?? step.defaultArtifact); | |
| const command = `${process.execPath} tools/${step.script}`; | |
| if (step.skipped) { | |
| console.log(`Skipping ${step.name}: ${step.skipReason}.`); | |
| return { | |
| name: step.name, | |
| status: "skip", | |
| commandStatus: "skip", | |
| evidenceStatus: "skip", | |
| command, | |
| artifactPath, | |
| reason: step.skipReason, | |
| durationMs: 0, | |
| }; | |
| } | |
| console.log(`Running ${step.name}: ${command}`); | |
| const startedAt = Date.now(); | |
| const exitCode = await runChild(process.execPath, [resolve(scriptDir, step.script)], envWithDefaults(step.env)); | |
| const durationMs = Date.now() - startedAt; | |
| const commandStatus = exitCode === 0 ? "pass" : "fail"; | |
| const evidence = await evidenceForStep(step, artifactPath); | |
| console.log(formatStepResult(step.name, commandStatus, evidence?.status, durationMs)); | |
| return { | |
| name: step.name, | |
| status: evidence?.status ?? commandStatus, | |
| commandStatus, | |
| evidenceStatus: evidence?.status ?? null, | |
| command, | |
| artifactPath, | |
| exitCode, | |
| durationMs, | |
| evidence, | |
| }; | |
| } | |
| function runChild(command, args, env) { | |
| return new Promise((resolvePromise) => { | |
| const child = spawn(command, args, { env, stdio: "inherit" }); | |
| child.on("error", (error) => { | |
| console.error(`${command} failed to start: ${error.message}`); | |
| resolvePromise(1); | |
| }); | |
| child.on("exit", (code) => resolvePromise(code ?? 1)); | |
| }); | |
| } | |
| function envWithDefaults(overrides = {}) { | |
| const env = { ...process.env }; | |
| for (const [key, value] of Object.entries(envDefaults)) { | |
| if (!(key in env)) env[key] = value; | |
| } | |
| return { ...env, ...overrides }; | |
| } | |
| async function writeSummary({ status, audit = null, commandFailures = [], auditPassed = null } = {}) { | |
| const currentAudit = audit ?? (await readJson(auditPath)); | |
| const payload = { | |
| generatedAt: new Date().toISOString(), | |
| sourceFingerprint: await sourceFingerprint(), | |
| status, | |
| passed: currentAudit?.passed === true, | |
| completed: status === "complete" && commandFailures.length === 0, | |
| soft, | |
| config: { | |
| resultPath, | |
| auditPath, | |
| skipLocal, | |
| skipUi, | |
| skipEvidenceExport, | |
| skipClientSide, | |
| skipLoopback, | |
| skipWebgpu, | |
| realMicMode, | |
| envDefaults, | |
| }, | |
| commands: commandResults, | |
| audit: summarizeAudit(currentAudit), | |
| }; | |
| if (auditPassed !== null) payload.auditPassed = auditPassed; | |
| if (commandFailures.length > 0) payload.commandFailures = commandFailures; | |
| await writeFile(resultPath, `${JSON.stringify(payload, null, 2)}\n`); | |
| } | |
| function summarizeAudit(audit) { | |
| if (!audit || typeof audit !== "object") return null; | |
| const checks = Array.isArray(audit.checks) ? audit.checks : []; | |
| return { | |
| generatedAt: audit.generatedAt ?? null, | |
| passed: audit.passed === true, | |
| required: checks.filter((check) => check.required).map(summarizeCheck), | |
| supporting: checks.filter((check) => !check.required).map(summarizeCheck), | |
| nextActions: Array.isArray(audit.nextActions) ? audit.nextActions : [], | |
| }; | |
| } | |
| function summarizeCheck(check) { | |
| return { | |
| name: check.name, | |
| status: check.status, | |
| message: check.message, | |
| }; | |
| } | |
| async function readJson(path) { | |
| try { | |
| return JSON.parse(await readFile(path, "utf8")); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| async function evidenceForStep(step, artifactPath) { | |
| const artifact = await readJson(artifactPath); | |
| if (!artifact) return { status: "missing", message: "Artifact JSON was not readable after the step." }; | |
| if (step.script === "audit-validation.mjs") { | |
| return { | |
| status: artifact.passed === true ? "pass" : "fail", | |
| message: artifact.passed === true ? "Final audit passed." : "Final audit did not pass all required evidence gates.", | |
| }; | |
| } | |
| if (step.script === "run-webgpu-benchmark.mjs") { | |
| if (artifact.skipped) { | |
| return { | |
| status: "missing", | |
| message: artifact.reason ?? "WebGPU benchmark was skipped.", | |
| }; | |
| } | |
| const completed = Array.isArray(artifact.candidates) | |
| ? artifact.candidates.filter((candidate) => candidate.status === "complete").length | |
| : 0; | |
| return { | |
| status: artifact.webgpu?.available === true && artifact.webgpu?.softwareAdapter !== true && completed > 0 ? "pass" : "missing", | |
| message: completed > 0 ? `${completed} hardware WebGPU candidate(s) completed.` : "No hardware WebGPU candidate completed.", | |
| }; | |
| } | |
| if (step.script === "run-real-mic-series.mjs") { | |
| if (artifact.dryRun) return { status: "preflight", message: "Real-mic dry run completed; no human speech rows collected." }; | |
| return { | |
| status: artifact.passed === true ? "pass" : "fail", | |
| message: artifact.passed === true ? "Real microphone rows passed." : "Real microphone rows are missing or below threshold.", | |
| }; | |
| } | |
| if ("passed" in artifact) { | |
| return { | |
| status: artifact.passed === true ? "pass" : "fail", | |
| message: artifact.passed === true ? "Step artifact reports passed." : "Step artifact reports failure.", | |
| }; | |
| } | |
| return { status: "unknown", message: "Step artifact did not expose a passed flag." }; | |
| } | |
| function parseRealMicMode(value) { | |
| const normalized = String(value).trim().toLowerCase(); | |
| if (["1", "true", "yes", "real", "run"].includes(normalized)) return "real"; | |
| if (["dry-run", "dryrun", "preflight"].includes(normalized)) return "dry-run"; | |
| return "skip"; | |
| } | |
| function printFinalSummary(audit, commandFailures) { | |
| if (commandFailures.length > 0) { | |
| console.log(`Command failures: ${commandFailures.map((result) => result.name).join(", ")}`); | |
| } | |
| if (!audit) { | |
| console.log("Validation audit JSON was not available."); | |
| return; | |
| } | |
| const required = Array.isArray(audit.checks) ? audit.checks.filter((check) => check.required) : []; | |
| console.log(`Final audit: ${audit.passed ? "pass" : "fail"}`); | |
| for (const check of required) { | |
| console.log(`${check.status}: ${check.name} - ${check.message}`); | |
| } | |
| if (!audit.passed && soft) console.log("Soft mode is enabled, so missing external gates do not fail this runner."); | |
| } | |
| function formatStepResult(name, commandStatus, evidenceStatus, durationMs) { | |
| const evidenceSuffix = evidenceStatus && evidenceStatus !== commandStatus ? `, evidence ${evidenceStatus}` : ""; | |
| return `${name}: command ${commandStatus}${evidenceSuffix} (${formatDuration(durationMs)})`; | |
| } | |
| function formatDuration(ms) { | |
| if (ms < 1000) return `${ms} ms`; | |
| return `${(ms / 1000).toFixed(1)} s`; | |
| } | |
| main().catch((error) => { | |
| console.error(error.stack ?? error.message); | |
| process.exitCode = 1; | |
| }); | |