235 lines
9.6 KiB
JavaScript
235 lines
9.6 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";
|
|
|
|
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
const dist = path.join(root, "web/dist");
|
|
const docs = path.join(root, "docs/p6");
|
|
const inspectMode = process.argv.includes("--inspect");
|
|
const pythonMode = process.argv.includes("--python");
|
|
const saveMode = process.argv.includes("--save");
|
|
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 wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
|
|
if (!chromePath) {
|
|
process.exitCode = 2;
|
|
} else {
|
|
const server = http.createServer((req, res) => {
|
|
const requestPath = decodeURIComponent(
|
|
new URL(req.url ?? "/", "http://127.0.0.1").pathname,
|
|
);
|
|
const filePath = path.resolve(
|
|
dist,
|
|
requestPath === "/" ? "measure.html" : requestPath.replace(/^\/+/, ""),
|
|
);
|
|
if (!fs.existsSync(filePath)) {
|
|
res.writeHead(404);
|
|
res.end();
|
|
return;
|
|
}
|
|
const extension = path.extname(filePath);
|
|
const contentType = extension === ".html"
|
|
? "text/html; charset=utf-8"
|
|
: extension === ".js"
|
|
? "text/javascript; charset=utf-8"
|
|
: "application/wasm";
|
|
res.writeHead(200, { "content-type": contentType, "cache-control": "no-store" });
|
|
fs.createReadStream(filePath).pipe(res);
|
|
});
|
|
|
|
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-measure-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}/measure.html${inspectMode ? "?action=inspect" : pythonMode ? "?action=python" : saveMode ? "?action=save" : ""}`,
|
|
],
|
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
let output = "";
|
|
chrome.stdout.on("data", (chunk) => { output += chunk; });
|
|
chrome.stderr.on("data", (chunk) => { output += 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) return;
|
|
this.pending.delete(message.id);
|
|
if (message.error) pending.reject(new Error(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;
|
|
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 attempt = 0; attempt < 300 && !target; attempt += 1) {
|
|
const match = output.match(/DevTools listening on ws:\/\/([^\s]+)/);
|
|
if (match) {
|
|
const port = match[1].split(":")[1].split("/")[0];
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
|
const targets = await response.json();
|
|
target = targets.find((item) => item.type === "page");
|
|
} catch {
|
|
target = undefined;
|
|
}
|
|
}
|
|
if (!target) await wait(100);
|
|
}
|
|
if (!target?.webSocketDebuggerUrl) throw new Error("Chrome CDP target unavailable");
|
|
cdp = new Cdp(target.webSocketDebuggerUrl);
|
|
const requests = [];
|
|
const exceptions = [];
|
|
cdp.on("Network.requestWillBeSent", (event) => requests.push(event.request.url));
|
|
cdp.on("Runtime.exceptionThrown", (event) => exceptions.push(event.exceptionDetails));
|
|
await cdp.send("Runtime.enable");
|
|
await cdp.send("Network.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.__freecadMeasureState");
|
|
const waitForStage = async (expected) => {
|
|
for (let attempt = 0; attempt < 300; attempt += 1) {
|
|
const value = await state();
|
|
if (value?.stage === expected) return value;
|
|
await wait(100);
|
|
}
|
|
throw new Error(`${expected} timed out`);
|
|
};
|
|
|
|
const ready = await waitForStage("ready");
|
|
await evaluate("document.querySelector('#run').click()");
|
|
const success = await waitForStage("success");
|
|
await evaluate("document.querySelector('#error').click()");
|
|
const expectedError = await waitForStage("expected-error");
|
|
await evaluate("document.querySelector('#reset').click()");
|
|
const reset = await waitForStage("reset");
|
|
const screenshot = Buffer.from(
|
|
(await cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true })).data,
|
|
"base64",
|
|
);
|
|
const artifactStem = inspectMode ? "measure-action-02-chrome" : pythonMode ? "measure-action-03-chrome" : saveMode ? "measure-action-04-chrome" : "measure-chrome";
|
|
fs.writeFileSync(path.join(docs, `${artifactStem}.png`), screenshot);
|
|
const externalRequests = requests.filter((url) => !url.startsWith(origin));
|
|
const result = (inspectMode
|
|
? success?.response?.kind === "result"
|
|
&& success.response.result?.action === "inspect/source-inventory"
|
|
&& success.response.result?.registeredTypeCount >= 10
|
|
&& success.response.result?.pythonBindingCount === 2
|
|
: pythonMode
|
|
? success?.response?.kind === "result"
|
|
&& success.response.result?.action === "python-api"
|
|
&& success.response.result?.apiCount === 2
|
|
&& success.response.result?.apiNames?.length === 2
|
|
: saveMode
|
|
? success?.response?.kind === "result"
|
|
&& success.response.result?.action === "save/reopen"
|
|
&& success.response.result?.roundTrip === true
|
|
&& success.response.result?.objectCount === 1
|
|
: success?.response?.kind === "result"
|
|
&& success.response.result?.distance === 5)
|
|
&& expectedError?.response?.kind === "error"
|
|
&& expectedError.response.error?.code === (inspectMode || pythonMode ? "revision_mismatch" : saveMode ? "invalid_path" : "invalid_fixture")
|
|
&& reset?.stage === "reset"
|
|
&& externalRequests.length === 0
|
|
&& exceptions.length === 0
|
|
? "pass"
|
|
: "fail";
|
|
const version = await new Promise((resolve) => {
|
|
const versionProcess = spawn(chromePath, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
let text = "";
|
|
versionProcess.stdout.on("data", (chunk) => { text += chunk; });
|
|
versionProcess.on("close", () => resolve(text.trim()));
|
|
});
|
|
const report = {
|
|
schema: inspectMode ? "freecad-web-p6-measure-action-02-chrome/v1" : pythonMode ? "freecad-web-p6-measure-action-03-chrome/v1" : saveMode ? "freecad-web-p6-measure-action-04-chrome/v1" : "freecad-web-p6-measure-chrome/v1",
|
|
taskId: inspectMode ? "FW-P6-MEA-ACT-02-F" : pythonMode ? "FW-P6-MEA-ACT-03-F" : saveMode ? "FW-P6-MEA-ACT-04-F" : "FW-P6-MEA-F",
|
|
module: "Measure",
|
|
action: inspectMode ? "inspect/source-inventory" : pythonMode ? "python-api" : saveMode ? "save/reopen" : "create/recompute",
|
|
result,
|
|
chrome: { path: chromePath, version },
|
|
mode: "headless-new",
|
|
origin,
|
|
viewport,
|
|
cases: { ready, success, expectedError, reset },
|
|
network: { requests, externalRequests, externalRequestCount: externalRequests.length },
|
|
exceptions,
|
|
policy: { browser: "Google Chrome only", nonChromeBrowsersAttempted: false },
|
|
evidence: {
|
|
screenshot: `docs/p6/${artifactStem}.png`,
|
|
screenshotSha256: hash(screenshot),
|
|
resultHash: hash(JSON.stringify({ ready, success, expectedError, reset })),
|
|
},
|
|
};
|
|
fs.writeFileSync(path.join(docs, `${artifactStem}.json`), `${JSON.stringify(report, null, 2)}\n`);
|
|
fs.writeFileSync(
|
|
path.join(root, `docs/freecad-p6-measure-${inspectMode ? "action-02-f" : pythonMode ? "action-03-f" : saveMode ? "action-04-f" : "f"}-chrome.log`),
|
|
`result=${result}\nchrome=${version}\nexternalRequests=${externalRequests.length}\nexceptions=${exceptions.length}\n`,
|
|
);
|
|
console.log(JSON.stringify({ result, chrome: version, externalRequests: externalRequests.length, exceptions: exceptions.length }));
|
|
if (result !== "pass") process.exitCode = 1;
|
|
} finally {
|
|
cdp?.close();
|
|
chrome.kill("SIGTERM");
|
|
server.close();
|
|
}
|
|
}
|