Files
FreeCADWasmDev/tools/techdraw-browser-cases.mjs
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

267 lines
19 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { once } from "node:events";
const origin = process.env.FREECAD_BROWSER_ORIGIN ?? "http://127.0.0.1:5188";
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-techdraw-browser-"));
const artifacts = path.resolve("docs/current/techdraw-browser");
fs.mkdirSync(artifacts, { recursive: true });
const chrome = spawn("/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 closed = once(chrome, "exit");
let socket;
const pending = new Map();
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)}`);
}
async function send(method, params = {}) {
const id = ++sequence;
const detail = method === "Runtime.evaluate" ? String(params.expression).replace(/\s+/g, " ").slice(0, 240) : params.url ?? "";
const started = performance.now();
if (process.env.FREECAD_TECHDRAW_TRACE === "1") fs.writeSync(2, `TechDraw browser start ${id} ${method}: ${detail}\n`);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${method} timed out at ${detail}`)); }, 45000);
pending.set(id, { resolve: result => {
if (process.env.FREECAD_TECHDRAW_TRACE === "1") fs.writeSync(2, `TechDraw browser completed ${id}: ${(performance.now() - started).toFixed(1)} ms\n`);
resolve(result);
}, reject, timer });
socket.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression) {
const result = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
return result.result?.value;
}
try {
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(r => r.json());
socket = new WebSocket(targets.find(t => t.type === "page").webSocketDebuggerUrl);
socket.addEventListener("message", event => {
const message = JSON.parse(event.data);
if (process.env.FREECAD_TECHDRAW_TRACE === "1" && message.method === "Runtime.consoleAPICalled") {
fs.writeSync(2, `TechDraw browser console: ${JSON.stringify(message.params.args.map(value => value.value ?? value.description))}\n`);
}
const 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("Runtime.enable");
await send("Emulation.setDeviceMetricsOverride", { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
await send("Page.navigate", { url: `${origin}/techdraw.html` });
const state = () => evaluate("({isolated:crossOriginIsolated,state:window.__freecadTechDrawState,text:document.querySelector('[role=status]')?.textContent})");
const initial = await until("Native drawing", state, value => ["success", "error"].includes(value?.state?.stage));
assert.equal(initial.state.stage, "success", JSON.stringify(initial));
assert.equal(initial.state.response.result.volume, 17280);
await evaluate(`{ const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
for (const [id, value] of [['length','11'],['width','6'],['height','3']]) {
const input = document.getElementById(id); set.call(input, value); input.dispatchEvent(new Event('input', {bubbles:true}));
} }`);
await evaluate("document.getElementById('run').click()");
await until("Edited drawing", state, value => Math.abs((value?.state?.response?.result?.volume ?? 0) - 198) < 1e-8);
await send("Page.setDownloadBehavior", { behavior: "allow", downloadPath: profile });
await evaluate("document.getElementById('export').click()");
const download = path.join(profile, "TechDrawDrawing.svg");
await until("SVG download", () => fs.existsSync(download), Boolean);
assert.ok(fs.readFileSync(download, "utf8").includes("<path"));
const checks = [];
for (const [width, height] of [[1440, 900], [390, 844]]) {
await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: width < 700 });
const layout = await evaluate(`(async () => {
const image = document.querySelector('img.drawing'); await image.decode();
const canvas = document.createElement('canvas'); canvas.width=320;canvas.height=240;
const context = canvas.getContext('2d');context.drawImage(image,0,0,320,240);
const pixels=context.getImageData(0,0,320,240).data;let painted=0;
for(let i=0;i<pixels.length;i+=4) if(pixels[i+3]>20) painted++;
return {painted,overflow:document.documentElement.scrollWidth>innerWidth,
bounds:[image.getBoundingClientRect().width,image.getBoundingClientRect().height]};
})()`);
assert.equal(layout.overflow, false);
assert.ok(layout.painted > 100, JSON.stringify(layout));
const screenshot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true });
fs.writeFileSync(path.join(artifacts, `${width}.png`), Buffer.from(screenshot.data, "base64"));
checks.push({ width, height, ...layout });
}
const routed = await evaluate(`(async()=>{
const {WebModuleRouter}=await import('/src/module-router.ts'); const router=new WebModuleRouter();
try {await router.loadManifest('/modules/manifest.json');const result=router.request('TechDraw','techdraw.createRecompute',
{schema:'freecad-web-techdraw/frontend-drawing/v1',module:'TechDraw',action:'create/recompute',documentId:'RouterDrawing',box:{length:5,width:7,height:2}});
const drawing=await result;
const saved=await router.request('TechDraw','techdraw.nativeSaveReopen',
{schema:'freecad-web-techdraw/save-reopen/v1',module:'TechDraw',action:'save/reopen',sourceEntry:'src/Mod/TechDraw/App/AppTechDraw.cpp',documentId:'RouterDrawing'});
if(saved.kind!=='result') throw new Error(JSON.stringify(saved));
return {drawing,saved:{documentId:saved.result.documentId,objectCount:saved.result.objectCount,byteLength:saved.result.bytes.byteLength,size:saved.result.size},loaded:router.listLoaded()};
}finally{router.dispose();}
})()`);
assert.equal(routed.drawing.kind, "result", JSON.stringify(routed));
assert.ok(Math.abs(routed.drawing.result.volume - 70) < 1e-8);
assert.equal(routed.saved.documentId, "RouterDrawing");
assert.equal(routed.saved.objectCount, routed.drawing.result.document.objectCount);
assert.ok(routed.saved.byteLength > 0);
assert.equal(routed.saved.byteLength, routed.saved.size);
assert.equal(routed.loaded.length, 1);
assert.equal(routed.loaded[0].worker, "techdraw-python.worker.ts");
await evaluate("document.getElementById('reset').click()");
await until("Reset drawing", state, value => value?.state?.response?.result?.volume === 17280);
await send("Emulation.setDeviceMetricsOverride", { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false });
await send("Page.navigate", { url: `${origin}/workbench.html?module=TechDraw&command=techdraw.createRecompute` });
await until("Workbench ready", () => evaluate("!!document.querySelector('#payload-editor')"), Boolean);
const workbench = () => evaluate("window.__freecadWorkbenchState");
// Route an explicitly named drawing while the shell still has its initial document selected.
await evaluate(`{
const input = document.querySelector('#payload-editor');
const payload = JSON.parse(input.value);
payload.documentId = 'TechDrawDrawing';
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(input, JSON.stringify(payload));
input.dispatchEvent(new Event('input', {bubbles:true}));
}`);
await evaluate("document.querySelector('button.run').click()");
const created = await until("Workbench drawing", workbench, value => !!value?.response);
assert.equal(created.response.kind, "result", JSON.stringify(created));
assert.equal(created.response.result.volume, 17280);
assert.equal(created.response.result.document.name, "TechDrawDrawing");
assert.equal(await evaluate("document.querySelector('figure figcaption')?.textContent"), "TechDrawDrawing / Page / View");
assert.ok(await evaluate("Array.from(document.querySelectorAll('.stat')).some(item=>item.textContent.includes('TechDrawDrawing.View'))"));
let detailRecord;
if (process.env.FREECAD_TECHDRAW_BROWSER_CASE !== "image") {
await until("Detail command available", () => evaluate("Array.from(document.querySelectorAll('.matrix-list button')).some(button=>button.querySelector('span')?.textContent==='TechDraw_DetailView')"), Boolean);
await evaluate("Array.from(document.querySelectorAll('.matrix-list button')).find(button=>button.querySelector('span')?.textContent==='TechDraw_DetailView').click()");
// Deliberately omit document and baseView: the next command must use the live selection.
const detailInput = { anchorX: 0, anchorY: 0, radius: 12, scale: 2, x: 180, y: 100, reference: "A" };
await evaluate(`{const input=document.querySelector('#payload-editor');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,${JSON.stringify(JSON.stringify(detailInput))});input.dispatchEvent(new Event('input',{bubbles:true}));}`);
await evaluate("document.querySelector('button.run').click()");
const detail = await until("Workbench detail", workbench, value => !!value?.response);
assert.equal(detail.response.kind, "result", JSON.stringify(detail));
detailRecord = detail.response.result.records.find(item => item.apiId === "TechDraw:desktop:TechDraw_DetailView");
assert.ok(["partial", "exposed"].includes(detailRecord?.status), JSON.stringify(detail));
assert.equal(detailRecord.result.document, "TechDrawDrawing");
assert.equal(detailRecord.result.baseView, "View");
assert.ok(detailRecord.result.edgeCount > 0);
assert.ok(detailRecord.result.svg.includes("<path"));
assert.equal(await evaluate("document.querySelector('figure figcaption')?.textContent"), `TechDrawDrawing / Page / ${detailRecord.result.view}`);
}
await until("Image command available", () => evaluate("Array.from(document.querySelectorAll('.matrix-list button')).some(button=>button.querySelector('span')?.textContent==='TechDraw_Image')"), Boolean);
await evaluate("Array.from(document.querySelectorAll('.matrix-list button')).find(button=>button.querySelector('span')?.textContent==='TechDraw_Image').click()");
await evaluate(`{
const input=document.querySelector('#payload-editor');
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,JSON.stringify({page:'Page',owner:'View'}));
input.dispatchEvent(new Event('input',{bubbles:true}));
}`);
await evaluate(`{
const bytes=new Uint8Array(58), data=new DataView(bytes.buffer);
bytes.set([66,77]);data.setUint32(2,58,true);data.setUint32(10,54,true);data.setUint32(14,40,true);
data.setInt32(18,1,true);data.setInt32(22,1,true);data.setUint16(26,1,true);data.setUint16(28,24,true);
data.setUint32(34,4,true);bytes.set([0,0,255],54);
const transfer=new DataTransfer();transfer.items.add(new File([bytes],'red.bmp',{type:'image/bmp'}));
const input=document.querySelector('input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
}`);
await until("Image upload read", () => evaluate("JSON.parse(document.querySelector('#payload-editor').value).imageBase64"), Boolean);
await evaluate("document.querySelector('button.run').click()");
const inserted = await until("Workbench image", workbench, value => !!value?.response);
assert.equal(inserted.response.kind, "result", JSON.stringify(inserted));
const imageRecord = inserted.response.result.records.find(item => item.apiId === "TechDraw:desktop:TechDraw_Image");
assert.ok(["exposed", "success"].includes(imageRecord.status), JSON.stringify(imageRecord));
assert.equal(imageRecord.code, "native_image_created");
assert.equal(imageRecord.result.document, "TechDrawDrawing");
const imagePixels = await evaluate(`(async()=>{
const image=document.querySelector('img[src^="data:image/bmp"]');await image.decode();
const canvas=document.createElement('canvas');canvas.width=canvas.height=1;
const context=canvas.getContext('2d');context.drawImage(image,0,0);
return Array.from(context.getImageData(0,0,1,1).data);
})()`);
assert.deepEqual(imagePixels, [255,0,0,255]);
const cancelImage = async () => {
await evaluate("Array.from(document.querySelectorAll('button')).find(button=>button.textContent==='取消').click()");
const cancelled = await until("Workbench image cancellation", workbench,
value => value?.response?.kind === "error"
|| value?.response?.result?.records?.some(record => record.status === "cancelled"));
assert.equal(cancelled.response.kind, "result", JSON.stringify(cancelled));
const record = cancelled.response.result.records.find(item => item.apiId === "TechDraw:desktop:TechDraw_Image");
assert.equal(record?.status, "cancelled", JSON.stringify(cancelled));
assert.equal(record.code, "image_insertion_cancelled");
const imageCount = await evaluate("document.querySelectorAll('img[src^=\"data:image/bmp\"]').length");
assert.equal(imageCount, 1);
return { record, imageCount };
};
const cancelledImage = await cancelImage();
const executeWorkbench = async (command, args, desktop = false) => {
await evaluate(`Array.from(document.querySelectorAll(${JSON.stringify(desktop ? '.matrix-list button' : 'button.command')})).find(button=>button.querySelector('span')?.textContent===${JSON.stringify(command)}).click()`);
await evaluate(`{const input=document.querySelector('#payload-editor');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,${JSON.stringify(JSON.stringify(args))});input.dispatchEvent(new Event('input',{bubbles:true}));}`);
await evaluate("document.querySelector('button.run').click()");
const result = await until(`Workbench ${command}`, workbench, value => !!value?.response);
assert.equal(result.response.kind, "result", JSON.stringify(result));
return result.response.result;
};
const drawingArgs = documentId => ({schema:'freecad-web-techdraw/frontend-drawing/v1',module:'TechDraw',action:'create/recompute',documentId});
await executeWorkbench('techdraw.createRecompute', {...drawingArgs('ImageIsolation'), box:{length:9,width:7,height:5}});
const greenBytes = Buffer.from(imageRecord.result.imageBase64, 'base64');
greenBytes.set([0,255,0],54);
const green = await executeWorkbench('TechDraw_Image', {documentId:'ImageIsolation',page:'Page',owner:'View',imageBase64:greenBytes.toString('base64')}, true);
assert.equal(green.records[0].code, 'native_image_created', JSON.stringify(green));
const retainedImages = [];
for (const [documentId, expected] of [['TechDrawDrawing',[255,0,0,255]],['ImageIsolation',[0,255,0,255]]]) {
const retained = await executeWorkbench('techdraw.createRecompute', drawingArgs(documentId));
assert.ok(retained.document.objectCount >= 5);
const actual = await evaluate(`(async()=>{
const images=Array.from(document.querySelectorAll('img[src^="data:image/bmp"]'));
if(images.length!==1) return {count:images.length};
await images[0].decode();const canvas=document.createElement('canvas');canvas.width=canvas.height=1;
const context=canvas.getContext('2d');context.drawImage(images[0],0,0);
return {count:images.length,pixel:Array.from(context.getImageData(0,0,1,1).data),caption:images[0].parentElement.querySelector('figcaption').textContent};
})()`);
assert.equal(actual.count,1, `Retained image missing after returning to ${documentId}`);
assert.deepEqual(actual.pixel,expected);
assert.ok(actual.caption.startsWith(`${documentId} / Page / `));
retainedImages.push({documentId,expected,actual,objectCount:retained.document.objectCount});
}
for (const [width,height] of [[1440,900],[390,844]]) {
await send("Emulation.setDeviceMetricsOverride", {width,height,deviceScaleFactor:1,mobile:width<700});
const screenshot=await send("Page.captureScreenshot",{format:"png",captureBeyondViewport:true});
fs.writeFileSync(path.join(artifacts,`image-${width}.png`),Buffer.from(screenshot.data,"base64"));
}
console.log(JSON.stringify({ schema: "freecad-web-business-cases/v1", results: [{
capabilityId: "TechDraw:desktop:TechDraw_Image", status: "passed", cases: [{
input: { file: "red.bmp", pixel: [255,0,0,255], page: "Page", owner: "View", document: "TechDrawDrawing" },
actual: { imageRecord, imagePixels, checks, retainedImages, routed }, assertions: [
{ name: "Manifest routing retains the same native drawing for creation and FCStd save", passed: routed.loaded.length === 1 && routed.saved.byteLength > 0 },
{ name: "Switching documents and recomputing retains each native image and displays only its document pixels",
passed: retainedImages.length === 2 && retainedImages.every(({actual, expected, documentId}) =>
actual.count === 1 && actual.pixel.every((value, index) => value === expected[index])
&& actual.caption.startsWith(`${documentId} / Page / `)) },
{ name: "Uploaded image inserted through workbench Worker in current document", passed: imageRecord.result.document === "TechDrawDrawing" && imageRecord.code === "native_image_created" },
{ name: "Browser decodes uploaded native image to expected RGBA pixel", passed: imagePixels.every((value, index) => value === [255,0,0,255][index]) },
],
}, {
input: { documentId: "TechDrawDrawing", page: "Page", owner: "View", action: "cancel", imageBase64: imageRecord.result.imageBase64 },
actual: cancelledImage,
assertions: [
{ name: "Workbench cancellation reaches native image command", passed: cancelledImage.record.status === "cancelled"
&& cancelledImage.record.code === "image_insertion_cancelled" },
{ name: "Workbench cancellation preserves the displayed image", passed: cancelledImage.imageCount === 1 },
],
}],
}] }));
} finally {
for (const request of pending.values()) clearTimeout(request.timer);
socket?.close();
chrome.kill("SIGTERM");
await closed;
fs.rmSync(profile, { recursive: true, force: true });
}