import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const toolsRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url))); const projectRoot = path.resolve(toolsRoot, ".."); const docsRoot = path.join(projectRoot, "docs"); const origin = "http://127.0.0.1:4174"; const sourceRevision = JSON.parse(fs.readFileSync(path.join(docsRoot, "freecad-web-offline-package-manifest.json"), "utf8")).sourceRevision; 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(`${message.error.code}: ${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 }); this.socket.send(JSON.stringify({ id, method, params })); }); } on(method, listener) { const listeners = this.listeners.get(method) ?? []; listeners.push(listener); this.listeners.set(method, listeners); } close() { this.socket.close(); } } const targets = await fetch("http://127.0.0.1:9223/json/list").then((response) => response.json()); const target = targets.find((item) => item.type === "page"); if (!target?.webSocketDebuggerUrl) throw new Error("no Chrome CDP page target found on port 9223"); const cdp = new CdpSession(target.webSocketDebuggerUrl); const networkRequests = []; const exceptions = []; cdp.on("Network.requestWillBeSent", (event) => networkRequests.push({ url: event.request.url, method: event.request.method, type: event.type })); cdp.on("Runtime.exceptionThrown", (event) => exceptions.push(event.exceptionDetails?.text ?? "runtime exception")); await cdp.send("Runtime.enable"); await cdp.send("Network.enable"); await cdp.send("Page.enable"); async function evaluate(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; } async function waitFor(label, predicate, timeoutMs = 30000) { const deadline = Date.now() + timeoutMs; let last; while (Date.now() < deadline) { last = await evaluate(`(() => { const status = document.querySelector('#status'); return { status: status?.textContent ?? '', level: status?.dataset.level ?? '', file: document.querySelector('#file-name')?.textContent ?? '', vertex: document.querySelector('#vertex-count')?.textContent ?? '', triangle: document.querySelector('#triangle-count')?.textContent ?? '', face: document.querySelector('#face-count')?.textContent ?? '', requestId: window.__freecadLastResponseRequestId ?? null }; })()`); if (predicate(last)) return last; await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error(`${label} timed out: ${JSON.stringify(last)}`); } async function navigate(pathname) { networkRequests.length = 0; exceptions.length = 0; await cdp.send("Page.navigate", { url: `${origin}${pathname}` }); await waitFor("page startup", (state) => state.status !== "" && !state.status.includes("初始化"), 30000).catch(async (error) => { const state = await evaluate("document.readyState"); throw new Error(`${error.message}; readyState=${state}`); }); } const cases = []; await navigate("/?sample=1"); const importState = await waitFor("sample import", (state) => state.status.startsWith("已加载 ")); cases.push({ id: "sample-import", status: "pass", state: importState, networkRequests: networkRequests.map((request) => request.url), externalRequests: networkRequests.filter((request) => !request.url.startsWith(origin)).map((request) => request.url), exceptions: [...exceptions], }); await evaluate("document.querySelector('#reset-button').click()"); const resetState = await waitFor("reset", (state) => state.status === "已清空"); cases.push({ id: "reset", status: "pass", state: resetState, externalRequests: networkRequests.filter((request) => !request.url.startsWith(origin)).map((request) => request.url) }); await navigate("/?storage=1"); const storageState = await waitFor("OPFS smoke", (state) => state.status.includes("OPFS smoke passed")); cases.push({ id: "opfs", status: "pass", state: storageState, externalRequests: networkRequests.filter((request) => !request.url.startsWith(origin)).map((request) => request.url), exceptions: [...exceptions] }); await navigate("/?cache=1"); const cacheState = await waitFor("mesh cache smoke", (state) => state.status === "网格缓存 smoke passed"); cases.push({ id: "mesh-cache", status: "pass", state: cacheState, externalRequests: networkRequests.filter((request) => !request.url.startsWith(origin)).map((request) => request.url), exceptions: [...exceptions] }); const result = { schema: "freecad-web-offline-functional/v1", sourceRevision, package: "FreeCAD/web/offline-package", origin, browser: "Google Chrome 151.0.7922.137 headless", cases, summary: { caseCount: cases.length, passedCases: cases.filter((item) => item.status === "pass").length, externalRequestCount: cases.reduce((total, item) => total + (item.externalRequests?.length ?? 0), 0), exceptionCount: cases.reduce((total, item) => total + (item.exceptions?.length ?? 0), 0), result: cases.every((item) => item.status === "pass" && item.externalRequests?.length === 0 && (item.exceptions?.length ?? 0) === 0) ? "pass_offline_core_functional_replay" : "fail_offline_core_functional_replay", }, }; fs.writeFileSync(path.join(docsRoot, "freecad-web-offline-functional.json"), `${JSON.stringify(result, null, 2)}\n`); console.log(`offline cases passed: ${result.summary.passedCases}/${result.summary.caseCount}`); console.log(`external requests: ${result.summary.externalRequestCount}; exceptions: ${result.summary.exceptionCount}`); cdp.close(); if (result.summary.result.startsWith("fail")) process.exitCode = 1;