#!/usr/bin/env node import crypto from "node:crypto"; import fs from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const distRoot = path.join(root, "web", "dist"); const docsRoot = path.join(root, "docs"); const inspectMode = process.argv.includes("--inspect"); const pythonMode = process.argv.includes("--python"); const persistenceMode = process.argv.includes("--persistence"); const outputPath = path.join(docsRoot, "p6", persistenceMode ? "cloud-action-04-chrome.json" : pythonMode ? "cloud-action-03-chrome.json" : inspectMode ? "cloud-action-02-chrome.json" : "cloud-chrome.json"); const logPath = path.join(docsRoot, persistenceMode ? "freecad-p6-cloud-act04-f-chrome.log" : pythonMode ? "freecad-p6-cloud-act03-f-chrome.log" : inspectMode ? "freecad-p6-cloud-act02-f-chrome.log" : "freecad-p6-cloud-f-chrome.log"); const screenshotPath = path.join(docsRoot, "p6", persistenceMode ? "cloud-action-04-chrome.png" : pythonMode ? "cloud-action-03-chrome.png" : inspectMode ? "cloud-action-02-chrome.png" : "cloud-chrome.png"); const viewport = { width: 1440, height: 900, deviceScaleFactor: 1 }; const timeoutMs = 30000; const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex"); const chromeCandidates = ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/opt/google/chrome/google-chrome"]; const chromePath = chromeCandidates.find((candidate) => fs.existsSync(candidate)) ?? null; if (!chromePath) { const blocked = { schema: "freecad-web-p6-cloud-chrome/v1", result: "blocked_chrome_unavailable", policy: { browser: "Google Chrome only", nonChromeBrowsersAttempted: false } }; fs.writeFileSync(outputPath, `${JSON.stringify(blocked, null, 2)}\n`); fs.writeFileSync(logPath, "Google Chrome executable was not found; no alternate browser was attempted\n"); process.exitCode = 2; } else { if (!fs.existsSync(path.join(distRoot, "cloud.html"))) throw new Error("web/dist/cloud.html is missing; run npm run build first"); const contentTypes = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css", ".json": "application/json", ".wasm": "application/wasm", ".png": "image/png" }; const server = http.createServer((request, response) => { const requestPath = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); if (requestPath === "/favicon.ico") { response.writeHead(204); response.end(); return; } const relative = requestPath === "/" ? "cloud.html" : requestPath.replace(/^\/+/, ""); const filePath = path.resolve(distRoot, relative); if (filePath !== distRoot && !filePath.startsWith(`${distRoot}${path.sep}`)) { response.writeHead(403); response.end("forbidden"); return; } if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { response.writeHead(404); response.end("not found"); return; } response.writeHead(200, { "content-type": contentTypes[path.extname(filePath)] ?? "application/octet-stream", "cache-control": "no-store" }); fs.createReadStream(filePath).pipe(response); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const origin = `http://127.0.0.1:${server.address().port}`; const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-p6-cloud-chrome-")); const chrome = spawn(chromePath, ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--remote-debugging-port=0", "--remote-allow-origins=*", `--user-data-dir=${userDataDir}`, `--window-size=${viewport.width},${viewport.height}`, `${origin}/cloud.html`], { stdio: ["ignore", "pipe", "pipe"] }); let chromeOutput = ""; chrome.stdout.on("data", (chunk) => { chromeOutput += chunk.toString(); }); chrome.stderr.on("data", (chunk) => { chromeOutput += chunk.toString(); }); class CdpSession { constructor(url) { this.socket = new WebSocket(url); this.nextId = 1; this.pending = new Map(); this.listeners = new Map(); this.ready = new Promise((resolve, reject) => { this.socket.addEventListener("open", resolve, { once: true }); this.socket.addEventListener("error", reject, { once: true }); }); this.socket.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.id !== undefined) { const pending = this.pending.get(message.id); if (!pending) return; this.pending.delete(message.id); if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`)); else pending.resolve(message.result); return; } for (const listener of this.listeners.get(message.method) ?? []) listener(message.params); }); } async send(method, params = {}) { await this.ready; const id = this.nextId++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject, method }); this.socket.send(JSON.stringify({ id, method, params })); }); } on(method, listener) { this.listeners.set(method, [...(this.listeners.get(method) ?? []), listener]); } close() { this.socket.close(); } } async function waitFor(label, predicate, evaluate) { const deadline = Date.now() + timeoutMs; let last; while (Date.now() < deadline) { last = await evaluate(); if (predicate(last)) return last; await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`${label} timed out: ${JSON.stringify(last)}`); } let cdp; try { let target; const deadline = Date.now() + timeoutMs; while (!target && Date.now() < deadline) { const match = chromeOutput.match(/DevTools listening on ws:\/\/([^\s]+)/); if (match) { const port = match[1].split(":")[1].split("/")[0]; const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json()); target = targets.find((item) => item.type === "page"); } if (!target) await new Promise((resolve) => setTimeout(resolve, 100)); } if (!target?.webSocketDebuggerUrl) throw new Error(`Chrome CDP target unavailable: ${chromeOutput.trim()}`); cdp = new CdpSession(target.webSocketDebuggerUrl); const requests = []; const responses = []; const consoleEntries = []; const exceptions = []; cdp.on("Network.requestWillBeSent", (event) => requests.push({ url: event.request.url, method: event.request.method, type: event.type })); cdp.on("Network.responseReceived", (event) => responses.push({ url: event.response.url, status: event.response.status })); cdp.on("Runtime.consoleAPICalled", (event) => consoleEntries.push({ type: event.type, text: event.args?.map((arg) => arg.value ?? arg.description ?? "").join(" ") ?? "" })); cdp.on("Runtime.exceptionThrown", (event) => exceptions.push(event.exceptionDetails?.text ?? "runtime exception")); cdp.on("Log.entryAdded", (event) => { if (event.entry.level === "error") consoleEntries.push({ type: "browser-error", text: event.entry.text }); }); await cdp.send("Runtime.enable"); await cdp.send("Network.enable"); await cdp.send("Log.enable"); await cdp.send("Page.enable"); await cdp.send("Emulation.setDeviceMetricsOverride", { ...viewport, mobile: false }); const evaluate = async (expression) => { const result = await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error(result.exceptionDetails.text ?? "Runtime.evaluate failed"); return result.result?.value; }; const state = () => evaluate("window.__freecadCloudState"); const ready = await waitFor("Cloud ready", (value) => value?.stage === "ready", state); await evaluate(`document.querySelector('${persistenceMode ? "#persistence" : pythonMode ? "#python" : inspectMode ? "#inspect" : "#configure"}').click()`); const successStage = persistenceMode ? "persistence-success" : pythonMode ? "python-success" : inspectMode ? "inspect-success" : "success"; const success = await waitFor(pythonMode ? "Cloud Python API" : inspectMode ? "Cloud source inventory" : "Cloud configure/recompute", (value) => value?.stage === successStage, state); await evaluate(`document.querySelector('${persistenceMode ? "#persistence-invalid" : pythonMode ? "#python-invalid" : inspectMode ? "#inspect-invalid" : "#invalid"}').click()`); const expectedStage = persistenceMode ? "persistence-expected-error" : pythonMode ? "python-expected-error" : inspectMode ? "inspect-expected-error" : "expected-error"; const expectedError = await waitFor("Cloud expected error", (value) => value?.stage === expectedStage, state); await evaluate("document.querySelector('#reset').click()"); const reset = await waitFor("Cloud reset", (value) => value?.stage === "reset", state); const screenshot = await cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true }); const screenshotBytes = Buffer.from(screenshot.data, "base64"); fs.writeFileSync(screenshotPath, screenshotBytes); const externalRequests = requests.filter((request) => !request.url.startsWith(origin) && !request.url.startsWith("blob:")).map((request) => request.url); const runtimeErrors = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "browser-error"); const actionPassed = persistenceMode ? success.response?.kind === "result" && success.response.result.action === "save/reopen" && success.response.result.applicable === false && success.response.result.saveAvailable === true && success.response.result.restoreAvailable === true && expectedError.response?.kind === "error" && expectedError.response.error.code === "invalid_fixture" : pythonMode ? success.response?.kind === "result" && success.response.result.action === "python-api" && success.response.result.apiCount === 8 && success.response.result.callableCount === 8 && success.response.result.readOnly === true && expectedError.response?.kind === "error" && expectedError.response.error.code === "revision_mismatch" : inspectMode ? success.response?.kind === "result" && success.response.result.action === "inspect/source-inventory" && success.response.result.methodCount === 8 && success.response.result.objectTypeCount === 0 && success.response.result.registeredFormatExtensions.length === 0 && success.response.result.readOnly === true && expectedError.response?.kind === "error" && expectedError.response.error.code === "revision_mismatch" && expectedError.response.error.diagnostic?.field === "sourceRevision" : success.response?.kind === "result" && success.response.result.configuration.url === "https://cloud.example.invalid" && success.response.result.configuration.setterCount === 6 && success.response.result.recompute.count === 1 && success.response.result.recompute.hasError === 0 && expectedError.response?.kind === "error" && expectedError.response.error.code === "invalid_argument" && expectedError.response.error.diagnostic?.field === "url"; const passed = actionPassed && reset.stage === "reset" && externalRequests.length === 0 && exceptions.length === 0 && runtimeErrors.length === 0; const resultPayload = { success, expectedError, reset }; const result = { schema: persistenceMode ? "freecad-web-p6-cloud-action-04-chrome/v1" : pythonMode ? "freecad-web-p6-cloud-action-03-chrome/v1" : inspectMode ? "freecad-web-p6-cloud-action-02-chrome/v1" : "freecad-web-p6-cloud-chrome/v1", task_id: persistenceMode ? "FW-P6-CLD-ACT-04-F" : pythonMode ? "FW-P6-CLD-ACT-03-F" : inspectMode ? "FW-P6-CLD-ACT-02-F" : "FW-P6-CLD-F", result: passed ? "pass" : "fail", source_revision: "b5a086fda59b0325fe0996147e6e9b344414f9bc", input_sha256: sha256(JSON.stringify({ action: persistenceMode ? "save/reopen" : pythonMode ? "python-api" : inspectMode ? "inspect/source-inventory" : "configure/recompute", viewport })), result_sha256: sha256(JSON.stringify(resultPayload)), chrome: { path: chromePath, version: (await new Promise((resolve) => { const version = spawn(chromePath, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }); let output = ""; version.stdout.on("data", (chunk) => { output += chunk; }); version.on("close", () => resolve(output.trim())); })) }, mode: "headless-new", origin, viewport, timeoutMs, cases: { ready: { status: "pass", state: ready }, success: { status: "pass", state: success }, expectedError: { status: "pass", state: expectedError }, reset: { status: "pass", state: reset } }, network: { requests, responses, externalRequests, externalRequestCount: externalRequests.length }, console: consoleEntries, exceptions, evidence: { screenshot: persistenceMode ? "FreeCAD/docs/p6/cloud-action-04-chrome.png" : pythonMode ? "FreeCAD/docs/p6/cloud-action-03-chrome.png" : inspectMode ? "FreeCAD/docs/p6/cloud-action-02-chrome.png" : "FreeCAD/docs/p6/cloud-chrome.png", screenshotSha256: sha256(screenshotBytes), resultHash: sha256(JSON.stringify(resultPayload)) }, commands: ["cd FreeCAD/web && npm run build", `node FreeCAD/tools/run_p6_cloud_chrome.mjs${persistenceMode ? " --persistence" : pythonMode ? " --python" : inspectMode ? " --inspect" : ""}`], artifacts: { page: "FreeCAD/web/dist/cloud.html", worker: "FreeCAD/web/dist/assets/cloud.worker-.js", wasm: "FreeCAD/web/dist/kernel/FreeCADWebCloudKernelProbe.wasm" }, policy: { browser: "Google Chrome only", nonChromeBrowsersAttempted: false }, boundaries: ["Desktop viewport only; no non-Chrome or mobile compatibility claim.", `The browser slice covers one Cloud ${persistenceMode ? "save/reopen" : pythonMode ? "python-api" : inspectMode ? "inspect/source-inventory" : "configure/recompute"} action.`], generated_at: new Date().toISOString(), }; fs.writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(logPath, `chrome=${result.chrome.version}\nmode=${result.mode}\nviewport=${JSON.stringify(viewport)}\nrequests=${requests.length}\nexternalRequests=${externalRequests.length}\nconsoleErrors=${runtimeErrors.length}\nexceptions=${exceptions.length}\nresult=${result.result}\n`); console.log(JSON.stringify({ result: result.result, externalRequests: externalRequests.length, consoleErrors: runtimeErrors.length, exceptions: exceptions.length })); if (result.result !== "pass") process.exitCode = 1; } finally { cdp?.close(); chrome.kill("SIGTERM"); server.close(); } }