8 lines
6.8 KiB
JavaScript
8 lines
6.8 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 output = path.join(docs, "help-chrome.json"); const log = path.join(root, "docs/freecad-p6-help-f-chrome.log"); const chromePath = ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium"].find((file) => fs.existsSync(file)); const viewport = { width: 1440, height: 900, deviceScaleFactor: 1 };
|
|
if (!chromePath) { const report = { schema: "freecad-web-p6-help-chrome/v1", result: "blocked_chrome_unavailable", policy: { browser: "Google Chrome only", nonChromeBrowsersAttempted: false } }; fs.mkdirSync(docs, { recursive: true }); fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`); fs.writeFileSync(log, "Google Chrome executable was not found\n"); process.exitCode = 2; } else {
|
|
const server = http.createServer((req, res) => { const requestPath = decodeURIComponent(new URL(req.url ?? "/", "http://127.0.0.1").pathname); const relative = requestPath === "/" ? "help.html" : requestPath.replace(/^\/+/, ""); const file = path.resolve(dist, relative); if (file !== dist && !file.startsWith(`${dist}${path.sep}`)) { res.statusCode = 403; return res.end("forbidden"); } if (!fs.existsSync(file)) { res.statusCode = 404; return res.end("not found"); } const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".wasm": "application/wasm", ".png": "image/png" }; res.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream", "cache-control": "no-store" }); fs.createReadStream(file).pipe(res); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const origin = `http://127.0.0.1:${server.address().port}`; const userData = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-p6-help-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=${userData}`, `--window-size=${viewport.width},${viewport.height}`, `${origin}/help.html`], { 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); }); } send(method, params = {}) { return this.ready.then(() => 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(); } }
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let cdp; try { let target; for (let i = 0; i < 300 && !target; i++) { 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 sleep(100); } if (!target) throw new Error("Chrome CDP target unavailable"); cdp = new Cdp(target.webSocketDebuggerUrl); const requests = []; const exceptions = []; const consoleErrors = []; cdp.on("Network.requestWillBeSent", (event) => requests.push(event.request.url)); cdp.on("Runtime.exceptionThrown", (event) => exceptions.push(event.exceptionDetails?.text ?? "runtime exception")); cdp.on("Log.entryAdded", (event) => { if (event.entry.level === "error") consoleErrors.push(event.entry.text); }); await cdp.send("Runtime.enable"); await cdp.send("Network.enable"); await cdp.send("Log.enable"); const evaluate = async (expression) => (await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result?.value; const waitStage = async (stage) => { let value; for (let i = 0; i < 300; i++) { value = await evaluate("window.__freecadHelpState"); if (value?.stage === stage) return value; await sleep(100); } throw new Error(`stage ${stage} timed out`); }; const ready = await waitStage("ready"); await evaluate("document.querySelector('#inspect').click()"); const success = await waitStage("success"); 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((url) => !url.startsWith(origin)); const result = externalRequests.length === 0 && exceptions.length === 0 && consoleErrors.length === 0 ? "pass" : "fail"; const report = { schema: "freecad-web-p6-help-chrome/v1", module: "Help", action: "inspect/source-inventory", result, chrome: { path: chromePath, version: (await new Promise((resolve) => { const version = spawn(chromePath, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }); let text = ""; version.stdout.on("data", (chunk) => { text += chunk; }); version.on("close", () => resolve(text.trim())); })) }, mode: "headless-new", origin, viewport, cases: { ready, success, expectedError, reset }, network: { requests, externalRequests, externalRequestCount: externalRequests.length }, exceptions, consoleErrors, policy: { browser: "Google Chrome only", nonChromeBrowsersAttempted: false }, evidence: { resultHash: crypto.createHash("sha256").update(JSON.stringify({ ready, success, expectedError, reset })).digest("hex") } }; fs.mkdirSync(docs, { recursive: true }); fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`); fs.writeFileSync(log, `result=${result}\nexternalRequests=${externalRequests.length}\nexceptions=${exceptions.length}\nconsoleErrors=${consoleErrors.length}\n`); console.log(JSON.stringify({ result, externalRequests: externalRequests.length, exceptions: exceptions.length })); if (result !== "pass") process.exitCode = 1; } finally { cdp?.close(); chrome.kill("SIGTERM"); server.close(); } }
|