import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawn, execFileSync } from "node:child_process"; import { once } from "node:events"; import { createHash } from "node:crypto"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const supported = ["product:document-workspace", "product:model-tree-properties", "product:multi-object-viewport", "product:react-primary-shell", "product:desktop-command-ui", "product:task-panel", "product:workbench-navigation", "product:dynamic-module-session", "product:file-workflows", "product:recovery-status"]; supported.push("Part:part.saveReopen"); const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? JSON.stringify(supported)); const results = []; const profile = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-web-business-")); const processes = []; const pending = new Map(); let socket; let sequence = 0; async function until(label, action, predicate, timeout = 30000) { const deadline = Date.now() + timeout; let value; while (Date.now() < deadline) { value = await action(); if (predicate(value)) return value; await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error(`${label}: ${JSON.stringify(value)}`); } function start(command, args, options) { const child = spawn(command, args, options); const closed = once(child, "exit"); closed.catch(() => {}); processes.push({ child, closed }); return child; } function send(method, params = {}) { const id = ++sequence; return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${method} timeout`)); }, 30000); pending.set(id, { resolve, reject, timer }); socket.send(JSON.stringify({ id, method, params })); }); } async function evaluate(expression) { const response = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); if (response.exceptionDetails) throw new Error(JSON.stringify(response.exceptionDetails)); return response.result?.value; } try { let origin = process.env.FREECAD_BROWSER_ORIGIN; if (!origin) { const server = start(path.join(root, "web/node_modules/.bin/vite"), ["preview", "--host", "127.0.0.1", "--port", "0"], { cwd: path.join(root, "web"), stdio: ["ignore", "pipe", "pipe"] }); origin = await new Promise((resolve, reject) => { let output = ""; const timer = setTimeout(() => reject(new Error("Preview startup timeout")), 30000); server.once("error", error => { clearTimeout(timer); reject(error); }); server.once("exit", code => { clearTimeout(timer); reject(new Error(`Preview exited ${code}`)); }); server.stdout.on("data", chunk => { output += chunk; const match = output.match(/http:\/\/127\.0\.0\.1:\d+/); if (match) { clearTimeout(timer); resolve(match[0]); } }); server.stderr.on("data", chunk => process.stderr.write(chunk)); }); } start(process.env.FREECAD_CHROME ?? "/usr/bin/google-chrome", ["--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "about:blank"], { stdio: "ignore" }); const port = await until("Chrome startup", () => { try { return fs.readFileSync(path.join(profile, "DevToolsActivePort"), "utf8").split("\n")[0]; } catch { return null; } }, Boolean); const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json()); socket = new WebSocket(targets.find(target => target.type === "page").webSocketDebuggerUrl); socket.addEventListener("message", event => { const message = JSON.parse(event.data), request = pending.get(message.id); if (!request) return; pending.delete(message.id); clearTimeout(request.timer); if (message.error) request.reject(new Error(JSON.stringify(message.error))); else request.resolve(message.result); }); await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); }); await send("Page.enable"); await send("Page.addScriptToEvaluateOnNewDocument", { source: ` window.__viewportDraws = 0; for (const type of [WebGLRenderingContext, WebGL2RenderingContext]) { const clear = type.prototype.clear; type.prototype.clear = function(...args) { window.__viewportDraws++; return clear.apply(this, args); }; } ` }); for (const requestedId of ids) { const capabilityId = requestedId; const cases = []; if (!supported.includes(capabilityId)) { results.push({ capabilityId, status: "not_implemented", cases }); continue; } try { const route = "/"; await send("Page.navigate", { url: origin + route }); await until("Page mount", () => evaluate("document.readyState === 'complete' && !!document.querySelector('#app button')"), Boolean); const check = (name, actual, passed, input = { route }) => { cases.push({ input, actual, assertions: [{ name, passed }] }); if (!passed) throw new Error(name); }; if (["product:model-tree-properties", "product:multi-object-viewport"].includes(capabilityId)) { const fixture = path.join(root, "web/public/samples/freecad-sample.step"); check("Existing native STEP sample is available", { fixture }, fs.existsSync(fixture)); const document = await send("DOM.getDocument"); const input = await send("DOM.querySelector", { nodeId: document.root.nodeId, selector: 'input[type="file"]' }); for (let count = 1; count <= 2; count++) { await send("DOM.setFileInputFiles", { nodeId: input.nodeId, files: [fixture] }); await until("Native file import creates a document object", () => evaluate("({ count:document.querySelectorAll('[role=treeitem]').length, busy:document.querySelector('input[type=file]').disabled, status:document.querySelector('[role=status]')?.textContent })"), value => value.count === count && !value.busy, 60000); } const objectIds = await evaluate("Array.from(document.querySelectorAll('[role=treeitem]')).map(item => item.dataset.objectId)"); check("Repeated native imports retain distinct document objects", objectIds, objectIds.length === 2 && new Set(objectIds).size === 2, { route, file: "freecad-sample.step", imports: 2 }); } if (["product:file-workflows", "product:dynamic-module-session"].includes(capabilityId)) { const fixture = path.join(root, "web/public/samples/freecad-sample.step"); const importFile = async file => { const dom = await send("DOM.getDocument"); const field = await send("DOM.querySelector", { nodeId: dom.root.nodeId, selector: 'input[type=file]' }); await send("DOM.setFileInputFiles", { nodeId: field.nodeId, files: [file] }); return until("User import completes", () => evaluate("({busy:document.querySelector('input[type=file]').disabled,status:document.querySelector('[role=status]').textContent,objects:Array.from(document.querySelectorAll('[role=treeitem]')).map(e=>({id:e.dataset.objectId,label:e.textContent})),properties:document.querySelector('.properties dl')?.innerText})"), value => !value.busy && !value.status.startsWith("Importing"), 60000); }; const first = await importFile(fixture); check("User STEP produces native mesh and a selected document object", first, first.objects.length === 1 && /[1-9][\d,]* triangles/.test(first.status) && /Faces/.test(first.properties ?? ""), { route, file: fixture }); const alternate = path.join(profile, "second-input.stp"); fs.copyFileSync(fixture, alternate); if (capabilityId === "product:dynamic-module-session") { await evaluate("document.querySelectorAll('button.tab')[1].click()"); await until("Second document is empty", () => evaluate("document.querySelectorAll('[role=treeitem]').length"), count => count === 0); } const second = await importFile(alternate); check("Second user file targets the active document", second, second.objects.length === (capabilityId === "product:file-workflows" ? 2 : 1) && second.objects.at(-1).label.includes("second-input.stp"), { route, file: alternate, documentIndex: capabilityId === "product:file-workflows" ? 0 : 1 }); const invalid = path.join(profile, "unsupported.txt"); fs.copyFileSync(fixture, invalid); const rejected = await importFile(invalid); check("Unsupported user file preserves existing document objects", rejected, /Only STEP\/STP/.test(rejected.status) && JSON.stringify(rejected.objects) === JSON.stringify(second.objects), { route, file: invalid }); if (capabilityId === "product:dynamic-module-session") { await evaluate("document.querySelectorAll('button.tab')[0].click()"); const restored = await until("Restore first document objects", () => evaluate("Array.from(document.querySelectorAll('[role=treeitem]')).map(e=>({id:e.dataset.objectId,label:e.textContent}))"), value => value.length === 1); check("Native import session preserves independent document state", restored, JSON.stringify(restored) === JSON.stringify(first.objects), { route, action: "activate", documentIndex: 0 }); const resources = await evaluate("performance.getEntriesByType('resource').map(e=>e.name).filter(name=>/worker|kernel/i.test(name))"); check("Native business uses a loaded Worker resource", { resources }, resources.some(name => /worker/i.test(name)), { route, action: "native-import-session" }); } else { await evaluate("Array.from(document.querySelectorAll('button')).find(b=>b.textContent==='移除对象').click()"); const remaining = await until("Remove imported object", () => evaluate("Array.from(document.querySelectorAll('[role=treeitem]')).map(e=>({id:e.dataset.objectId,label:e.textContent}))"), value => value.length === 1); check("Removing second imported file preserves first object", remaining, JSON.stringify(remaining) === JSON.stringify(first.objects), { route, action: "remove-selected" }); } } else if (capabilityId === "product:recovery-status") { const click = text => evaluate(`Array.from(document.querySelectorAll('button')).find(b=>b.textContent===${JSON.stringify(text)}).click()`); const tabs = () => evaluate("Array.from(document.querySelectorAll('button.tab')).map(b=>({text:b.textContent,active:b.classList.contains('active')}))"); const key = "freecad-web-project-ui-checkpoint"; await evaluate(`localStorage.removeItem(${JSON.stringify(key)})`); await click("恢复工程 checkpoint"); const missing = await until("Missing checkpoint status", () => evaluate("document.querySelector('[role=status]').textContent"), text => text === "No checkpoint found"); check("Missing checkpoint is reported", { status: missing }, missing === "No checkpoint found", { route, action: "restore-missing" }); for (let iteration = 0; iteration < 2; iteration++) { await click("新建 Document"); const before = await tabs(); await click("保存工程 checkpoint"); const saved = await evaluate(`localStorage.getItem(${JSON.stringify(key)})`); check("Checkpoint stores current document metadata", { saved }, typeof saved === "string" && saved.includes(`project-document-${iteration + 3}`), { route, action: "save-checkpoint", iteration }); await click("关闭活动 Document"); await until("Document closed before recovery", tabs, value => value.length === before.length - 1); await click("恢复工程 checkpoint"); const restored = await until("Checkpoint restores workspace", tabs, value => value.length === before.length); check("Recovery restores document identities and active document", restored, JSON.stringify(restored) === JSON.stringify(before), { route, action: "restore-checkpoint", iteration }); await evaluate(`localStorage.setItem(${JSON.stringify(key)}, '{invalid-json')`); await click("恢复工程 checkpoint"); const rejected = await until("Corrupt checkpoint rejected", () => evaluate("document.querySelector('[role=status]').textContent"), text => text.startsWith("Checkpoint rejected:")); const preserved = await tabs(); check("Corrupt checkpoint reports failure and preserves workspace", { status: rejected, tabs: preserved }, rejected.length > "Checkpoint rejected:".length && JSON.stringify(preserved) === JSON.stringify(before), { route, action: "restore-corrupt", iteration }); } await evaluate(`localStorage.removeItem(${JSON.stringify(key)})`); } else if (capabilityId === "Part:part.saveReopen") { const click = text => evaluate(`Array.from(document.querySelectorAll('button')).find(button => button.textContent === ${JSON.stringify(text)}).click()`); const input = async (label, value) => { await evaluate(`(() => { const element = document.querySelector('input[aria-label=' + JSON.stringify(${JSON.stringify(label)}) + ']'); element.focus(); element.select(); })()`); await send("Input.insertText", { text: String(value) }); }; const dimensions = async values => { for (const [index, name] of ["Length", "Width", "Height"].entries()) await input(name, values[index]); }; const idle = () => until("Native document operation", () => evaluate("!Array.from(document.querySelectorAll('button')).find(button => button.textContent === '创建长方体')?.disabled"), Boolean, 60000); const nativeVolume = () => evaluate("Number(document.querySelector('[data-native-volume]')?.textContent)"); await dimensions([5, 6, 7]); await click("创建长方体"); await until("Native box creation", () => evaluate("document.querySelectorAll('[role=treeitem]').length"), count => count === 1, 60000); await idle(); check("User dimensions reach native FreeCAD recompute", { volume: await nativeVolume() }, Math.abs(await nativeVolume() - 210) < 1e-6, { dimensions: [5, 6, 7] }); await click("创建长方体"); await until("Second native object", () => evaluate("document.querySelectorAll('[role=treeitem]').length"), count => count === 2); await idle(); const nativeDocument = await send("DOM.getDocument"); const nativeFileInput = await send("DOM.querySelector", { nodeId: nativeDocument.root.nodeId, selector: 'input[type="file"]' }); await send("DOM.setFileInputFiles", { nodeId: nativeFileInput.nodeId, files: [path.join(root, "web/public/samples/freecad-sample.step")] }); await until("STEP enters the same native document", () => evaluate("document.querySelectorAll('[role=treeitem]').length"), count => count === 3, 60000); await idle(); const importedVolume = await nativeVolume(); check("Imported STEP is a native document object", { importedVolume }, importedVolume > 0, { file: "freecad-sample.step" }); await evaluate("document.querySelector('[role=treeitem]').click()"); await input("Object Label", "Persistent native box"); await until("Native object Label edit", () => evaluate("document.querySelector('[role=treeitem][aria-selected=true]')?.textContent"), label => label === "Persistent native box"); await evaluate("document.querySelector('input[aria-label=\"Object Label\"]').blur()"); await dimensions([2, 3, 4]); await click("应用尺寸并重算"); await until("Edited native geometry", nativeVolume, value => Math.abs(value - 24) < 1e-6); await idle(); await click("保存 FCStd 到 OPFS"); await until("OPFS file save", () => evaluate("document.querySelector('.stage > [role=status]')?.textContent"), text => text?.startsWith("Saved "), 60000); await idle(); const fileName = await evaluate("document.querySelector('select[aria-label=\"OPFS document\"]').value"); const file = await evaluate(`(async () => { const directory = await (await navigator.storage.getDirectory()).getDirectoryHandle('freecad-documents'); const bytes = new Uint8Array(await (await (await directory.getFileHandle(${JSON.stringify(fileName)})).getFile()).arrayBuffer()); return { name: ${JSON.stringify(fileName)}, bytes: bytes.byteLength, signature: [...bytes.slice(0, 4)] }; })()`); check("OPFS stores actual native FCStd ZIP bytes", file, file.bytes > 1000 && JSON.stringify(file.signature) === "[80,75,3,4]", { action: "save", fileName }); await dimensions([4, 5, 6]); await click("应用尺寸并重算"); await until("Unsaved native changes", nativeVolume, value => Math.abs(value - 120) < 1e-6); await idle(); await send("Page.reload"); await until("Reloaded workspace and OPFS listing", () => evaluate(`document.readyState === 'complete' && document.querySelectorAll('[role=treeitem]').length === 0 && [...document.querySelectorAll('select[aria-label="OPFS document"] option')].some(option => option.value === ${JSON.stringify(fileName)})`), Boolean); await evaluate(`(() => { const select = document.querySelector('select[aria-label="OPFS document"]'); select.value = ${JSON.stringify(fileName)}; select.dispatchEvent(new Event('change', { bubbles: true })); })()`); await click("打开 OPFS 文档"); await until("Native FCStd restore after reload", () => evaluate("document.querySelectorAll('[role=treeitem]').length"), count => count === 3, 60000); await idle(); const restored = await evaluate("({ objects:document.querySelectorAll('[role=treeitem]').length, label:document.querySelector('[role=treeitem]')?.textContent, volume:Number(document.querySelector('[data-native-volume]')?.textContent) })"); check("Reload restores saved objects and labels, not later unsaved edits", restored, restored.objects === 3 && restored.label === "Persistent native box" && Math.abs(restored.volume - 24) < 1e-6, { action: "reload-and-open", fileName }); await evaluate("document.querySelectorAll('[role=treeitem]')[2].click()"); check("Imported STEP survives OPFS and native FCStd restore", { importedVolume, restoredVolume: await nativeVolume() }, Math.abs(await nativeVolume() - importedVolume) < 1e-5, { fileName, object: "Imported1" }); await evaluate("document.querySelectorAll('[role=treeitem]')[1].click()"); check("Second object retains independent geometry", { volume: await nativeVolume() }, Math.abs(await nativeVolume() - 210) < 1e-6); await dimensions([-1, 6, 7]); await click("应用尺寸并重算"); await idle(); check("Invalid dimensions do not overwrite valid native geometry", { volume: await nativeVolume() }, Math.abs(await nativeVolume() - 210) < 1e-6, { dimensions: [-1, 6, 7] }); await click("移除对象"); const remainingObjects = await until("Native object removal", () => evaluate("document.querySelectorAll('[role=treeitem]').length"), count => count === 2); await idle(); check("Native removal leaves the other objects", { objects: remainingObjects }, remainingObjects === 2, { action: "remove", object: "Box2" }); await click("关闭活动 Document"); const closedObjects = await until("Native document close", () => evaluate("document.querySelectorAll('[role=treeitem]').length"), count => count === 0); await idle(); check("Native document close clears its viewport ownership", { objects: closedObjects }, closedObjects === 0, { action: "close" }); } else if (capabilityId === "product:document-workspace") { const tabs = () => evaluate("Array.from(document.querySelectorAll('button.tab')).map(b=>({text:b.textContent,active:b.classList.contains('active')}))"); const click = text => evaluate(`Array.from(document.querySelectorAll('button')).find(b=>b.textContent===${JSON.stringify(text)}).click()`); const initial = await tabs(); check("At least two independent document tabs", initial, initial.length >= 2); for (let iteration = 0; iteration < 2; iteration++) { const before = await tabs(); await click("新建 Document"); const created = await until("Create document", tabs, value => value.length === before.length + 1); check("New document becomes active", created, created.at(-1).active, { route, action: "create", iteration }); await evaluate("document.querySelector('button.tab').click()"); const switched = await until("Activate first document", tabs, value => value[0]?.active); check("Activation preserves all documents", switched, switched.length === created.length, { route, action: "activate", index: 0 }); await click("关闭活动 Document"); const closed = await until("Close document", tabs, value => value.length === before.length); check("Closing removes only active document", closed, !closed.some(tab => tab.text === switched[0].text) && closed.filter(tab => tab.active).length === 1, { route, action: "close", document: switched[0].text }); } while ((await tabs()).length) { const count = (await tabs()).length; await click("关闭活动 Document"); await until("Close remaining document", tabs, value => value.length === count - 1); } const disabled = await evaluate("Array.from(document.querySelectorAll('button')).find(b=>b.textContent==='关闭活动 Document').disabled"); check("Empty workspace disables close", { disabled }, disabled, { route, action: "close-all" }); } else { const actual = await evaluate(`({ react:!!document.querySelector('#app') && Object.keys(document.querySelector('#app')).some(key=>key.startsWith('__reactContainer')), treeItems:document.querySelectorAll('[role=treeitem]').length, propertyInputs:document.querySelectorAll('[aria-label*=Properties] input, [aria-label*=属性] input').length, canvases:document.querySelectorAll('canvas').length, text:document.querySelector('#app')?.innerText })`); if (capabilityId === "product:react-primary-shell") check("Primary URL mounts existing React product shell", actual, actual.react); else if (capabilityId === "product:desktop-command-ui") { const commands = await evaluate("Array.from(document.querySelectorAll('button')).map(b => b.textContent?.trim()).filter(Boolean)"); check("Desktop command UI exposes actionable commands", { commands }, commands.length >= 3 && commands.some(text => /新建|导入|关闭|保存|命令|command/i.test(text))); } else if (capabilityId === "product:task-panel") { const registration = JSON.parse(execFileSync("python3", ["tools/record_runtime_issue.py", capabilityId, "--error", "Task panel is present in the active product shell", "--args-json", '{"route":"/"}', "--check-only"], { cwd: root, encoding: "utf8" })); check("Task-panel fault registration accepts the product ID without a desktop alias", registration, registration.status === "valid" && registration.capabilityId === capabilityId && registration.module === "Web", { capabilityId, checkOnly: true }); const panel = await evaluate("({ panels:document.querySelectorAll('[aria-label=\"Task: Box dimensions\"]').length })"); check("Mounted box task panel", panel, panel.panels === 1); const click = text => evaluate(`Array.from(document.querySelectorAll('button')).find(b=>b.textContent===${JSON.stringify(text)}).click()`); const dimensions = values => evaluate(`(() => { ${JSON.stringify(values)}.forEach((value,index) => { const input = document.querySelectorAll('[aria-label="Task: Box dimensions"] input')[index]; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input,String(value)); input.dispatchEvent(new Event('input',{bubbles:true})); }); })()`); const volume = () => evaluate("Number(document.querySelector('[data-native-volume]')?.textContent)"); await dimensions([2,3,4]); await click("创建长方体"); await until("Native task creation", volume, value => value === 24, 60000); for (const values of [[3,4,5], [4,5,6]]) { await until("Task idle", () => evaluate("!document.querySelector('[aria-label=\"Task: Box dimensions\"]').disabled"), Boolean); await dimensions(values); await click("应用尺寸并重算"); const actual = await until("Native task recompute", volume, value => value === values.reduce((a,b)=>a*b), 60000); check("Task applies user dimensions to native object", {volume:actual}, actual === values.reduce((a,b)=>a*b), {values}); } await until("Task idle", () => evaluate("!document.querySelector('[aria-label=\"Task: Box dimensions\"]').disabled"), Boolean); await dimensions([9,9,9]); await click("取消尺寸编辑"); const cancelled = await evaluate("Array.from(document.querySelectorAll('[aria-label=\"Task: Box dimensions\"] input')).map(input=>Number(input.value))"); check("Cancel restores committed dimensions without changing geometry", {cancelled,volume:await volume()}, JSON.stringify(cancelled) === '[4,5,6]' && await volume() === 120); } else if (capabilityId === "product:workbench-navigation") { await evaluate("document.querySelectorAll('button.tab')[1].click()"); const active = await evaluate("new URL(location.href).searchParams.get('document')"); for (const module of ['Part','Draft']) { await evaluate(`document.querySelector('[data-route="module-${module}"]').click()`); const state = await until("Workbench destination", () => evaluate("({path:location.pathname,module:new URL(location.href).searchParams.get('module'),document:new URL(location.href).searchParams.get('document'),mounted:!!document.querySelector('#app button')})"), value => value.path === '/workbench.html' && value.module === module && value.mounted); check("Workbench navigation preserves active document and selects destination", state, !!active && state.document === active, {module,document:active}); const selected = await until("Selected workbench module", () => evaluate("document.querySelector('button.module.selected strong')?.textContent"), value => value === module); check("Mounted workbench selects the requested module", { selected }, selected === module, { module }); const links = await evaluate("Array.from(document.querySelectorAll('.global-nav a[data-route^=\"module-\"]')).map(link => new URL(link.href).searchParams.get('document'))"); check("Workbench destination links retain the active document", { links }, links.length > 0 && links.every(document => document === active), { document: active }); } } else if (capabilityId === "product:model-tree-properties") { check("Primary workspace exposes selectable tree and editable properties", actual, actual.treeItems > 0 && actual.propertyInputs > 0); await evaluate("document.querySelector('[role=treeitem]').click()"); const selected = await evaluate("document.querySelector('[role=treeitem][aria-selected=true]')?.textContent"); check("Tree selection identifies the current object", { selected }, typeof selected === "string" && selected.trim().length > 0); for (const label of ["Acceptance object A", "Acceptance object B"]) { const edited = await evaluate(`(() => { const panel = document.querySelector('[aria-label*=Properties], [aria-label*=属性]'); const input = Array.from(panel?.querySelectorAll('input') ?? []).find(element => /label|标签/i.test([element.name, element.getAttribute('aria-label'), ...Array.from(element.labels ?? []).map(label => label.textContent)].join(' '))); if (!input) return { error: 'Selected object Label editor is missing' }; const previous = input.value; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, ${JSON.stringify(label)}); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); input.blur(); return { previous }; })()`); check("Selected object exposes a Label editor", edited, !edited.error, { route, action: "edit-label", label }); const updated = await until("Object label update", () => evaluate("document.querySelector('[role=treeitem][aria-selected=true]')?.textContent"), value => value?.includes(label)); check("Edited Label propagates to selected tree object", { updated }, updated.includes(label), { route, action: "edit-label", label }); } } else { check("Primary viewport provides multiple selectable document objects", actual, actual.canvases > 0 && actual.treeItems >= 2); const snapshot = () => evaluate("Array.from(document.querySelectorAll('[role=treeitem]')).map(item=>({text:item.textContent,selected:item.getAttribute('aria-selected'),visible:item.querySelector('input[type=checkbox]')?.checked}))"); const viewportImage = async () => { await evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))"); const clip = await evaluate("(() => { const r = document.querySelector('canvas').getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height, scale: 1 }; })()"); const data = (await send("Page.captureScreenshot", { format: "png", clip })).data; return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex"); }; for (const index of [0, 1]) { // Imported files can overlap in their native coordinates. Isolate // the selected object to test its pixels without moving geometry. await evaluate(`Array.from(document.querySelectorAll('[role=treeitem]')).forEach((item, other) => { const box = item.querySelector('input[type=checkbox]'); if (other !== ${index} && box.checked) box.click(); })`); await evaluate(`document.querySelectorAll('[role=treeitem]')[${index}].click()`); const selection = await snapshot(); check("Each object can be selected independently", selection, selection[index]?.selected === "true" && selection.filter(item => item.selected === "true").length === 1, { route, action: "select", index }); check("Object has a visibility control", selection, typeof selection[index]?.visible === "boolean", { route, index }); const beforeImage = await viewportImage(); await evaluate(`document.querySelectorAll('[role=treeitem]')[${index}].querySelector('input[type=checkbox]').click()`); const toggled = await snapshot(); check("Visibility toggle affects only selected object", toggled, toggled[index].visible !== selection[index].visible && toggled.every((item, other) => other === index || item.visible === selection[other].visible), { route, action: "toggle-visibility", index }); const toggledImage = await viewportImage(); check("Object visibility changes actual viewport pixels", { beforeImage, toggledImage }, beforeImage !== toggledImage, { route, action: "toggle-visibility", index }); await evaluate(`document.querySelectorAll('[role=treeitem]')[${index}].querySelector('input[type=checkbox]').click()`); const restored = await snapshot(); const restoredImage = await viewportImage(); check("Restoring visibility restores object state and viewport", { restored, restoredImage }, restored.every((item, other) => item.visible === selection[other].visible) && restoredImage === beforeImage, { route, action: "restore-visibility", index }); await evaluate("document.querySelectorAll('[role=treeitem] input[type=checkbox]').forEach(box => { if (!box.checked) box.click(); })"); } await evaluate("document.querySelectorAll('button.tab')[1].click()"); const emptyDocument = await until("Activate empty document", () => evaluate("document.querySelectorAll('[role=treeitem]').length"), count => count === 0); check("Document switch isolates imported objects", { objects: emptyDocument }, emptyDocument === 0, { route, action: "switch-document" }); await evaluate("document.querySelectorAll('button.tab')[0].click()"); const restoredObjects = await until("Restore original document", snapshot, value => value.length === 2); check("Switching back restores both objects", restoredObjects, restoredObjects.length === 2, { route, action: "restore-document" }); const cameraBefore = await viewportImage(); const bounds = await evaluate("(() => { const r = document.querySelector('canvas').getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; })()"); await send("Input.dispatchMouseEvent", { type: "mouseWheel", ...bounds, deltaX: 0, deltaY: -160 }); await new Promise(resolve => setTimeout(resolve, 1000)); const cameraAfter = await viewportImage(); check("Camera zoom changes rendered geometry", { cameraBefore, cameraAfter }, cameraBefore !== cameraAfter); const idle = await evaluate(`new Promise(resolve => { const start = performance.now(), before = window.__viewportDraws; setTimeout(() => resolve({ milliseconds: performance.now()-start, draws: window.__viewportDraws-before }), 750); })`); check("Settled viewport stops drawing", idle, idle.draws === 0); const clip = await evaluate("(() => { const r = document.querySelector('canvas').getBoundingClientRect(); return {x:r.x+scrollX,y:r.y+scrollY,width:r.width,height:r.height,scale:1}; })()"); const screenshot = (await send("Page.captureScreenshot", { format: "png", clip })).data; const pixels = await evaluate(`new Promise(resolve => { const source = new Image(); source.onload = () => { const capture = document.createElement('canvas'); capture.width = source.width; capture.height = source.height; const context = capture.getContext('2d'); context.drawImage(source, 0, 0); const data = context.getImageData(0, 0, capture.width, capture.height).data; const colors = new Set(); for (let offset = 0; offset < data.length; offset += 4) colors.add(data.slice(offset, offset + 4).join(',')); resolve({ width: capture.width, height: capture.height, colors: colors.size }); }; source.src = 'data:image/png;base64,${screenshot}'; })`); check("Viewport contains nonuniform rendered pixels", pixels, pixels.width > 0 && pixels.height > 0 && pixels.colors > 1); } } const coverage = capabilityId === "product:task-panel" ? { resolvedIssueIds: ["runtime:dc4f0154b95b844119ed"], resolvedFailureKeys: ["0c8fd14532dc432acf36", "fd199e315318e583db08"] } : capabilityId === "product:workbench-navigation" ? { resolvedIssueIds: ["runtime:919379a9079dbf4c2059", "runtime:f54206349617d8d16c0d"], resolvedFailureKeys: ["0d85ccaa20bf7fa3c8f8"] } : {}; results.push({ capabilityId: requestedId, status: "passed", cases, ...coverage }); } catch (error) { results.push({ capabilityId: requestedId, status: "failed", cases, error: String(error) }); } } } catch (error) { for (const capabilityId of ids.filter(id => !results.some(result => result.capabilityId === id))) results.push({ capabilityId, status: "failed", cases: [], error: String(error) }); } finally { for (const request of pending.values()) clearTimeout(request.timer); socket?.close(); for (const { child, closed } of processes.reverse()) { if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); await closed.catch(() => {}); } fs.rmSync(profile, { recursive: true, force: true }); } fs.writeSync(1, JSON.stringify({ schema: "freecad-web-business-cases/v1", results }) + "\n"); process.exit(results.every(result => result.status === "passed") ? 0 : 1);