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(" { 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;i20) 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(" 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 }); }