import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { spawn } from "node:child_process"; const root = resolve(fileURLToPath(new URL("../../..", import.meta.url))); const appHtml = resolve(root, "kdl-wasm/web/app/virtual-controller.html"); const outputDir = resolve(root, "kdl-wasm/web/test-results/virtual-controller"); const chromeBin = process.env.CHROME_BIN ?? defaultChromeBin(); const targetUrl = process.argv[2] ?? pathToFileURL(appHtml).href; const isRemote = /^https?:\/\//i.test(targetUrl); mkdirSync(outputDir, { recursive: true }); const evidence = await verifyButtonFlow(targetUrl); const evidencePath = join(outputDir, isRemote ? "button-evidence-public.json" : "button-evidence.json"); writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); console.log(JSON.stringify(evidence, null, 2)); async function verifyButtonFlow(url) { const browser = await launchChrome("buttons"); try { const client = await connect(browser.websocketUrl); await client.send("Page.enable"); await client.send("Runtime.enable"); await client.send("Network.enable"); await client.send("Emulation.setDeviceMetricsOverride", { width: 1440, height: 960, deviceScaleFactor: 1, mobile: false }); await client.send("Page.navigate", { url }); await waitForLoad(client); const initial = await waitForProbe( client, (probe) => probe.title === "ABB120 Virtual Controller" && probe.frameTotal > 0 && probe.samplePeriodSec === 0.05 && probe.stepRows > 0 && probe.activeStepRows === 1 && (!isRemote || probe.programOptions.length >= 10), "initial controller render", 12_000 ); if (isRemote && initial.programOptions.length < 10) { throw new Error(`remote manifest did not populate enough programs: ${initial.programOptions.length}`); } await clickCommand(client, "load"); const afterLoadClick = await sleepAndProbe(client, 120); if (!["load", "loading", "parsing", "ready"].includes(afterLoadClick.state)) { throw new Error(`Load did not show an active state, got ${afterLoadClick.state}`); } if (!afterLoadClick.log.includes(afterLoadClick.program)) { throw new Error(`Load did not write the active program to the log: ${afterLoadClick.log}`); } const afterParse = await waitForProbe( client, (probe) => probe.state === "ready" && probe.parser.includes("parsed") && probe.parserProgress === 1 && probe.stepRows > 1 && probe.frameRows === probe.sourceFrameTotal, "Load parse stream", 4000 ); if (afterLoadClick.state === "parsing") { if (!afterLoadClick.parser.includes("parsing")) { throw new Error(`Load did not expose parser progress: ${afterLoadClick.parser}`); } if (afterLoadClick.frameRows >= afterParse.frameRows && afterParse.frameRows > 1) { throw new Error(`Trajectory did not grow during parsing: ${afterLoadClick.frameRows} -> ${afterParse.frameRows}`); } } await clickCommand(client, "run"); const afterRun = await waitForProbe( client, (probe) => probe.state === "running" && probe.running, "Run command", 1200 ); if (!afterRun.wait.includes("waiting") && !afterRun.wait.includes("satisfied")) { throw new Error(`Run did not update wait state: ${afterRun.wait}`); } assertProgramSteps(afterRun, "Run command"); await sleep(700); await clickCommand(client, "pause"); const afterPause = await waitForProbe(client, (probe) => probe.state === "paused", "Pause command", 1000); assertProgramSteps(afterPause, "Pause command"); await clickCommand(client, "step"); const afterStep = await waitForProbe(client, (probe) => probe.state === "paused", "Step command", 1000); const expectedStepFrame = afterPause.frameCurrent < afterPause.frameTotal ? afterPause.frameCurrent + 1 : afterPause.frameCurrent; if (afterStep.frameCurrent !== expectedStepFrame) { throw new Error(`Step sample mismatch: expected ${expectedStepFrame}, got ${afterStep.frameCurrent}`); } assertProgramSteps(afterStep, "Step command"); await clickCommand(client, "stop"); const afterStop = await waitForProbe(client, (probe) => probe.state === "stopped", "Stop command", 1000); await clickCommand(client, "reset"); const afterReset = await waitForProbe( client, (probe) => probe.state === "ready" && probe.frameCurrent === 1, "Reset command", 1000 ); await clickCommand(client, "export"); const afterExport = await sleepAndProbe(client, 150); if (!afterExport.log.includes("exported") && !afterExport.reports.includes("exported")) { throw new Error(`Export did not update log/report fields: ${afterExport.log} / ${afterExport.reports}`); } const fullSpecId = afterExport.programOptions.find((id) => id.startsWith("W2_99_")); let afterProgramSwitch = afterExport; if (fullSpecId) { await selectProgram(client, fullSpecId); afterProgramSwitch = await waitForProbe( client, (probe) => probe.program === fullSpecId && probe.state === "ready" && probe.sourceFrameTotal >= 20 && probe.frameTotal >= 39 && probe.stepRows >= 8 && probe.activeStepRows === 1, `${fullSpecId} program switch`, isRemote ? 12_000 : 4_000 ); assertTcpAndJoints(afterProgramSwitch, fullSpecId); } const afterCacheWarm = await waitForProbe( client, (probe) => !isRemote || probe.storageState.writes >= 3, "OPFS cache warmup", isRemote ? 12_000 : 1500 ); const offlineCache = await verifyOfflineCache(client, afterCacheWarm); await client.close(); return { targetUrl: url, chrome: chromeBin, initial, afterLoadClick, afterRun, afterPause, afterStep, afterStop, afterReset, afterExport, afterProgramSwitch, offlineCache }; } finally { browser.close(); } } async function verifyOfflineCache(client, beforeOffline) { if (!isRemote) { return { skipped: true, reason: "local file target does not require network-offline cache proof" }; } const cacheProgram = beforeOffline.program ?? beforeOffline.programOptions.find((id) => id === "W2_60_IOWaitPulse") ?? beforeOffline.programOptions[0]; if (!cacheProgram) throw new Error("No program available for offline cache check"); await evaluate(client, `() => { const app = window.__virtualController; app.programCache.clear(); app.programLoads.clear(); app.assetTextCache.clear(); return true; }`); await client.send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 }); await selectProgram(client, cacheProgram); const offline = await waitForProbe( client, (probe) => probe.program === cacheProgram && probe.state === "ready" && probe.sourceFrameTotal >= 4 && probe.storageState.hits > beforeOffline.storageState.hits, "offline OPFS program load", 5000 ); if (offline.storageState.mode !== "OPFS") { throw new Error(`Expected OPFS mode during offline load, got ${offline.storageState.mode}`); } assertTcpAndJoints(offline, cacheProgram); return { program: cacheProgram, mode: offline.storageState.mode, hitsBefore: beforeOffline.storageState.hits, hitsAfter: offline.storageState.hits, frame: offline.frame, storage: offline.storage }; } async function clickCommand(client, command) { return evaluate(client, `() => { const button = document.querySelector("[data-command='${command}']"); if (!button) throw new Error("Missing command ${command}"); button.click(); return true; }`); } async function selectProgram(client, programId) { return evaluate(client, `() => { const select = document.querySelector("[data-program-select]"); if (!select) throw new Error("Missing program select"); select.value = ${JSON.stringify(programId)}; select.dispatchEvent(new Event("change", { bubbles: true })); return select.value; }`); } function assertTcpAndJoints(probe, label) { if (!/\[[^\]]+\]/.test(probe.joints) || probe.joints.split(",").length < 6) { throw new Error(`${label}: joints field is not a 6-axis vector: ${probe.joints}`); } if (!probe.tcp.includes("pos [") || !probe.tcp.includes(" q [")) { throw new Error(`${label}: TCP field is incomplete: ${probe.tcp}`); } if (probe.frameRows !== probe.sourceFrameTotal) { throw new Error(`${label}: frame table rows ${probe.frameRows} != source frame total ${probe.sourceFrameTotal}`); } if (probe.samplePeriodSec !== 0.05) { throw new Error(`${label}: expected 50ms sample period, got ${probe.samplePeriodSec}`); } assertProgramSteps(probe, label); } function assertProgramSteps(probe, label) { if (probe.stepRows < 1) { throw new Error(`${label}: program step table did not render rows`); } if (probe.activeStepRows !== 1) { throw new Error(`${label}: expected one active program step, got ${probe.activeStepRows}`); } if (!probe.stepStatus.includes("/") || !probe.stepStatus.includes("line")) { throw new Error(`${label}: step status is incomplete: ${probe.stepStatus}`); } if (!probe.activeStepText.includes("@50ms") && !probe.activeStepText.includes("line")) { throw new Error(`${label}: active step does not expose execution detail: ${probe.activeStepText}`); } } async function sleepAndProbe(client, ms) { await sleep(ms); return evaluate(client, pageProbeSource()); } async function waitForProbe(client, predicate, label, timeoutMs) { const started = Date.now(); let lastProbe; while (Date.now() - started < timeoutMs) { lastProbe = await evaluate(client, pageProbeSource()); if (predicate(lastProbe)) return lastProbe; await sleep(100); } throw new Error(`${label} timed out. Last probe: ${JSON.stringify(lastProbe)}`); } function pageProbeSource() { return `() => { const field = (name) => document.querySelector("[data-field='" + name + "']")?.textContent?.trim() ?? ""; const frame = field("frame"); const frameMatch = frame.match(/(\\d+)\\s*\\/\\s*(\\d+)/); const app = window.__virtualController; return { title: document.title, state: field("state"), program: field("program"), wait: field("wait"), log: field("log"), reports: field("reports"), storage: field("storage"), joints: field("joints"), tcp: field("tcp"), frame, frameCurrent: frameMatch ? Number(frameMatch[1]) : 0, frameTotal: frameMatch ? Number(frameMatch[2]) : 0, frameRows: document.querySelectorAll("[data-frame-table] tr").length, frameTime: field("frame-time"), parser: field("parser"), parserProgress: Number(document.querySelector("[data-parser-progress]")?.value ?? 0), stepStatus: field("step-status"), stepRows: document.querySelectorAll("[data-step-table] tr").length, activeStepRows: document.querySelectorAll("[data-step-table] tr[data-step-state='active']").length, activeStepText: document.querySelector("[data-step-table] tr[data-step-state='active']")?.textContent?.trim() ?? "", programOptions: Array.from(document.querySelectorAll("[data-program-select] option")).map((item) => item.value), running: document.querySelector(".workbench")?.dataset.running === "true", playbackTime: Number(app?.playbackTime ?? 0), samplePeriodSec: Number(app?.samplePeriodSec ?? 0), sampleIndex: Number(app?.currentSampleIndex ?? 0), sourceFrameIndex: Number(app?.currentFrameIndex ?? 0), sourceFrameTotal: Array.isArray(app?.programCache?.get?.(app?.selectedProgramId)?.trajectory?.frames) ? app.programCache.get(app.selectedProgramId).trajectory.frames.length : document.querySelectorAll("[data-frame-table] tr").length, storageState: app?.storage ? { ...app.storage } : {}, parseStream: app?.parseStream ? { ...app.parseStream, timer: Boolean(app.parseStream.timer) } : {}, errors: Array.isArray(window.__vcErrors) ? [...window.__vcErrors] : [] }; }`; } function launchChrome(name) { const userDataDir = mkdtempSync(join(tmpdir(), `kdl-vc-${name}-`)); const child = spawn(chromeBin, [ "--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors", "--remote-debugging-port=0", `--user-data-dir=${userDataDir}`, "about:blank" ], { stdio: ["ignore", "pipe", "pipe"] }); let log = ""; let settled = false; const cleanup = () => { child.kill("SIGTERM"); setTimeout(() => { rmSync(userDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }, 250).unref(); }; return new Promise((resolveLaunch, reject) => { const timeout = setTimeout(() => { if (!settled) { settled = true; cleanup(); reject(new Error(`Chrome did not expose DevTools endpoint. ${log}`)); } }, 15_000); const onData = (chunk) => { log += chunk.toString(); const match = log.match(/DevTools listening on (ws:\/\/[^\s]+)/); if (!match || settled) return; settled = true; clearTimeout(timeout); findPageWebsocket(match[1]).then((pageWebsocketUrl) => { resolveLaunch({ websocketUrl: pageWebsocketUrl, close: cleanup }); }, (error) => { cleanup(); reject(error); }); }; child.stdout.on("data", onData); child.stderr.on("data", onData); child.on("error", (error) => { if (!settled) { settled = true; clearTimeout(timeout); cleanup(); reject(error); } }); child.on("exit", (code) => { if (!settled) { settled = true; clearTimeout(timeout); cleanup(); reject(new Error(`${basename(chromeBin)} exited early with ${code}. ${log}`)); } }); }); } async function findPageWebsocket(browserWebsocketUrl) { const endpoint = new URL(browserWebsocketUrl); const baseUrl = `http://${endpoint.host}`; const started = Date.now(); let lastError; while (Date.now() - started < 5000) { try { const response = await fetch(`${baseUrl}/json/list`); const targets = await response.json(); const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl); if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl; } catch (error) { lastError = error; } await sleep(100); } throw new Error(`Could not find Chrome page target. ${lastError instanceof Error ? lastError.message : ""}`); } function connect(websocketUrl) { const socket = new WebSocket(websocketUrl); let nextId = 1; const pending = new Map(); socket.addEventListener("message", (event) => { const message = JSON.parse(event.data.toString()); if (!message.id) return; const request = pending.get(message.id); if (!request) return; pending.delete(message.id); if (message.error) { request.reject(new Error(`${message.error.code}: ${message.error.message}`)); } else { request.resolve(message.result ?? {}); } }); return new Promise((resolveClient, reject) => { socket.addEventListener("open", () => { resolveClient({ send(method, params = {}) { const id = nextId++; socket.send(JSON.stringify({ id, method, params })); return new Promise((resolveSend, rejectSend) => { pending.set(id, { resolve: resolveSend, reject: rejectSend }); }); }, close() { socket.close(); } }); }, { once: true }); socket.addEventListener("error", reject, { once: true }); }); } function waitForLoad(client) { return new Promise((resolveLoad, reject) => { const started = Date.now(); const poll = async () => { try { const ready = await evaluate(client, "() => document.readyState"); if (ready === "complete") { resolveLoad(); return; } if (Date.now() - started > 10_000) { reject(new Error("Timed out waiting for document.readyState complete")); return; } setTimeout(poll, 100); } catch (error) { reject(error); } }; poll(); }); } async function evaluate(client, expression) { const result = await client.send("Runtime.evaluate", { expression: `(${expression})()`, awaitPromise: true, returnByValue: true }); if (result.exceptionDetails) { throw new Error(result.exceptionDetails.text ?? "Runtime.evaluate failed"); } return result.result.value; } function sleep(ms) { return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); } function defaultChromeBin() { if (process.platform !== "win32") return "/usr/bin/google-chrome"; const candidates = [ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe") : "", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe" ]; return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "chrome.exe"; }