/** * 浏览器直接消费 /package 的编译 ESM:不依赖 TypeScript stripping 或应用 bundler。 * 真实测试 module Worker、PNG 解码、SHA-256 及浏览器存储恢复;网络显式阻止,不验证后端写入。 */ import {createRegistry,builtinComponents,createDocument} from '/package/modules/canvas-core/index.js'; import {openProjectEngine} from '/package/modules/canvas-runtime/index.js'; import {createFoundryPersistence} from '/package/modules/canvas-service/index.js'; import {createBrowserStorage,createBrowserDigests,createBrowserImageDecoder} from '/package/modules/canvas-runtime/browser.js'; // 由浏览器检查入口调用并返回已通过场景的文字;所有断言在实际浏览器能力上执行。 export async function run(){ // 最小断言在页面端抛错,由外层 runner 读取失败;results 只有对应断言完成后才追加。 const assert=(condition,message)=>{if(!condition)throw Error(message);},results=[]; // 每次使用随机数据库名,创建生产 storage、digest 和 image 适配器,避免复用其他测试数据库。 const name=`headless-package-${crypto.randomUUID()}`,storage=createBrowserStorage({name}),digests=createBrowserDigests(),images=createBrowserImageDecoder(); // 独立随机项目和空文档基线;重开时仍使用这组 scope 和同一 storage。 const scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:crypto.randomUUID()},registry=createRegistry(builtinComponents()),document=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]); // 记录 Worker URL 和活跃数;保存原生构造器以便清理时恢复全局对象。 const workerUrls=[],NativeWorker=globalThis.Worker;let active=0; // 包装真实 Worker 而非模拟消息:构造成功后计数,terminate 只扣减一次,仍调用原生终止方法。 globalThis.Worker=class extends NativeWorker{ // 先完成原生构造,再记录 URL 与活跃计数;保留正常 Worker 消息和错误行为。 constructor(url,...args){super(url,...args);workerUrls.push(String(url));active++;this.live=true;} // 同一实例多次终止只扣减一次计数,避免负数掩盖泄漏;每次仍调用原生 terminate。 terminate(){if(this.live){this.live=false;active--;}super.terminate();}}; let engine; try{ // 用 OffscreenCanvas 生成真实 4×2 PNG,检查编译图片 Worker 的尺寸结果。 const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}); const dimensions=await images.decode(bytes);assert(dimensions.width===4&&dimensions.height===2,'compiled image Worker dimensions'); // 将编译哈希 Worker 结果与浏览器原生 SHA-256 对照,不使用预设摘要跳过实际字节读取。 const digest=await digests.digest(bytes),expected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join('');assert(digest===expected,'compiled digest Worker SHA256'); // Worker 必须请求发布的 .js 文件,任务结束后活跃数为零;这验证 URL 布局和正常结束时的释放。 assert(workerUrls.some(url=>url.endsWith('/image.worker.js'))&&workerUrls.some(url=>url.endsWith('/digest.worker.js')),'Worker URLs did not resolve emitted JS');assert(active===0,'compiled Workers leaked'); results.push('compiled image and digest module Workers load emitted JS, decode a real PNG, match native SHA256 and terminate'); // 再验证 16 MiB 数据摘要及释放;耗时仅记为本机观察值,没有性能 SLA 断言。 const largeBytes=new Blob([new Uint8Array(16*1024*1024).fill(97)]),largeExpected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await largeBytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join(''),started=performance.now(),largeDigest=await digests.digest(largeBytes),digestMs=performance.now()-started;assert(largeDigest===largeExpected&&active===0,'large compiled digest returned wrong bytes or retained its Worker');results.push('16 MiB compiled digest Worker matched SHA256 in '+digestMs.toFixed(1)+' ms (observation, not an SLA)'); // 显式禁止网络派发,单独验证实际浏览器持久化适配器,避免引入虚构后端协议。 // 借用真实编译端口但声明不支持任何请求,因此编辑只保存在本地;若意外进入网络就直接失败。 const persistence={...createFoundryPersistence({registry,rootId:scope.projectId,workspaceId:scope.workspaceId,transport:async()=>{throw Error('unexpected network');}}),supportsRequest:()=>false}; // 以相同 scope 重新装配引擎,触发真实浏览器存储的 owner 申请和镜像恢复。 const open=()=>openProjectEngine({scope,registry,document,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:()=>crypto.randomUUID()}); engine=await open(); // 创建一次文本元素和一个临时视图,前者及历史应恢复,后者必须随旧会话消失。 const target={id:crypto.randomUUID(),incarnation:1}; assert(engine.editor.dispatch({type:'element.create',element:{...target,ownerCanvasId:scope.projectId,componentId:'text',dataVersion:1,structure:'node',data:{title:'Packed recovery',text:'persisted'},geometry:{x:12,y:24,width:160,height:80,rotation:0}}}).status==='applied','compiled command rejected'); engine.sessions.attach('temporary-view'); // 等待本地保存后关闭/重开,检查修订不回退、文档和一个 undo 单元保留、views 为空。 await engine.settle();const revision=engine.getSnapshot().rootRevision;await engine.close();engine=await open(); assert(engine.getSnapshot().rootRevision>=revision,'compiled IndexedDB recovery revision regressed');assert(engine.getSnapshot().document.elements.get(target.id)?.data.text==='persisted','compiled durable document lost');assert(engine.getSnapshot().history.undo.length===1,'compiled durable history lost');assert(engine.sessions.getSnapshot().views.size===0,'transient sessions persisted');await engine.settle(); results.push('compiled browser storage and engine claim/release Web Locks and recover an edited document/history from IndexedDB without persisting views'); // 仅在全部 Worker 和存储断言通过后返回场景结果。 return results; // 清理当前引擎和适配器,恢复原生 Worker,并发起删除测试数据库;这里不等待 deleteDatabase 的完成事件。 }finally{await engine?.close();images.close();digests.close();storage.close();globalThis.Worker=NativeWorker;indexedDB.deleteDatabase(name);} }