import { existsSync, mkdtempSync, mkdirSync, 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(); mkdirSync(outputDir, { recursive: true }); const desktop = await verifyViewport({ name: "desktop", width: 1440, height: 960, deviceScaleFactor: 1, mobile: false, screenshotPath: join(outputDir, "virtual-controller-desktop.png") }); const mobile = await verifyViewport({ name: "mobile", width: 390, height: 844, deviceScaleFactor: 2, mobile: true, screenshotPath: join(outputDir, "virtual-controller-mobile.png") }); const evidence = { app: appHtml, chrome: chromeBin, desktop, mobile }; writeFileSync(join(outputDir, "evidence.json"), `${JSON.stringify(evidence, null, 2)}\n`); console.log(JSON.stringify(evidence, null, 2)); async function verifyViewport(viewport) { const browser = await launchChrome(viewport); try { const client = await connect(browser.websocketUrl); await client.send("Page.enable"); await client.send("Runtime.enable"); await client.send("Emulation.setDeviceMetricsOverride", { width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.deviceScaleFactor, mobile: viewport.mobile }); await client.send("Page.navigate", { url: pathToFileURL(appHtml).href }); await waitForLoad(client); const initial = await waitForProbe( client, (probe) => probe.parserProgress === 1 && !probe.parser.includes("parsing"), "initial parse", 4000 ); assertProbe(initial, viewport.name, "initial"); await evaluate(client, clickCommandSource("load")); await evaluate(client, clickCommandSource("run")); await evaluate(client, clickIoSource()); const afterRun = await waitForProbe(client, (probe) => probe.state === "running", "run", 1200); assertProbe(afterRun, viewport.name, "after run"); if (afterRun.state !== "running") { throw new Error(`${viewport.name}: expected state running, got ${afterRun.state}`); } if (!afterRun.running) { throw new Error(`${viewport.name}: data-running was not set after Run`); } if (!afterRun.wait.includes("satisfied")) { throw new Error(`${viewport.name}: expected DI wait to be satisfied, got ${afterRun.wait}`); } const screenshot = await client.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }); writeFileSync(viewport.screenshotPath, Buffer.from(screenshot.data, "base64")); const pngBytes = Buffer.byteLength(screenshot.data, "base64"); if (pngBytes < 10_000) { throw new Error(`${viewport.name}: screenshot is unexpectedly small (${pngBytes} bytes)`); } await client.close(); return { viewport: { width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.deviceScaleFactor, mobile: viewport.mobile }, screenshot: viewport.screenshotPath, screenshotBytes: pngBytes, state: afterRun.state, parser: afterRun.parser, wait: afterRun.wait, trace: afterRun.trace, visiblePanels: afterRun.visiblePanels }; } finally { browser.close(); } } function launchChrome(viewport) { const userDataDir = mkdtempSync(join(tmpdir(), `kdl-vc-${viewport.name}-`)); const child = spawn(chromeBin, [ "--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage", "--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 new Promise((resolveRetry) => setTimeout(resolveRetry, 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 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 new Promise((resolveRetry) => setTimeout(resolveRetry, 100)); } throw new Error(`${label} timed out. Last probe: ${JSON.stringify(lastProbe)}`); } 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 pageProbeSource() { return `() => { const field = (name) => document.querySelector("[data-field='" + name + "']")?.textContent?.trim() ?? ""; const box = (selector) => { const item = document.querySelector(selector); if (!item) return null; const rect = item.getBoundingClientRect(); return { width: rect.width, height: rect.height, top: rect.top, left: rect.left }; }; const visiblePanels = [".command-bar", ".object-tree", ".viewport", ".pendant", ".editor", ".bottom-panel"] .map((selector) => ({ selector, box: box(selector) })) .filter((item) => item.box && item.box.width > 0 && item.box.height > 0) .map((item) => item.selector); return { title: document.title, state: field("state"), wait: field("wait"), queue: field("queue"), trace: field("trace"), parser: field("parser"), parserProgress: Number(document.querySelector("[data-parser-progress]")?.value ?? 0), editor: field("editor"), stepRows: document.querySelectorAll("[data-step-table] tr").length, activeStepRows: document.querySelectorAll("[data-step-table] tr[data-step-state='active']").length, stepStatus: field("step-status"), running: document.querySelector(".workbench")?.dataset.running === "true", visiblePanels, viewport: box(".viewport"), bodyScrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }; }`; } function clickCommandSource(command) { return `() => { document.querySelector("[data-command='${command}']").click(); return document.querySelector(".workbench")?.dataset.lastCommand; }`; } function clickIoSource() { return `() => { document.querySelector("[data-io='di1']").click(); return document.querySelector("[data-field='wait']")?.textContent; }`; } function assertProbe(probe, viewportName, phase) { if (probe.title !== "ABB120 Virtual Controller") { throw new Error(`${viewportName}/${phase}: unexpected title ${probe.title}`); } const required = [".command-bar", ".object-tree", ".viewport", ".pendant", ".editor", ".bottom-panel"]; for (const selector of required) { if (!probe.visiblePanels.includes(selector)) { throw new Error(`${viewportName}/${phase}: ${selector} is not visible`); } } if (!probe.editor.includes("proc main") && !probe.editor.includes("MoveJ") && !probe.editor.includes("PTP")) { throw new Error(`${viewportName}/${phase}: editor source did not render`); } if (probe.stepRows < 1 || probe.activeStepRows !== 1 || !probe.stepStatus.includes("line")) { throw new Error(`${viewportName}/${phase}: program steps did not render correctly`); } if (!probe.viewport || probe.viewport.width < 100 || probe.viewport.height < 100) { throw new Error(`${viewportName}/${phase}: viewport is too small`); } if (probe.bodyScrollWidth > probe.clientWidth + 2) { throw new Error(`${viewportName}/${phase}: horizontal overflow detected`); } } 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"; }