Files
FreeCADWasmDev/tools/run_p6_import_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

96 lines
11 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 dist = path.join(root, "web/dist");
const docs = path.join(root, "docs/p6");
const action01 = process.argv.includes("action01"); const action03 = process.argv.includes("action03"); const action04 = process.argv.includes("action04"); const action05 = process.argv.includes("action05");
const reportPath = path.join(docs, action01 ? "import-action-01-chrome.json" : action03 ? "import-action-03-chrome.json" : action04 ? "import-action-04-chrome.json" : action05 ? "import-action-05-chrome.json" : "import-chrome.json");
const screenshotPath = path.join(docs, action01 ? "import-action-01-chrome.png" : action03 ? "import-action-03-chrome.png" : action04 ? "import-action-04-chrome.png" : action05 ? "import-action-05-chrome.png" : "import-chrome.png");
const logPath = path.join(root, action01 ? "docs/freecad-p6-import-action-01-f-chrome.log" : action03 ? "docs/freecad-p6-import-action-03-f-chrome.log" : action04 ? "docs/freecad-p6-import-action-04-f-chrome.log" : action05 ? "docs/freecad-p6-import-action-05-f-chrome.log" : "docs/freecad-p6-import-f-chrome.log");
const chromePath = ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/opt/google/chrome/google-chrome"].find(fs.existsSync);
const viewport = { width: 1440, height: 900, deviceScaleFactor: 1 };
const timeoutMs = 30000;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
if (!chromePath) {
fs.writeFileSync(reportPath, `${JSON.stringify({ schema: "freecad-web-p6-import-chrome/v1", result: "blocked_chrome_unavailable", policy: { browser: "Google Chrome only", nonChromeBrowsersAttempted: false } }, null, 2)}\n`);
process.exitCode = 2;
} else {
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 === "/" ? "import.html" : requestPath.replace(/^\/+/, "");
const file = path.resolve(dist, relative);
if (file !== dist && !file.startsWith(`${dist}${path.sep}`)) { response.writeHead(403); response.end("forbidden"); return; }
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end("not found"); return; }
const type = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".wasm": "application/wasm", ".png": "image/png" }[path.extname(file)] ?? "application/octet-stream";
response.writeHead(200, { "content-type": type, "cache-control": "no-store" });
fs.createReadStream(file).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 profile = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-p6-import-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=${profile}`, `--window-size=${viewport.width},${viewport.height}`, `${origin}/import.html${action01 ? "?action=create-recompute" : action03 ? "?action=inspect" : action04 ? "?action=python-api" : action05 ? "?action=save-reopen" : ""}`], { stdio: ["ignore", "pipe", "pipe"] });
let chromeOutput = "";
chrome.stdout.on("data", (chunk) => { chromeOutput += chunk; });
chrome.stderr.on("data", (chunk) => { chromeOutput += chunk; });
class Cdp {
constructor(url) {
this.socket = new WebSocket(url); this.id = 0; 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) { const pending = this.pending.get(message.id); if (pending) { this.pending.delete(message.id); message.error ? pending.reject(new Error(message.error.message)) : pending.resolve(message.result); } } else for (const listener of this.listeners.get(message.method) ?? []) listener(message.params); });
}
async send(method, params = {}) { await this.ready; return new Promise((resolve, reject) => { const id = ++this.id; this.pending.set(id, { resolve, reject }); this.socket.send(JSON.stringify({ id, method, params })); }); }
on(method, listener) { this.listeners.set(method, [...(this.listeners.get(method) ?? []), listener]); }
close() { this.socket.close(); }
}
let cdp;
try {
let target;
for (let index = 0; index < 300 && !target; index += 1) {
const match = chromeOutput.match(/DevTools listening on ws:\/\/([^\s]+)/);
if (match) {
const port = match[1].split(":")[1].split("/")[0];
try { target = (await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json())).find((item) => item.type === "page"); } catch { /* Chrome is still starting. */ }
}
if (!target) await wait(100);
}
if (!target?.webSocketDebuggerUrl) throw new Error(`Chrome CDP target unavailable: ${chromeOutput.trim()}`);
cdp = new Cdp(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: "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) => (await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result?.value;
const state = () => evaluate("window.__freecadImportState");
async function waitStage(stage) { let value; for (let index = 0; index < 300; index += 1) { value = await state(); if (value?.stage === stage) return value; await wait(100); } throw new Error(`${stage} timed out: ${JSON.stringify(value)}`); }
const ready = await waitStage("ready");
await evaluate("document.querySelector('#run').click()"); const success = await waitStage("success");
const screenshot = Buffer.from((await cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true })).data, "base64"); fs.writeFileSync(screenshotPath, screenshot);
await evaluate("document.querySelector('#error').click()"); const expectedError = await waitStage("expected-error");
await evaluate("document.querySelector('#reset').click()"); const reset = await waitStage("reset");
const externalRequests = requests.filter((request) => !request.url.startsWith(origin));
const consoleErrors = consoleEntries.filter((entry) => entry.type === "error");
const validSuccess = success.response?.kind === "result" && (action01 ? success.response.result?.object?.count === 32 && success.response.result?.document?.activeAfterClose === 0 : action03 ? success.response.result?.sourceEntry === "src/Mod/Import/Init.py" && success.response.result?.readOnly === true : action04 ? success.response.result?.apiCount === 1135 && success.response.result?.arbitraryExecution === false : action05 ? success.response.result?.reopened?.objects === 32 && success.response.result?.document?.activeAfterReopen === 0 : success.response.result?.summary?.objects === 32 && success.response.result?.activeAfterRelease === 0);
const validError = expectedError.response?.kind === "error" && expectedError.response.error?.code === ((action03 || action04) ? "revision_mismatch" : "invalid_fixture");
const result = validSuccess && validError && reset.stage === "reset" && externalRequests.length === 0 && exceptions.length === 0 && consoleErrors.length === 0 ? "pass" : "fail";
const chromeVersion = await new Promise((resolve) => { const child = spawn(chromePath, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }); let text = ""; child.stdout.on("data", (chunk) => { text += chunk; }); child.on("close", () => resolve(text.trim())); });
const report = { schema: action01 ? "freecad-web-p6-import-action-01-chrome/v1" : action03 ? "freecad-web-p6-import-action-03-chrome/v1" : action04 ? "freecad-web-p6-import-action-04-chrome/v1" : action05 ? "freecad-web-p6-import-action-05-chrome/v1" : "freecad-web-p6-import-chrome/v1", taskId: action01 ? "FW-P6-IMP-ACT-01-F" : action03 ? "FW-P6-IMP-ACT-03-F" : action04 ? "FW-P6-IMP-ACT-04-F" : action05 ? "FW-P6-IMP-ACT-05-F" : undefined, module: "Import", action: action01 ? "create/recompute" : action03 ? "inspect/source-inventory" : action04 ? "python-api" : action05 ? "save/reopen" : "import/step", result, chrome: { path: chromePath, version: chromeVersion }, mode: "headless-new", origin, viewport, timeoutMs, cases: { ready, success, expectedError, reset }, network: { requests, responses, externalRequests, externalRequestCount: externalRequests.length }, console: consoleEntries, exceptions, runtimeExceptionCount: exceptions.length, policy: { browser: "Google Chrome only", nonChromeBrowsersAttempted: false }, evidence: { screenshot: action01 ? "docs/p6/import-action-01-chrome.png" : action03 ? "docs/p6/import-action-03-chrome.png" : action04 ? "docs/p6/import-action-04-chrome.png" : action05 ? "docs/p6/import-action-05-chrome.png" : "docs/p6/import-chrome.png", screenshotSha256: hash(screenshot), resultHash: hash(JSON.stringify({ ready, success, expectedError, reset })) } };
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); fs.writeFileSync(logPath, `result=${result}\nchrome=${chromeVersion}\nexternalRequests=${externalRequests.length}\nexceptions=${exceptions.length}\nconsoleErrors=${consoleErrors.length}\n`); console.log(JSON.stringify({ result, chrome: chromeVersion, externalRequests: externalRequests.length, exceptions: exceptions.length, screenshotSha256: report.evidence.screenshotSha256 }));
if (result !== "pass") process.exitCode = 1;
} finally { cdp?.close(); chrome.kill("SIGTERM"); server.close(); }
}