399 lines
36 KiB
JavaScript
399 lines
36 KiB
JavaScript
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);
|