Files
FreeCADWasmDev/tools/run_p6_cloud_chrome.mjs
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

163 lines
14 KiB
JavaScript

#!/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-<hash>.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(); }
}