From e0e4276e71a55ac6779358043e19e25d6df23357 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 11:11:20 +0800 Subject: [PATCH 1/2] docs(tests): explain browser drivers React fixtures and renderer probes --- .../blob-checkpoint-browser-cases.mjs | 24 ++++++++++ tests/headless/browser-storage.mjs | 24 +++++++++- tests/headless/cdp-client.mjs | 15 ++++++- tests/headless/cdp-client.test.mjs | 9 ++++ tests/headless/pointer-capture.test.mjs | 6 +++ tests/headless/react-hooks-fixture.tsx | 44 +++++++++++++++++++ tests/headless/renderer-extension.tsx | 9 ++++ tests/headless/renderer-performance.mjs | 43 ++++++++++++++++-- tests/headless/renderers.test.mjs | 12 +++++ tests/headless/selection-listener-fixture.tsx | 13 +++++- 10 files changed, 193 insertions(+), 6 deletions(-) diff --git a/tests/headless/blob-checkpoint-browser-cases.mjs b/tests/headless/blob-checkpoint-browser-cases.mjs index 97df120..bed5901 100644 --- a/tests/headless/blob-checkpoint-browser-cases.mjs +++ b/tests/headless/blob-checkpoint-browser-cases.mjs @@ -1,30 +1,54 @@ +/** + * 真实浏览器存储的 Blob checkpoint 场景,由 browser-storage-cases 调用并注入断言函数。 + * 分别处理 DB schema 4→5、checkpoint 编码版本与恢复镜像格式版本,三个版本号不是同一时钟。 + * 覆盖旧行升级、精确字节校验、未来格式、事务回滚、owner 失效与被旧连接阻挡的升级。 + */ import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts'; import {sealRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts'; +// 手工建立旧数据库后再通过生产 storage 升级,直接观察持久化行及重新打开后的恢复结果。 export async function blobCheckpointCase(assert){ + // 随机数据库名隔离场景;镜像含 Unicode、大量文本和固定请求字节,用来检查序列化保持精确内容。 const name=`blob-checkpoint-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},key=JSON.stringify(['a','w','p']),image=revision=>({formatVersion:1,rootRevision:revision,localRevision:revision,payload:{rows:Array.from({length:250},(_,i)=>({id:i,text:`版本 ${revision} 👀 ${'x'.repeat(100)}`})),request:{id:'frozen',body:'{"exact":"original bytes"}'}}}); + // 三个 Promise 工具分别等待 IDB 请求、事务完成/abort 和原始 Blob SHA-256;事务成功不能只看单个 put 成功。 const read=req=>new Promise((resolve,reject)=>{req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);}),done=tx=>new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);}),hash=async blob=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',await blob.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''); + // 手工打开 DB v4 并创建旧表,绕开新适配器自动建库,保证后面确实经历旧 checkpoint 升级。 let raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name,4);request.onupgradeneeded=()=>{for(const store of ['metadata','checkpoints','journal','files'])request.result.createObjectStore(store);};request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);}); + // 把旧对象镜像与其 checksum 一起写入 revision 0,并关闭旧连接后再启动新适配器。 const initial=image(0),checksum=await hash(new Blob([JSON.stringify(initial)]));let tx=raw.transaction(['metadata','checkpoints'],'readwrite'),terminal=done(tx);tx.objectStore('metadata').put({fence:0,throughRevision:0,checkpointRevision:0},key);tx.objectStore('checkpoints').put({image:initial,checksum},[key,0]);await terminal;raw.close();let storage=createBrowserStorage({name,checkpointEvery:1}),leader; try{ + // 先核对旧对象恢复不变,再提交带 sealed 镜像与文件的首个新切点。 leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(initial),'DB v4 legacy checkpoint changed during upgrade');const first=image(1);await storage.commit(leader,{image:sealRecoveryImage(1,1,1,first.payload),files:[{id:'kept',bytes:new Blob(['kept bytes'])}]}); + // DB 应升级为 5;新 checkpoint 存 Blob 而非克隆文档对象,文本和 checksum 覆盖精确序列化字节。 raw=await read(indexedDB.open(name));assert(raw.version===5,'Blob cuts did not fence old DB clients');const saved=await read(raw.transaction('checkpoints').objectStore('checkpoints').get([key,1]));assert(saved.checkpointVersion===1&&saved.bytes instanceof Blob&&!saved.image,'full cut is still a cloned document object');assert(await saved.bytes.text()===JSON.stringify(first),'Blob checkpoint changed serialized bytes');assert(saved.checksum===await hash(saved.bytes),'Blob checksum did not cover exact source bytes'); + // 旧 DB v4 客户端再打开必须得到 VersionError,防止老解释器接受新 Blob 行。 const old=await new Promise(resolve=>{const request=indexedDB.open(name,4);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(old==='VersionError','DB v4 reader accepted Blob checkpoint rows'); + // 用原始 IDB 连接替换指定 checkpoint 行,构造损坏输入后仍等待事务真正完成。 const write=async value=>{const tx=raw.transaction('checkpoints','readwrite'),terminal=done(tx);tx.objectStore('checkpoints').put(value,[key,1]);await terminal;}; + // 分别破坏字节、header、编码版本和 UTF-8;每次恢复检查对应错误,并还原原行供下一场景继续。 for(const [kind,expected]of [['bytes','recovery_checksum_mismatch'],['header','recovery_checksum_mismatch'],['future','recovery_checkpoint_interpreter_unavailable'],['utf8','corrupt_recovery_image']]){ const damaged=structuredClone(saved);if(kind==='bytes')damaged.bytes=new Blob([JSON.stringify(image(9))]);else if(kind==='header')damaged.header.formatVersion=2;else if(kind==='future')damaged.checkpointVersion=2;else{damaged.bytes=new Blob([new Uint8Array([123,34,120,34,58,34,255,34,125])]);damaged.checksum=await hash(damaged.bytes);} await write(damaged);let code;try{await storage.recover(leader);}catch(error){code=error.code;}assert(code===expected,`${kind} cut was accepted: ${code}`);if(kind==='future'){code=undefined;try{await storage.commit(leader,{image:image(2)});}catch(error){code=error.code;}assert(code===expected,'writer replaced a future checkpoint');}await write(saved); } + // 暂时包装原生 put:先排入写请求再抛错,把故障放在事务已安排部分写入的窗口。 const nativePut=IDBObjectStore.prototype.put;let injected=false,error; + // 只在 revision 2 的新 Blob checkpoint 写入点注入异常;其他表/版本继续调用真实 IndexedDB。 IDBObjectStore.prototype.put=function(value,k){const request=nativePut.call(this,value,k);if(this.name==='checkpoints'&&value?.checkpointVersion===1&&value.header.rootRevision===2){injected=true;throw Error('abort_blob_checkpoint');}return request;}; + // 带新文件的提交失败后恢复原 put,再验证旧 Root 仍可读且新文件字节没有泄漏。 try{await storage.commit(leader,{image:image(2),files:[{id:'orphan',bytes:new Blob(['must rollback'])}]});}catch(cause){error=cause.message;}finally{IDBObjectStore.prototype.put=nativePut;} assert(injected&&error==='abort_blob_checkpoint','checkpoint fault was not injected after put');assert((await storage.recover(leader)).rootRevision===1&&await storage.readFile(leader,'orphan')===undefined,'aborted Blob cut leaked head or file bytes'); + // 释放并重建适配器,检查 Blob 与同批文件在新 owner 下仍能恢复。 await leader.release();storage.close();storage=createBrowserStorage({name,checkpointEvery:1});leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(first),'fresh adapter lost Blob content');assert(await(await storage.readFile(leader,'kept')).text()==='kept bytes','Blob reopening lost atomic file'); + // 大切点准备期间释放 owner,完成结果必须是 storage_leader_lost;重新 claim 后不得看到新切点或晚到文件。 const large=sealRecoveryImage(1,2,2,{rows:Array.from({length:16000},(_,i)=>({id:i,text:'x'.repeat(100)}))});let failure;const pending=storage.commit(leader,{image:large,files:[{id:'late',bytes:new Blob(['late'])}]}).catch(error=>{failure=error.code;});await Promise.resolve();await leader.release();await pending;assert(failure==='storage_leader_lost','released owner published or changed its cancellation error');leader=await storage.claim(scope);assert((await storage.recover(leader)).rootRevision===1&&await storage.readFile(leader,'late')===undefined,'released owner advanced Blob checkpoint'); + // 关闭原始连接、释放 owner 和 adapter,再发起删除数据库;这里不等待删除完成事件。 }finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);} } +// 保持旧 DB 连接打开以阻挡升级;同一失败 adapter 保留原错误,旧连接退场后需新 adapter 才能成功。 export async function blockedUpgradeCase(assert){ + // 本场景只需一个仍打开的 v4 数据库,不依赖上一个升级场景留下的数据。 const name=`blocked-upgrade-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},read=req=>new Promise((resolve,reject)=>{req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});let raw=await read(indexedDB.open(name,4)),storage=createBrowserStorage({name}),leader; + // 重复 claim 都应报告 storage_upgrade_blocked;丢弃的升级请求不能在稍后悄悄升级,关旧连接并新建 adapter 后才升到 5。 try{let code;try{await storage.claim(scope);}catch(error){code=error.code;}assert(code==='storage_upgrade_blocked','active old connection did not reject the upgrade');code=undefined;try{await storage.claim(scope);}catch(error){code=error.code;}assert(code==='storage_upgrade_blocked','blocked adapter lost the original failure reason');raw.close();raw=await read(indexedDB.open(name));assert(raw.version===4,'abandoned upgrade changed database version after rejection');raw.close();storage.close();storage=createBrowserStorage({name});leader=await storage.claim(scope);assert(await storage.recover(leader)===null,'fresh adapter could not open after blocked request was retired');raw=await read(indexedDB.open(name));assert(raw.version===5,'fresh upgrade failed after abandoned open');} finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);} } diff --git a/tests/headless/browser-storage.mjs b/tests/headless/browser-storage.mjs index 79cb99a..bce35e7 100644 --- a/tests/headless/browser-storage.mjs +++ b/tests/headless/browser-storage.mjs @@ -1,4 +1,8 @@ -/** Real Chromium IndexedDB/Web Locks checks, in an isolated temporary browser profile. */ +/** + * 使用隔离 Chrome profile 运行真实 IndexedDB/Web Locks 场景,或以 --package 消费构建后的浏览器 ESM。 + * 本地 HTTP server 只提供测试页面、源码/发布文件和可控 NDJSON 流;普通模式通过 Node 去除 .ts 类型,不运行应用 bundler。 + * 此入口内联了一套简化 CDP 请求表,未使用 cdp-client 的连接级超时/故障保护,不能把 Runtime.evaluate 的 timeout 当作所有命令的截止时间。 + */ import { createServer } from 'node:http'; import { readFile, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -7,18 +11,24 @@ import { spawn } from 'node:child_process'; import { stripTypeScriptTypes } from 'node:module'; import { setTimeout as pause } from 'node:timers/promises'; +// 从脚本位置定位仓库,随机临时 profile 保证测试不读写日常 Chrome 会话。 const root = resolve(import.meta.dirname, '../..'), profile = await mkdtemp(join(tmpdir(), 'endoleap-storage-browser-')); +// --package 只影响发布文件服务和页面导入入口;它不会在这里自动构建 .headless-dist。 const packageMode=process.argv.includes('--package'); +// 按 key 保留挂起的响应与读取次数,供页面测试控制共享读取的首块、尾块和失败尾部。 const readStreams=new Map(); +// 只在回环地址提供 fixture;源码读取或类型去除失败时返回 500 供页面检查报告。 const server = createServer(async (request, response) => { try { const path = new URL(request.url, 'http://localhost').pathname; + // 同一 key 每次读取计数增加,立即发送第一行并保持连接;由独立 release 请求决定何时结束。 if(path==='/loader-stream'){ const url=new URL(request.url,'http://localhost'),key=url.searchParams.get('key'); if(!key){response.writeHead(400).end();return;} const state=readStreams.get(key)??{reads:0};state.reads++;state.response=response;readStreams.set(key,state); response.writeHead(200,{'Content-Type':'application/x-ndjson'});response.write(JSON.stringify({part:0,complete:false})+'\n');return; } + // 查找挂起流,可发送合法完成行或非法 JSON 尾部;返回读次数用于核对共享加载没有重复 fetch。 if(path==='/loader-release'){ const url=new URL(request.url,'http://localhost'),state=readStreams.get(url.searchParams.get('key')); if(!state){response.writeHead(404).end();return;} @@ -26,30 +36,39 @@ const server = createServer(async (request, response) => { response.setHeader('Content-Type','application/json');response.end(JSON.stringify({reads:state.reads}));return; } if (path === '/') { response.setHeader('Content-Type', 'text/html'); response.end('Headless storage checks'); return; } + // 包模式从 .headless-dist 读取,其余只允许 src/lib 和 tests/headless;resolve 后再检查目录前缀。 const isPackage=packageMode&&path.startsWith('/package/'); const filename = isPackage?resolve(root,'.headless-dist',path.slice('/package/'.length)):resolve(root, `.${path}`); if (!(isPackage&&filename.startsWith(`${root}/.headless-dist/`)) && !filename.startsWith(`${root}/src/lib/`) && !filename.startsWith(`${root}/tests/headless/`)) { response.writeHead(404).end(); return; } + // 仅对 .ts 去除类型后提供 JavaScript;.mjs 和编译包文件保持读取内容,不进行应用级转换。 let source = await readFile(filename, 'utf8'); if (filename.endsWith('.ts')) source = stripTypeScriptTypes(source); response.setHeader('Content-Type', 'text/javascript'); response.end(source); } catch (error) { response.writeHead(500).end(String(error)); } }); +// 先绑定随机本地端口,再用独立 profile 启动 headless Chrome;CHROME_BIN 可覆盖默认安装路径。 await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const chrome = spawn(process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ['--headless=new', '--no-first-run', '--disable-background-networking', '--disable-component-update', '--remote-debugging-port=0', `--user-data-dir=${profile}`, `http://127.0.0.1:${server.address().port}/`], { stdio: 'ignore' }); let socket; try { + // 最多轮询 100 次 DevToolsActivePort,每次间隔 100 毫秒;未找到端口即失败,不连接用户现有浏览器。 let port; for (let attempt = 0; attempt < 100; attempt++) { try { port = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); } } if (!port) throw new Error('isolated Chromium did not start'); + // 从本次 Chrome 的调试服务查找 page,建立 WebSocket 后才发送 CDP 命令。 const tabs = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json()); socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); + // pending 仅按 ID 路由 Promise;这里没有每条命令的本地截止定时器。 let next = 0; const pending = new Map(); + // 只处理带 ID 的响应,协议错误拒绝相应 Promise;没有转发无 ID 事件或整连接失败处理。 socket.onmessage = event => { const message = JSON.parse(event.data); if (message.id) { const callbacks = pending.get(message.id); pending.delete(message.id); if (message.error) callbacks.reject(new Error(JSON.stringify(message.error))); else callbacks.resolve(message.result); } }; + // 登记递增 ID 后发送命令,等待对应响应;其行为不同于独立 createCdpCalls 的有界调用器。 const call = (method, params) => new Promise((resolve, reject) => { const id = ++next; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); }); const origin = `http://127.0.0.1:${server.address().port}`; + // 导航到本次 fixture origin,随后最多 100 次检查页面 origin 与 readyState,避免误在空白页执行。 await call('Page.navigate', { url: `${origin}/` }); let loaded = false; for (let attempt = 0; attempt < 100; attempt++) { @@ -58,10 +77,13 @@ try { await pause(50); } if (!loaded) throw new Error('browser test origin did not finish loading'); + // 页面动态导入普通存储场景或编译包场景并等待 run;30 秒 timeout 是该 evaluate 的协议参数。 const result = await call('Runtime.evaluate', { expression: `import('/tests/headless/${packageMode?'package-browser-cases':'browser-storage-cases'}.mjs').then(module => module.run())`, awaitPromise: true, returnByValue: true, timeout: 30000 }); + // 页面异常直接使 runner 失败,只有成功返回的场景消息才逐条打印 PASS。 if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); for (const message of result.result.value) console.log(`PASS ${message}`); } finally { + // 清理连接并请求 Chrome 退出,等待进程 exit 后再关闭 HTTP server 和删除 profile;此等待没有额外强杀截止时间。 socket?.close(); const exited = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await exited; diff --git a/tests/headless/cdp-client.mjs b/tests/headless/cdp-client.mjs index f1154b3..1788a4a 100644 --- a/tests/headless/cdp-client.mjs +++ b/tests/headless/cdp-client.mjs @@ -1,19 +1,32 @@ -/** Bounded protocol calls for isolated browser fixtures, never product code. */ +/** + * 隔离浏览器 fixture 专用的有界 CDP 请求路由器,不作为产品运行时代码。 + * 每个正整数 ID 对应一个 Promise 和截止定时器;连接/目标级故障是整个实例的终态。 + * 本函数不负责建立或关闭 socket,也不提供独立 dispose;外层 runner 持有连接。 + */ +// 接收已连接 socket 和可替换定时器;默认给每个调用设置 30 秒截止定时器,事件由 onEvent 转发。 export function createCdpCalls(socket,{timeoutMs=30000,onEvent=()=>{},setTimer=setTimeout,clearTimer=clearTimeout}={}){ + // pending 是未回应调用的唯一查找表;failure 保留首个致命错误,使未来调用立刻拒绝。 let serial=0,failure;const pending=new Map(); + // 全局失败清除每个截止定时器并拒绝所有 Promise;重复故障不覆盖最初原因。 const fail=error=>{failure??=error;for(const row of pending.values()){clearTimer(row.timer);row.reject(failure);}pending.clear();}; + // 连接关闭和传输错误都不再允许继续发命令。 socket.addEventListener('close',()=>fail(Error('CDP connection closed'))); socket.addEventListener('error',()=>fail(Error('CDP connection failed'))); + // 收到消息先解析 JSON;解析或事件回调异常同样会进入全局失败。 socket.addEventListener('message',event=>{ try{ const message=JSON.parse(event.data); + // 无请求 ID 的消息是事件;先通知观察者,再把崩溃、脱离或对话框视为目标不可继续使用。 if(!message.id){onEvent(message);if(['Inspector.targetCrashed','Inspector.detached','Target.targetCrashed','Page.javascriptDialogOpening'].includes(message.method))fail(Error('CDP target unavailable: '+message.method+' '+JSON.stringify(message.params??{})));return;} + // 已超时/完成的响应无对应 row 时忽略;普通协议 error 只拒绝该请求,不使整个连接失效。 const row=pending.get(message.id);if(!row)return;pending.delete(message.id);clearTimer(row.timer);message.error?row.reject(Error(JSON.stringify(message.error))):row.resolve(message.result); }catch(error){fail(error);} }); + // 为新命令分配递增 ID,先登记 pending 与定时器再发送;send 同步抛错也会拒绝全部在途工作。 return(method,params={})=>new Promise((resolve,reject)=>{ if(failure){reject(failure);return;} const id=++serial,row={resolve,reject,timer:undefined};pending.set(id,row); + // 任意一个调用超时都使整个调用器失败,避免测试随后无限挂在另一个依赖命令。 row.timer=setTimer(()=>fail(Error(`CDP ${method} timed out after ${timeoutMs}ms`)),timeoutMs); try{socket.send(JSON.stringify({id,method,params}));}catch(error){fail(error);} }); diff --git a/tests/headless/cdp-client.test.mjs b/tests/headless/cdp-client.test.mjs index 3a13e2f..c9f1327 100644 --- a/tests/headless/cdp-client.test.mjs +++ b/tests/headless/cdp-client.test.mjs @@ -1,8 +1,17 @@ +/** + * 浏览器测试专用 CDP 调用器的单元测试。用 socket/定时器替身验证请求 ID 路由、截止时间及致命故障,不启动浏览器。 + * 单个协议命令错误只拒绝对应请求;连接或目标级故障则拒绝整个调用器及未来调用。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createCdpCalls} from './cdp-client.mjs'; +// 把 socket 事件、已发送命令和定时器保存在 Map/数组,测试可直接投递乱序响应或手动触发超时。 function fixture(){const handlers=new Map(),sent=[],timers=new Map();let id=0;const socket={addEventListener:(event,callback)=>handlers.set(event,callback),send:text=>sent.push(JSON.parse(text))},events=[],call=createCdpCalls(socket,{timeoutMs:30,onEvent:event=>events.push(event),setTimer:callback=>{timers.set(++id,callback);return id;},clearTimer:key=>timers.delete(key)});return{call,sent,timers,events,event:(name,data)=>handlers.get(name)({data:JSON.stringify(data)})};} +// 两个响应反序返回仍按 ID 完成各自 Promise;无 ID 事件交给 onEvent,成功响应清除对应定时器。 test('CDP responses route by request ID, forward events and release deadline timers',async()=>{const f=fixture(),one=f.call('one'),two=f.call('two');f.event('message',{method:'Runtime.event'});f.event('message',{id:2,result:{value:'two'}});f.event('message',{id:1,result:{value:'one'}});assert.deepEqual(await one,{value:'one'});assert.deepEqual(await two,{value:'two'});assert.equal(f.events.length,1);assert.equal(f.timers.size,0);}); +// 一个调用超时即终止全部 pending 和未来调用;迟到响应被忽略,不产生额外报告读取来掩盖超时。 test('a stalled CDP call rejects all pending work and cannot hide behind another unbounded report read',async()=>{const f=fixture(),one=assert.rejects(f.call('Runtime.evaluate'),/timed out/),two=assert.rejects(f.call('Page.captureScreenshot'),/timed out/);[...f.timers.values()][0]();await Promise.all([one,two]);assert.equal(f.timers.size,0);f.event('message',{id:1,result:{late:true}});await assert.rejects(f.call('Runtime.evaluate'),/timed out/);}); +// 分别注入连接关闭、错误、目标崩溃、脱离和对话框事件;所有情况清空 pending,调用器不会自动接受对话框。 test('connection loss and fatal target events reject pending calls without accepting dialogs',async()=>{for(const event of ['close','error','Inspector.targetCrashed','Target.targetCrashed','Inspector.detached','Page.javascriptDialogOpening']){const f=fixture(),result=assert.rejects(f.call('Runtime.evaluate'),/CDP/);event.includes('.')?f.event('message',{method:event}):f.event(event);await result;assert.equal(f.timers.size,0);assert.deepEqual(f.sent.map(row=>row.method),['Runtime.evaluate']);}}); +// 普通命令错误只拒绝它自己,另一命令仍可成功且两者都清除定时器。 test('one protocol command error does not corrupt another request or leak its timer',async()=>{const f=fixture(),one=assert.rejects(f.call('bad'),/invalid/),two=f.call('good');f.event('message',{id:1,error:{message:'invalid'}});f.event('message',{id:2,result:{ok:true}});await one;assert.deepEqual(await two,{ok:true});assert.equal(f.timers.size,0);}); diff --git a/tests/headless/pointer-capture.test.mjs b/tests/headless/pointer-capture.test.mjs index 400f8b3..a3bc42e 100644 --- a/tests/headless/pointer-capture.test.mjs +++ b/tests/headless/pointer-capture.test.mjs @@ -1,4 +1,10 @@ +/** + * 指针捕获辅助函数的对象替身测试,不分派真实 DOM PointerEvent。 + * 指针 ID 与捕获目标共同决定归属,旧 lostpointercapture 不能取消新目标或重新捕获后的手势。 + */ import {test} from 'node:test';import assert from 'node:assert/strict'; import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from '../../src/lib/canvas-bindings/pointer-capture.ts'; +// move/up/cancel 检查 pointerId;lostpointercapture 还检查事件 target 与当前捕获状态,空 owner 不匹配。 test('pointer move/up/cancel only match their owner and old lost-capture cannot cancel a replacement target',()=>{const capture={},other={},owner={pointerId:7,capture};for(const type of ['pointermove','pointerup','pointercancel']){assert.equal(ownsPointerCapture(owner,{pointerId:8,type}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type}),true);}assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:other}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:capture}),true);assert.equal(ownsPointerCapture(null,{pointerId:7}),false);const recaptured={hasPointerCapture:()=>true};assert.equal(ownsPointerCapture({pointerId:7,capture:recaptured},{pointerId:7,type:'lostpointercapture',target:recaptured}),false);}); +// 成功捕获只释放一次;节点脱离导致的 set/hasPointerCapture 异常作为捕获失败或安全清理处理,不升级为业务错误。 test('failed capture and detached release stay transport failures, while a held capture releases once',()=>{let held=false,releases=0;const target={setPointerCapture(){held=true;},hasPointerCapture(){return held;},releasePointerCapture(){held=false;releases++;}};assert.equal(acquirePointerCapture(target,1),true);releasePointerCapture(target,1);releasePointerCapture(target,1);assert.equal(releases,1);assert.equal(acquirePointerCapture({...target,setPointerCapture(){throw Error('detached');}},2),false);assert.doesNotThrow(()=>releasePointerCapture({...target,hasPointerCapture(){throw Error('detached');}},2));}); diff --git a/tests/headless/react-hooks-fixture.tsx b/tests/headless/react-hooks-fixture.tsx index 4b2a580..4ed423d 100644 --- a/tests/headless/react-hooks-fixture.tsx +++ b/tests/headless/react-hooks-fixture.tsx @@ -1,3 +1,8 @@ +/** + * 供真实浏览器页面调用的 React hooks 场景。组件挂在隐藏容器中,在 StrictMode 下检查订阅路由、手势租约和资源句柄。 + * engine 与 hooks 使用生产实现,存储、上传和文档服务为内存替身;此处不验证远程授权、数据库或图片解码。 + * 断言通过页面全局入口返回文字报告,由外层浏览器 runner 收集;不会在组件导入时自动运行全部场景。 + */ "use client"; import {StrictMode,memo,useEffect,useLayoutEffect} from 'react'; import {createRoot} from 'react-dom/client';import {flushSync} from 'react-dom'; @@ -5,54 +10,93 @@ import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBinding import {createRegistry,builtinComponents,createDocument,applyChanges} from '@/lib/canvas-core/index'; import {openProjectEngine} from '@/lib/canvas-runtime/engine'; import {equal} from '@/lib/canvas-core/values'; +// 浏览器断言失败直接抛错,使外层 Runtime.evaluate 得到失败;不把失败写成通过报告。 const assert=(value:unknown,message:string)=>{if(!value)throw Error(message);}; +// 短暂等待浏览器计时器;until 最多轮询 1000 次,观察 React effect/订阅结果,而非假设 flush 后同步可读。 const tick=()=>new Promise(resolve=>setTimeout(resolve,5));async function until(check:()=>boolean){for(let i=0;i<1000;i++){if(check())return;await tick();}throw Error('hook observation timed out');} +// 装配两个节点和两个 view;partial 模式只发布 a,但完整来源仍含 b,供 unloaded→present/absent 检查。 async function fixture(partial=false){ + // 合法文本节点外壳;版本、身份和 owner 固定,序列号另用于命令/请求 ID。 const node=(id:string)=>({id,incarnation:1,structure:'node' as const,componentId:'text',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:100,height:80,rotation:0},data:{title:id,text:''}});let server=createDocument(partial?'canvas':'hooks',[{id:'canvas',parent:null}],[node('a'),node('b')]),version=1,serial=0,image:any=null,fail=false,readGate:Promise|undefined; + // 分别保存本地 Blob、文档回执及异步错误,read 次数用于验证资源 hooks 共享读取。 const files=new Map(),receipts=new Map(),errors:unknown[]=[];let reads=0; + // 内存存储可注入 quota,readFile 可被 Promise 屏障延迟;不实现真实浏览器锁或 IndexedDB。 const storage={async claim(scope:any){return{scope,fence:1,active:true,async release(){this.active=false;}};},async recover(){return image;},async commit(_leader:any,batch:any){if(fail)throw Error('quota');for(const file of batch.files??[])files.set(file.id,file.bytes);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(_leader:any,id:string){reads++;if(readGate)await readGate;return files.get(id);},close(){}}; + // 上传替身返回固定 2×1 元数据与资源 URL,不验证图片字节;查询保持 unresolved,accept 直接取 canonical。 const assets={sourceURL:(id:string)=>`https://assets.test/${id}`,displayURL:(id:string)=>`/assets/${id}`,async upload(request:any,bytes:Blob){const payload=JSON.parse(request.body);return{outcome:'applied' as const,canonical:{id:payload.resource_id,url:assets.sourceURL(payload.resource_id),bytes:bytes.size,mime_type:bytes.type,width:2,height:1}};},async query(){return{outcome:'unresolved' as const};},accept:(_request:any,status:any)=>status.canonical}; + // 文档替身应用真实 changes 并递增版本,把成功模型放在回执表;不实现真实 HTTP 或请求去重。 const persistence={compile(intent:any,id:string,snapshot:any){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request:any){server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['canvas',++version]])});return{outcome:'applied' as const};},async query(){return{outcome:'unresolved' as const};},canonical:(request:any)=>receipts.get(request.id)}; + // 装配真实引擎与 loading 状态,显式 attach v/w 后等待本地初始工作收敛。 const engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:partial?'canvas':'hooks'},document:partial?createDocument('canvas',[{id:'canvas',parent:null}],[node('a')]):server,...(partial?{loading:{version:1 as const,sourceToken:'hook-partial',declaredCanvasIds:['canvas'],receivedElements:1,totalElements:2}}:{}),versions:new Map([['canvas',1]]),registry:createRegistry(builtinComponents()),storage,assets,persistence,createId:()=>`hook-${++serial}`,onError:error=>errors.push(error)});const detachA=engine.sessions.attach('v','canvas'),detachB=engine.sessions.attach('w','canvas');await engine.settle(); + // 向场景暴露保存故障、读取屏障与读次数;detach 只清理 fixture 自己附加的两个会话。 return{engine,node,files,errors,full:server,get reads(){return reads;},fail(value:boolean){fail=value;},gate(value?:Promise){readGate=value;},detach(){detachA();detachB();}}; } +// 创建隐藏 React root 并串行运行场景;保留组件读到的最新 hook 结果和渲染/选择器计数供断言。 async function run(){ const f=await fixture(),engine=f.engine,container=document.createElement('div');container.style.display='none';document.body.append(container);const root=createRoot(container),reports:string[]=[],counts={title:0,element:0,mode:0,gesture:0,titleOnly:0};let titleOnly:unknown,titleSelections=0;let title:any,element:any,actions:any,mode:any,gesture:any,operation:any,status:any,assetA:any,assetB:any; + // 全局选择器只提取 a 的标题,equal 比较结果对象,检查无关 Root 发布不引发重渲染。 const Title=memo(function Title(){title=useCanvas(snapshot=>({title:snapshot.document.elements.get('a')?.data.title}),equal);counts.title++;return null;}); + // 完整元素 hook 固定到 a 的第一代身份,稍后用于验证删除恢复后状态变为 stale。 const Element=memo(function Element(){element=useElement({id:'a',incarnation:1});counts.element++;return null;}); + // 计数元素选择器实际执行次数,区分“跳过无关目标”与“目标变化但结果相等不渲染”。 const selectTitle=(view:ElementView)=>{titleSelections++;return view.status==='present'?view.element.data.title:view.status;}; + // 只订阅目标标题字段;同元素其他字段变化允许重新选择,但不能增加组件渲染次数。 const ElementTitle=memo(function ElementTitle(){titleOnly=useElement({id:'a',incarnation:1},selectTitle);counts.titleOnly++;return null;}); + // 捕获绑定到目标身份的命令 facade,检查稳定引用、历史选项和过期身份拒绝。 const Actions=memo(function Actions(){actions=useActions({id:'a',incarnation:1});return null;}); + // 观察 v 视图的 mode,另一个 view 的 mode 更新不能使它重渲染。 const Mode=memo(function Mode(){mode=useMode({id:'a',incarnation:1});counts.mode++;return null;}); + // 捕获目标绑定手势控制器;beginOnLayout 在替换目标的 layout effect 中立即开启新手势,以暴露旧 cleanup 误取消问题。 const Gesture=memo(function Gesture({targetId='a',beginOnLayout=false}:{targetId?:string;beginOnLayout?:boolean}){const binding=useGestureBindings({id:targetId,incarnation:1});gesture=binding;counts.gesture++;useLayoutEffect(()=>{if(beginOnLayout){assert(binding.begin().enabled,'replacement gesture did not begin');assert(binding.update({x:88}).enabled,'replacement gesture did not preview');}},[targetId,beginOnLayout]);return null;}); + // 按逻辑操作 ID 观察操作及其请求;最初 missing 应没有记录,rename 后改绑真实 ID。 const Operation=memo(function Operation({id}:{id:string}){operation=useOperation(id);return null;}); + // 独立观察同步、持久化错误和关闭状态,不靠业务文档再次变化触发观察。 const Status=memo(function Status(){status=useSyncStatus();return null;}); + // 两个 slot 可同时借用同一资源,分别保存 hook 结果以检查共享 URL 及引用计数释放。 const Asset=memo(function Asset({id,slot}:{id:string;slot:'a'|'b'}){const value=useAsset(id);if(slot==='a')assetA=value;else assetB=value;return null;}); + // 每次以 StrictMode 和 CanvasProvider 同步替换子树;owner 可替换成另一个 engine,随后等待 effects。 const render=async(children:any,owner=engine)=>{flushSync(()=>root.render({children}));await tick();}; try{ + // 先等 synced 再记录动态基线;StrictMode 可能重复渲染,因此断言增量而非写死初始次数。 await render(<><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id="missing"/><Status/></>);await until(()=>status?.status==='synced');const baseline={...counts},originalActions=actions,initialTitleSelections=titleSelections; + // 改无关 b:窄订阅计数和 action 引用不变,元素标题选择器也不执行。 engine.editor.dispatch({type:'element.data',target:{id:'b',incarnation:1},patch:{title:'unrelated'}});await engine.settle();await tick();assert(equal(baseline,counts),'unrelated element/ACK rerendered narrow hooks');assert(actions===originalActions,'action facade identity drifted');assert(titleSelections===initialTitleSelections,'unrelated edit/ACK evaluated the element selector'); + // 改 a 的 text 会执行其标题选择器,但标题没变时不得重渲染 ElementTitle。 const titleRenders=counts.titleOnly;engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'other field'}});await engine.settle();await tick();assert(titleSelections>initialTitleSelections&&counts.titleOnly===titleRenders&&titleOnly==='a','field selector did not isolate title from another field on the same target');reports.push('element ID routing skips unrelated selector evaluation; field selectors observe their target without rerendering for other fields'); + // 经 actions.rename 验证命令历史 label/groupId、canonical 观察及 operation/request 状态。 const renamed=actions.rename('renamed',{label:'Hook rename',groupId:'hook-rename'});await engine.settle();const renamedHistory=engine.getSnapshot().history.entries.get(engine.getSnapshot().history.undo.at(-1)!);assert(renamedHistory?.label==='Hook rename'&&renamedHistory.groupId==='hook-rename','action facade dropped command history options');await until(()=>title.title==='renamed'&&titleOnly==='renamed');assert(element.status==='present'&&element.element.data.title==='renamed','element did not observe canonical rename');await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);assert(operation?.operation.phase==='resolved'&&operation.request?.phase==='resolved','operation hook did not include its request');reports.push('selectors suppress unrelated element and ACK renders; bound actions share validated history/ledger and operation/request observation'); + // 手势预览只改变交互状态,不改业务几何、revision 或 history;取消不留撤销记录,mode 仍按 view 隔离。 const history=engine.getSnapshot().history.undo.length,revision=engine.getSnapshot().revision;assert(gesture.begin().enabled&&gesture.update({x:42}).enabled,'gesture did not acquire/update');await until(()=>gesture.active&&gesture.geometry.x===42);assert(engine.getSnapshot().document.elements.get('a')!.structure==='node'&&(engine.getSnapshot().document.elements.get('a') as any).geometry.x===0,'preview mutated committed geometry');assert(engine.getSnapshot().revision===revision,'preview created an edit');gesture.cancel();await until(()=>!gesture.active);assert(engine.getSnapshot().history.undo.length===history,'canceled preview changed history');assert(mode.setMode('editing').enabled,'view mode rejected');await until(()=>mode.mode==='editing');const modeRenders=counts.mode;engine.sessions.mode('w','a','resize');await tick();assert(counts.mode===modeRenders,'another view changed this view mode'); + // 第二次手势提交只新增一个历史单元,租约释放后旧 commit 不再可写。 assert(gesture.begin().enabled,'second gesture did not acquire');gesture.update({x:55});assert(gesture.commit().status==='applied','gesture did not commit');await engine.settle();assert(engine.getSnapshot().history.undo.length===history+1,'gesture did not create exactly one history entry');assert(gesture.commit().status==='rejected','released gesture lease remained writable');reports.push('gesture preview/cancel/commit owns a token and one history unit; modes stay scoped to their view'); + // 切换 hook 目标后立即在 layout effect 开新租约;旧控制器的 cancel/commit 和 cleanup 都不能碰新目标。 const previousGesture=gesture,beforeOwnerSwap=engine.getSnapshot();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture targetId="b" beginOnLayout/><Operation id={renamed.operationIds[0]}/><Status/></>);const replacementToken=engine.sessions.getSnapshot().gesture?.token;assert(!!replacementToken&&engine.sessions.getSnapshot().gesture?.targets[0].id==='b','old target cleanup canceled the new layout gesture');assert(previousGesture.commit().status==='rejected','old controls committed the replacement gesture');previousGesture.cancel();assert(engine.sessions.getSnapshot().gesture?.token===replacementToken,'old controls canceled the replacement gesture');assert(engine.getSnapshot()===beforeOwnerSwap,'owner switch or obsolete callback published document/history work');gesture.cancel();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);reports.push('gesture controller replacement owns an independent lease; old cleanup, cancel and commit cannot touch a new target gesture'); + // 先检查只有 durability 完成也能清除 dirty,再注入 quota 验证 blocked/dirty,重试成功才恢复 synced。 engine.editor.updateRuntime(previous=>({...previous}));await engine.persist();await tick();await until(()=>status.status==='synced'&&!status.dirty);assert(!status.dirty,'durable-only metadata completion was not observable');f.fail(true);actions.rename('unsaved');await until(()=>status.status==='blocked'&&status.error==='quota');assert(status.dirty,'failed persistence appeared durable');f.fail(false);await engine.retry();await engine.settle();await until(()=>status.status==='synced'&&!status.dirty);reports.push('sync status observes durable-only completion and quota failure/retry without claiming in-memory edits are synced'); + // 暂存任意图片类型字节只用于资源引用测试;不是 PNG 解码验证。 let resource='';engine.stageFiles([{name:'image.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([ref],tx)=>{resource=ref.id;tx.dispatch({type:'element.create',element:{...f.node('image'),componentId:'image',data:{title:'image',text:'',assetId:ref.id,src:ref.src}}});});await engine.settle();const reads=f.reads; + // 两个 hooks 必须共用一次读取和一个 URL;移除一个不影响另一个,最后一个卸载后 URL 才失效。 await render(<><Asset id={resource} slot="a"/><Asset id={resource} slot="b"/><Status/></>);await until(()=>assetA?.status==='ready'&&assetB?.status==='ready');assert(assetA.url===assetB.url&&f.reads===reads+1,'two resource hooks did not share one display read/URL');const url=assetA.url;assert(await(await fetch(url)).text()==='image bytes','asset hook did not expose original bytes');await render(<><Asset id={resource} slot="a"/><Status/></>);assert(await(await fetch(url)).text()==='image bytes','unmounting one reader revoked the other');await render(<Status/>);let revoked=false;try{await fetch(url);}catch{revoked=true;}assert(revoked,'last resource reader did not release its URL');reports.push('resource hooks share a URL, retain remaining readers and release the last handle on unmount'); + // 读取尚未返回时卸载 hook,随后才放行;迟到资源结果应释放显示 pin,不能泄漏借用。 let release!:()=>void;f.gate(new Promise<void>(resolve=>release=resolve));const before=f.reads;await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>f.reads>before);await render(<Status/>);release();f.gate();await tick();assert(!engine.getResourcePins().pins.get(resource)?.has('display'),'late resource load leaked a display pin'); + // 删除并撤销恢复元素后 incarnation 已变化;旧 facade 必须拒绝写入,不能自动改绑同 ID 的新对象。 await render(<><Element/><Actions/><Gesture/><Status/></>);const staleActions=actions;engine.editor.dispatch({type:'element.remove',target:{id:'a',incarnation:1}});await engine.settle();engine.editor.history.undo();await engine.settle();await until(()=>element.status==='stale');const snapshot=engine.getSnapshot();assert(staleActions.rename('stale').status==='rejected'&&engine.getSnapshot()===snapshot,'old incarnation facade edited a restored element');reports.push('late resource loads release their pins and stale action targets cannot retarget restored incarnations'); + // 关闭 engine 时 mounted hooks 也要观察到 closed/error 并释放资源。 await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>assetA.status==='ready');await engine.close();await until(()=>status.status==='closed'&&assetA.status==='error');reports.push('engine close is observable and releases mounted resource handles without owning a second project'); + // 换 Provider 所属引擎后,资源字节和命令目标都必须来自新 owner,不能修改旧引擎快照。 const other=await fixture();try{ let otherResource='';other.engine.stageFiles([{name:'other.png',bytes:new Blob(['other identity bytes'],{type:'image/png'})}],([ref],tx)=>{otherResource=ref.id;tx.dispatch({type:'element.create',element:{...other.node('other-image'),componentId:'image',data:{title:'other',text:'',assetId:ref.id,src:ref.src}}});});await other.engine.settle(); const oldSnapshot=engine.getSnapshot();await render(<><Asset id={otherResource} slot="a"/><Actions/><Status/></>,other.engine);await until(()=>assetA.status==='ready');assert(await(await fetch(assetA.url)).text()==='other identity bytes','Provider change reused another engine resource handle');assert(actions.rename('second owner').status==='applied','new owner action did not dispatch');await other.engine.settle();assert(other.engine.getSnapshot().document.elements.get('a')!.data.title==='second owner'&&engine.getSnapshot()===oldSnapshot,'Provider change routed commands to the old owner');reports.push('Provider owner changes replace resource handles and action targets without writing to the previous engine'); }finally{await other.engine.close();} + // 部分来源完成前 b 和未知 ID 都是 unloaded;loading.complete 后才能分别判断 present 与 absent。 const partial=await fixture(true);try{ let b:any,missing:any;function Lookup(){b=useElement('b');missing=useElement('not-in-source');return null;} await render(<><Lookup/><Status/></>,partial.engine);assert(b.status==='unloaded'&&missing.status==='unloaded','partial lookup guessed absence');const completed=await partial.engine.loading.complete(partial.full,new Map([['canvas',1]]),{sourceToken:'hook-partial'});assert(completed.status==='applied','hook partial fixture did not complete');await partial.engine.settle();await until(()=>b.status==='present'&&missing.status==='absent');reports.push('element hooks preserve unloaded versus absent until source completion and then expose the newly loaded element'); }finally{await partial.engine.close();} + // 只有所有场景通过才返回报告;finally 卸载 root、移除隐藏容器、恢复故障开关并清理 fixture。 return reports; }finally{flushSync(()=>root.unmount());container.remove();f.fail(false);f.detach();await engine.close();} } +// effect 仅注册页面测试入口;清理时先比较函数身份,防止旧挂载删除新实例注册的入口。 export function HookChecks(){useEffect(()=>{const host=globalThis as typeof globalThis&{__runCanvasHookChecks?:()=>ReturnType<typeof run>};host.__runCanvasHookChecks=run;return()=>{if(host.__runCanvasHookChecks===run)delete host.__runCanvasHookChecks;};},[]);return null;} diff --git a/tests/headless/renderer-extension.tsx b/tests/headless/renderer-extension.tsx index b706875..d3765ba 100644 --- a/tests/headless/renderer-extension.tsx +++ b/tests/headless/renderer-extension.tsx @@ -1,3 +1,7 @@ +/** + * 浏览器渲染集成专用插件:模型定义与 ReactFlow renderer 一起通过 CanvasRuntimeExtension 注入。 + * 页面全局计数和 editing facade 仅用于测试观察,数据编辑仍经过生产命令/事务入口。 + */ "use client"; import {useScopedNodeEditing} from '@/components/canvas/node-editing'; import { Handle, Position, BaseEdge, getBezierPath, type NodeProps, type EdgeProps } from '@xyflow/react'; @@ -5,15 +9,20 @@ import { defineComponent, editable, movable } from '@/lib/canvas-core/index'; import type {ReactFlowProjectionNode as ProjectionNode,ReactFlowProjectionEdge as ProjectionEdge} from '@/lib/canvas-bindings/react-flow'; import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-flow'; import { useCanvasBinding,useElement, useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow'; +// 按元素 ID 计数真实 renderer 调用,供外层检查无关元素是否重渲染,不以 DOM 数量代替渲染次数。 function rendered(id:string){const host=globalThis as typeof globalThis&{__fixtureRenders?:Record<string,number>};const counts=host.__fixtureRenders??={};counts[id]=(counts[id]??0)+1;} +// 通过 useElement 取得当前原记录,捕获有 scoped target 与无绑定两种旧 editing facade,交给外层验证桥接隔离。 function Meter({data}:NodeProps<ProjectionNode>){ const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!;rendered(record.id);const legacy=useScopedNodeEditing(record.id,data.__headlessTarget),unbound=useScopedNodeEditing(record.id),binding=useCanvasBinding();const host=globalThis as typeof globalThis&{__fixtureEngine?:typeof binding.engine;__fixtureLegacy?:Record<string,typeof legacy>;__fixtureUnbound?:Record<string,typeof legacy>};host.__fixtureEngine=binding.engine;(host.__fixtureLegacy??={})[record.id]=legacy;(host.__fixtureUnbound??={})[record.id]=unbound; + // Increase 按钮阻止点击冒泡,并在一个事务里修改 metric 与 raw,要求只产生一个历史单元;自定义 Handle ID 供连线端口测试。 return <div data-renderer-fixture="meter" style={{width:'100%',height:'100%',background:'white',border:'1px solid #ded7ce',padding:16}}> <span>Metric {String(record.data.metric)}</span><button className="nodrag" aria-label={`Increase ${record.id}`} onClick={event=>{event.stopPropagation();commands.transaction(tx=>{tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{metric:Number(record.data.metric)+1}});tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{raw:{retained:true,edited:true}}});},{label:'Increase metric'});}}>Increase</button> <Handle type="source" id="custom-out" position={Position.Right}/><Handle type="source" id="custom-in" position={Position.Left}/> </div>; } +// 自定义边记录渲染次数,使用 ReactFlow Bezier path 绘制;标题从原 connection data 读取 metric。 function MeterEdge(props:EdgeProps<ProjectionEdge>){rendered(props.id);const [path]=getBezierPath(props);return <g data-renderer-fixture="metric-edge"><title>Metric {String(props.data?.__headlessRecord?.data.metric)};} +// 两种模型分别校验 metric,节点安装编辑/移动能力;renderer 键精确匹配 componentId、dataVersion 与 structure。 export const fixtureExtension:CanvasRuntimeExtension={ components:[defineComponent({id:'fixture:meter',dataVersion:1,structure:'node',validate:data=>Number.isSafeInteger(data.metric),features:[editable(),movable()],copy:{references:[],omit:[]}}),defineComponent({id:'fixture:metric-edge',dataVersion:1,structure:'connection',validate:data=>typeof data.metric==='number',features:[],copy:{references:[],omit:[]}})], renderers:[{componentId:'fixture:meter',dataVersion:1,structure:'node',rendererId:'fixture-meter',render:Meter},{componentId:'fixture:metric-edge',dataVersion:1,structure:'connection',rendererId:'fixture-metric-edge',render:MeterEdge}], diff --git a/tests/headless/renderer-performance.mjs b/tests/headless/renderer-performance.mjs index 6e1ee9f..d0784e4 100644 --- a/tests/headless/renderer-performance.mjs +++ b/tests/headless/renderer-performance.mjs @@ -1,32 +1,46 @@ -/** Actual GraphPage/React Flow + IndexedDB probe; fixture HTTP server, not production service. */ +/** + * 实际 GraphPage/ReactFlow 页面与 IndexedDB 的输入、保留和深复制探针,HTTP 后端由 fixture 提供。 + * 调用者注入 CDP、页面 evaluate、服务文档及报告路径;本文件不独立启动浏览器或构建项目。 + * 将场景断言与耗时/堆采样分别报告:计时是本次环境的观测,不构成通用 SLA;内嵌页面脚本保持测试输入原字节。 + */ import assert from 'node:assert/strict'; import {editDuringCopy} from './renderer-copy-input.mjs'; import {writeFile} from 'node:fs/promises'; import {setTimeout as pause} from 'node:timers/promises'; import {canvasTransform,toWorld} from '../../src/lib/canvas-core/coordinates.ts'; +// 依次验证原生编辑、离屏输入保留、宿主面板、portal 和大分区复制,再写报告与成功截图。 export async function measureRenderer({call,evaluate,documents,calls=[],root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline,buildMode='development',bundler='webpack',buildMs}){ + // 保存测量墙上起点、堆样本及复制状态;target 用于离屏裁剪断言,不是最终服务端元素总数。 const target=2*count+3,started=Date.now(),heapSamples=[];let copying=false,nextHeap=0; + // 每次条件轮询有四分钟墙上截止并检查页面异常;复制中可每十秒取堆与回执保留快照,写 .heap.json。单次 await 仍依赖注入调用器返回。 const wait=async(expression,label)=>{const deadline=Date.now()+240000;while(Date.now()=nextHeap){const heap=await call('Runtime.getHeapUsage'),retention=await evaluate('window.__fixtureReceiptRetention');heapSamples.push({wallMs:Date.now()-started,serverElements:documents.get(root)?.elements.size,heap,retention});nextHeap=Date.now()+10000;await writeFile(reportPath+'.heap.json',JSON.stringify({baseline,heapSamples},null,2));}if(await evaluate(expression))return;await pause(100);}throw Error(label);}; let report,duringCopyInput; + // 环境开关决定是否在复制期间额外输入;默认仅测复制,报告 scenario 据此区分。 const concurrentInput=process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'; try{ + // 等真实 frame 与至少两个节点挂载;未选择时不应保留空节点工具栏订阅。 await wait(`!!document.querySelector('.react-flow__node[data-id="perf-frame"]')&&document.querySelectorAll('.react-flow__node').length>=2`,'initial large canvas did not render'); assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),0,'unselected nodes retained empty toolbar subscriptions'); + // 读取页面 performance.now,是相对页面时间原点的就绪观测,不是函数 Date.now 起点的独立渲染耗时。 const initialReadyMs=await evaluate('performance.now()');console.log('PERF initial scene ready '+Math.round(initialReadyMs)+' ms'); - // Use native input: synthetic focus alone misses the editing-mode transition. + // 使用原生输入进入编辑;仅脚本 focus 不会覆盖真实编辑模式切换。 await wait(`!!document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]')`,'first visible text card missing'); + // 记录双击前后 DOM 数量,用于断言进入单卡编辑不会把离屏全集挂载。 const beforeEditing=await evaluate('document.querySelectorAll(".react-flow__node").length'); const editPoint=await evaluate(`(()=>{const rect=document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:rect.x+60,y:rect.y+30};})()`); + // 经 CDP 发送原生双击,随后验证 activeElement 为可写 textarea,避免只用脚本 focus 绕过编辑模式。 await call('Input.dispatchMouseEvent',{type:'mousePressed',...editPoint,button:'left',buttons:1,clickCount:2}); await call('Input.dispatchMouseEvent',{type:'mouseReleased',...editPoint,button:'left',buttons:0,clickCount:2}); await wait(`document.activeElement===document.querySelector('.react-flow__node[data-id="perf-0"] textarea')&&!document.activeElement.readOnly`,'native text editing did not start'); await evaluate('window.__performanceEditor=document.activeElement;new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); const duringEditing=await evaluate('document.querySelectorAll(".react-flow__node").length'); if(count>=100)assert.ok(duringEditing<=beforeEditing+10,'editing one visible card mounted the offscreen canvas'); + // 输入后同一 DOM、焦点和文本都要保留,再开启 IME 并将目标平移到屏幕外。 await call('Input.insertText',{text:'Native editing remains responsive'}); assert.equal(await evaluate('window.__performanceEditor.isConnected&&document.activeElement===window.__performanceEditor&&window.__performanceEditor.value.includes("Native editing remains responsive")'),true,'native input lost its DOM, focus or value'); await evaluate('window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector(".react-flow__viewport")).transform);true'); + // 先建立组合输入再平移;目标确实离屏且仍保留输入 DOM,正常规模下不能因保留一个 editor 挂载全集。 await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1}); await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); @@ -34,6 +48,7 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par assert.equal(await evaluate('(()=>{const r=window.__performanceEditor.getBoundingClientRect();return r.right<0||r.bottom<0||r.left>innerWidth||r.top>innerHeight;})()'),true,'IME probe did not move its owner offscreen'); const offscreenEditing=await evaluate('document.querySelectorAll(".react-flow__node").length'); if(count>=100)assert.ok(offscreenEditing<=10,'retaining one offscreen editor mounted the complete canvas'); + // 离屏提交组合文本后恢复 viewport 并 blur,等同步完成再开始复制计时,排除上一轮未保存输入干扰。 await call('Input.insertText',{text:'保留输入'}); assert.equal(await evaluate('window.__performanceEditor.value.endsWith("保留输入")'),true,'offscreen IME commit lost content'); await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;})()`); @@ -41,6 +56,7 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'native input did not synchronize before copy measurement'); const inputCheck={beforeEditing,duringEditing,offscreenEditing}; console.log('PERF native editing '+JSON.stringify(inputCheck)); + // 记录 AI 任务 POST 基数;修改面板设置或复制只能改配置/文档,不应启动生成。 const runStartsBefore=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length; await wait(`!!document.querySelector('[aria-label="AI 创作"]')&&!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI panel action did not become available'); await evaluate(`document.querySelector('[aria-label="AI 创作"]').click()`); @@ -48,24 +64,29 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par await evaluate(`(()=>{window.__performancePanel=document.querySelector('[aria-label="AI 指令"]');window.__performancePanel.focus();window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);})()`); await call('Input.insertText',{text:'Stable host panel draft'}); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + // 宿主 AI 面板与节点编辑不同:面板持有草稿不应额外保留所有节点。 inputCheck.panelFocusedNodes=await evaluate('document.querySelectorAll(".react-flow__node").length'); if(count>=100)assert.ok(inputCheck.panelFocusedNodes<=beforeEditing+10,'host-panel focus mounted the complete canvas'); await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + // 平移后面板 DOM、焦点和草稿都保留,但画布离屏节点应归零。 assert.equal(await evaluate('window.__performancePanel.isConnected&&document.activeElement===window.__performancePanel&&window.__performancePanel.value==="Stable host panel draft"'),true,'independent panel lost its focus or draft during pan'); inputCheck.offscreenPanel=await evaluate('document.querySelectorAll(".react-flow__node").length'); assert.equal(inputCheck.offscreenPanel,0,'host-panel input pinned unrelated offscreen nodes'); await evaluate('window.__performancePanel.blur();new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'AI settings did not save before copy'); + // blur 后从 fixture 服务文档核对配置已经保存,再恢复视口并关闭面板。 assert.equal(documents.get(root).elements.get('perf-0').data.ai?.prompt,'Stable host panel draft','host-panel blur lost saved settings'); await evaluate(`(()=>{document.querySelector('[aria-label="关闭 AI 工具"]').click();const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__performancePanel;delete window.__editViewport;})()`); assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,runStartsBefore,'editing settings started a generation task'); + // 通过 elementFromPoint 搜索真正空白 pane 坐标并原生点击,清空之前编辑选择,避免污染 frame 工具栏计数。 const panePoint=await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect();for(let y=rect.y+5;yrequestAnimationFrame(()=>requestAnimationFrame(resolve)))`); @@ -76,13 +97,16 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); assert.equal(await evaluate('window.__performancePortal.isConnected&&document.activeElement===window.__performancePortal&&window.__performancePortal.value==="Retained portal title"'),true,'portal title draft was lost during offscreen pan'); + // 分区标题 portal 离屏时只保留必要 owner;blur 后应释放全部离屏节点,回到原视口再确认标题保存及重挂载。 inputCheck.offscreenPortal=await evaluate('document.querySelectorAll(".react-flow__node").length'); if(count>=100)assert.ok(inputCheck.offscreenPortal<=10,'one portal draft mounted the complete canvas'); await evaluate('window.__performancePortal.blur();true'); await wait('document.querySelectorAll(".react-flow__node").length===0','released portal retained offscreen owners'); await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;delete window.__performancePortal;})()`); await wait(`document.querySelector('[aria-label="分区名称"]')?.value==='Retained portal title'&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'portal title did not save and remount'); + // 捕获复制开始前的原文,用来证明并发输入不进入已经捕获的副本。 const sourceText=documents.get(root).elements.get('perf-0').data.text; + // 页面脚本以 rAF 采样新 DOM、进度完成、节点峰值、帧间隔,并在支持时观察 longtask;tick 反复采样,read 汇总,stop 取消并断开 observer。 await evaluate(`(()=>{ const originals=new Set(['perf-source','perf-frame',...Array.from({length:${count}},(_,i)=>'perf-'+i)]),start=performance.now(),tasks=[],gaps=[]; let raf,last=start,sawPreparing=false,firstDOM=null,firstFrame=null,localComplete=null,localCompleteFrame=null,prepared=null,maxNodes=0; @@ -93,11 +117,15 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par window.__canvasPerformance={read(){const sorted=[...gaps].sort((a,b)=>a-b),p95=sorted[Math.max(0,Math.ceil(sorted.length*.95)-1)]??0;return{frameGapP95Ms:p95,elapsedMs:performance.now()-start,firstDOMMs:firstDOM,firstPresentedFrameMs:firstFrame,localCompleteMs:localComplete,localCompleteFrameMs:localCompleteFrame,prepareMs:prepared,maxMountedNodes:maxNodes,nodeCount:document.querySelectorAll('.react-flow__node').length,longTasksSupported:supported,longTaskCount:tasks.length,maxLongTaskMs:Math.max(0,...tasks.map(task=>task.duration)),totalLongTaskMs:tasks.reduce((total,task)=>total+task.duration,0),maxFrameGapMs:Math.max(0,...gaps),longTasks:tasks};},stop(){cancelAnimationFrame(raf);if(observer){tasks.push(...observer.takeRecords().map(entry=>({start:entry.startTime-start,duration:entry.duration})));observer.disconnect();}return this.read();}}; document.querySelector('[aria-label="深复制选中内容"]').click(); })()`); + // 页面脚本安装采样后点击复制;Node 侧此后才启用复制期间的可选堆采样。 copying=true;console.log('PERF copy requested'); + // 可选探针通过原生输入继续编辑源卡,校验完整文字增量;副本随后仍须保留复制起点文本。 if(concurrentInput){duringCopyInput=await editDuringCopy({call,evaluate,wait,count});assert.equal(duringCopyInput.value,sourceText+(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'concurrent typing lost pre-existing source text');console.log('PERF concurrent input '+JSON.stringify(duringCopyInput));} await wait(`document.querySelectorAll('.react-flow__node').length>0&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'large copy did not finish rendering and synchronization'); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + // 完成进度与已同步后再等两帧,要求 renderer 测量出现合批;统计批量大小而非只计渲染总数。 const internalsBatches=await evaluate('window.__fixtureInternalsBatches??[]');assert.ok(internalsBatches.some(size=>size>1),'mounted nodes did not share a renderer measurement batch'); + // 停止页面采样并验证服务元素数、复制 canvas 和上级连线。firstPresentedFrame 是检测新 DOM 后的下一次 rAF 近似值,不是 compositor 上屏证明。 const measurements=await evaluate('window.__canvasPerformance.stop()'),document=documents.get(root),copiedCanvas=[...document.canvases.values()].find(canvas=>canvas.parent&&canvas.id!==partition); assert.equal(document.elements.size,2*count+5);assert.ok(copiedCanvas);assert.ok([...document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId==='perf-source'&&element.target.elementId===copiedCanvas.parent.entryElementId));assert.ok(document.elements.has('perf-incoming'));assert.ok(measurements.firstPresentedFrameMs!==null&&measurements.localCompleteFrameMs!==null);if(count>=100)assert.ok(measurements.nodeCountcall.method==='POST'&&call.path.endsWith('/ai/runs')).length-runStartsBefore; assert.equal(runStartsDuringScenario,0,'deep copy or settings unexpectedly started generation'); + // 离屏源仍需保留两条可见分区连线,随后把最后一个副本节点平移到屏幕内,检查实际挂载。 const visibleEdges=await evaluate('document.querySelectorAll(".react-flow__edge").length');assert.equal(visibleEdges,2,'frame connections did not render for the offscreen source'); - // Completion is a model/progress check. Verify an offscreen copied card actually mounts after panning. + // 进度完成只证明模型/作业状态;还要平移后看到副本卡片实际挂载。 + // 按原节点局部位置找到复制后的末项,而非只依赖进度或服务记录宣称渲染完成。 const originalLast=document.elements.get(`perf-${count-1}`),copiedLast=[...document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId===copiedCanvas.id&&element.geometry.x===originalLast.geometry.x&&element.geometry.y===originalLast.geometry.y);assert.ok(copiedLast); + // 从副本 owner 的局部坐标换算世界中心,给页面 viewport 平移定位使用。 const world=toWorld(canvasTransform(document,copiedLast.ownerCanvasId),{x:copiedLast.geometry.x+copiedLast.geometry.width/2,y:copiedLast.geometry.y+copiedLast.geometry.height/2}); await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect(),matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(${world.x}*matrix.a+matrix.e-rect.width/2)/.5,deltaY:(${world.y}*matrix.a+matrix.f-rect.height/2)/.5,bubbles:true,cancelable:true}));})()`); await wait(`!!document.querySelector('.react-flow__node[data-id="${copiedLast.id}"] textarea')`,'offscreen copied card did not mount after viewport pan'); + // 记录平移后的实际挂载数量,和之前的可见连线数一起写入报告。 const viewportCheck={visibleEdgesBeforePan:visibleEdges,copiedId:copiedLast.id,mountedAfterPan:await evaluate('document.querySelectorAll(".react-flow__node").length')}; + // 报告同时保存输入断言上下文、浏览器/构建模式、采样、fixture 文档计数与测量批次,便于跨环境比较。 report={inputCheck,duringCopyInput,runStartsDuringScenario,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} ${bundler}; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks`,count,initialReadyMs,buildMs,internals:{batches:internalsBatches.length,maxBatch:Math.max(0,...internalsBatches),totalRequests:internalsBatches.reduce((sum,size)=>sum+size,0)},measurements,viewportCheck,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started}; + // 仅成功路径截图,输出到报告同名 PNG;截图是场景结束视口,不是每个计时点的屏幕证据。 const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile(reportPath.endsWith('.json')?reportPath.slice(0,-5)+'.png':reportPath+'.png',Buffer.from(screenshot.data,'base64')); + // 失败也尽力停止页面采样并写诊断报告,然后重抛;不能把部分观测输出当作场景通过。 }catch(error){let measurements;try{measurements=await evaluate('window.__canvasPerformance?.stop()');}catch{}report={duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',count,buildMode,buildMs,error:error.message,measurements,serverElements:documents.get(root)?.elements.size,serverCanvases:documents.get(root)?.canvases.size,wallMs:Date.now()-started};await writeFile(reportPath,JSON.stringify(report,null,2));throw error;} + // 成功报告落盘后打印紧凑摘要,长任务明细仍保存在 JSON 文件。 await writeFile(reportPath,JSON.stringify(report,null,2));console.log('MEASURE '+JSON.stringify({...report,measurements:{...report.measurements,longTasks:undefined}}));console.log('REPORT '+reportPath); } diff --git a/tests/headless/renderers.test.mjs b/tests/headless/renderers.test.mjs index 1904139..1a995d8 100644 --- a/tests/headless/renderers.test.mjs +++ b/tests/headless/renderers.test.mjs @@ -1,30 +1,42 @@ +/** + * 渲染定义目录与 React 组装的契约测试。模型 registry 定义数据与能力,renderer catalog 定义宿主如何显示,两者按类型/版本/结构匹配。 + * 这里检查描述捕获、投影记录与函数身份,不挂载真实 ReactFlow 页面。 + */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createRendererCatalog,builtinRendererDefinitions} from '../../src/lib/canvas-bindings/renderers.ts'; import {createReactRenderers} from '../../src/lib/canvas-bindings/react-renderers.ts'; import {createDocument,createRegistry,builtinComponents,defineComponent,movable,createEditor} from '../../src/lib/canvas-core/index.ts'; import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts'; +// 自定义计量节点的精确渲染键,不允许用同一映射自动兼容未来版本或不同 structure。 const descriptor={componentId:'plugin:meter',dataVersion:1,structure:'node',rendererId:'meter'}; +// 模型独立声明 metric 校验和移动能力;同一个模型可由多个宿主使用不同渲染器。 const definition=defineComponent({id:'plugin:meter',dataVersion:1,structure:'node',validate:data=>typeof data.metric==='number',features:[movable()]}),registry=createRegistry([...builtinComponents(),definition]); +// 保留不透明标题和看似内部字段的数据,用来核对投影注入的可信原记录不会被用户 data 冒充。 const node={id:'meter',incarnation:1,ownerCanvasId:'root',structure:'node',componentId:'plugin:meter',dataVersion:1,data:{metric:7,title:{opaque:true},__headlessRecord:{untrusted:true}},geometry:{x:0,y:0,width:200,height:100,rotation:0}}; +// 目录只捕获不可变元数据,不持有 React render 值;输入描述后续变化无效,未来版本/不同结构不匹配,重复或保留 ID 被拒绝。 test('renderer catalog captures exact version/structure mappings without React values or mutable descriptors',()=>{ const input={...descriptor,render(){}};const catalog=createRendererCatalog([input]);input.rendererId='changed';assert.equal(catalog.resolve(node).rendererId,'meter');assert.equal(catalog.resolve({...node,dataVersion:2}),undefined);assert.equal(catalog.resolve({...node,structure:'connection'}),undefined);assert.equal(catalog.definitions[0].render,undefined);assert.ok(Object.isFrozen(catalog.definitions[0]));assert.throws(()=>createRendererCatalog([descriptor,descriptor]),/duplicate_renderer_definition/);assert.throws(()=>createRendererCatalog([{...descriptor,rendererId:'unsupported'}]),/invalid_renderer_definition/); }); +// 组装仅在同一个 render 实现时共享别名包装;拒绝空 renderer、ID 冲突与模型契约不符,getter 只读取一次并保留 memo 对象。 test('React assembly preserves aliases only for the same component and rejects invalid renderers or model contracts',()=>{ const render=()=>null,assembly=createReactRenderers([{...descriptor,render}],registry);assert.equal(assembly.nodeTypes.meter.type,render);assert.ok(Object.isFrozen(assembly.nodeTypes));assert.equal(assembly.edgeTypes.meter,undefined); assert.throws(()=>createReactRenderers([{...descriptor,render:null}]),/invalid_react_renderer/);assert.throws(()=>createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render:()=>null}]),/renderer_id_conflict/);assert.throws(()=>createReactRenderers([{...descriptor,dataVersion:2,render}],registry),/renderer_model_mismatch/); let reads=0;const getter=createReactRenderers([{...descriptor,get render(){reads++;return render;}}]);assert.equal(reads,1);assert.equal(getter.definitions[0].render,render);assert.equal(getter.nodeTypes.meter,assembly.nodeTypes.meter); const alias=createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render}]);assert.equal(alias.nodeTypes.meter,assembly.nodeTypes.meter);const memo={$$typeof:Symbol.for('react.memo'),type:render};assert.equal(createReactRenderers([{...descriptor,render:memo}]).nodeTypes.meter,memo); }); +// 自定义节点与边投影携带原记录和 transform,未知 data/path 不丢;未安装映射或未来 schema 走 unsupported。 test('installed node and edge renderers receive original records and transforms while absent schemas still fall back',()=>{ const customEdge=defineComponent({id:'plugin:edge',dataVersion:1,structure:'connection',validate:()=>true,features:[]}),r=createRegistry([...builtinComponents(),definition,customEdge]),other={...node,id:'second',geometry:{...node.geometry,x:300}},edge={id:'edge',incarnation:1,structure:'connection',componentId:'plugin:edge',dataVersion:1,ownerCanvasId:'root',source:{elementId:'meter',portId:'custom-out'},target:{elementId:'second',portId:'custom-in'},path:{type:'custom',retained:true},data:{path:{own:true},metric:99}},source=createDocument('project',[{id:'root',parent:null}],[node,other,edge]),catalog=createRendererCatalog([...builtinRendererDefinitions,descriptor,{...descriptor,componentId:'plugin:edge',structure:'connection',rendererId:'meter-edge'}]),p=projectCanvas(source,'root',r,new Set(),()=>undefined,null,catalog); assert.equal(p.nodes[0].type,'meter');assert.equal(p.nodes[0].data.__headlessFallback,undefined);assert.equal(p.nodes[0].data.__headlessRecord,source.elements.get('meter'));assert.deepEqual(p.nodes[0].data.__headlessRecord.data.title,{opaque:true});assert.equal(p.nodes[0].data.__headlessTransform.scale,1);assert.equal(p.edges[0].type,'meter-edge');assert.equal(p.edges[0].data.__headlessRecord,source.elements.get('edge'));assert.deepEqual(p.edges[0].data.__headlessRecord.path,{type:'custom',retained:true}); const missing=projectCanvas(source,'root',r,new Set(),()=>undefined);assert.equal(missing.nodes[0].type,'unsupported');assert.equal(missing.edges[0].type,'unsupported');const future=createDocument('project',[{id:'root',parent:null}],[{...node,dataVersion:2}]);assert.equal(projectCanvas(future,'root',r,new Set(),()=>undefined,null,catalog).nodes[0].type,'unsupported'); }); +// 加载前预览必须显式提供 preview,实现与正式 renderer 独立捕获;非法 preview 或不一致别名被拒绝。 test('pre-engine preview renderers are explicit and captured independently from engine-bound renderers',()=>{ const render=()=>null,preview=()=>null,assembly=createReactRenderers([{...descriptor,render,preview}]);assert.equal(assembly.nodeTypes.meter.type,render);assert.equal(assembly.previewNodeTypes.meter.type,preview);assert.equal(assembly.definitions[0].preview,preview);assert.ok(Object.isFrozen(assembly.previewNodeTypes));assert.equal(createReactRenderers([{...descriptor,render}]).previewNodeTypes.meter,undefined);assert.throws(()=>createReactRenderers([{...descriptor,render,preview:0}]),/invalid_react_preview/);assert.throws(()=>createReactRenderers([{...descriptor,render,preview},{...descriptor,componentId:'other',render}]),/renderer_id_conflict/); }); +// 两个宿主对同一 component 选择不同 rendererId,但共享同一文档记录与移动结果,命令仍只产生一个历史单元。 test('independent host renderers for one component share model data and capability behavior',()=>{ const editor=createEditor({registry,document:createDocument('project',[{id:'root',parent:null}],[node])}),one=createReactRenderers([{...descriptor,rendererId:'host-one',render:()=>null}],registry),two=createReactRenderers([{...descriptor,rendererId:'host-two',render:()=>null}],registry); assert.equal(editor.dispatch({type:'element.geometry',target:{id:node.id,incarnation:node.incarnation},patch:{x:42}}).status,'applied'); diff --git a/tests/headless/selection-listener-fixture.tsx b/tests/headless/selection-listener-fixture.tsx index 5ba6ed0..d068a3f 100644 --- a/tests/headless/selection-listener-fixture.tsx +++ b/tests/headless/selection-listener-fixture.tsx @@ -1,19 +1,30 @@ +/** + * 页面专用原生 ReactFlow selection 观察组件。默认不安装监听器,由外层测试 enable 后才挂载 Listener。 + * 此 fixture 观察 ReactFlow 自己的选择事件和 nodeLookup,不代替业务文档或 headless session 的事实源。 + */ "use client"; import {useCallback,useEffect,useState} from 'react'; import {useOnSelectionChange,useStoreApi} from '@xyflow/react'; +// 把回调中的节点/边对象收窄成 ID 列表,避免把整个 renderer 对象留在测试事件日志。 type Event={nodes:string[];edges:string[]}; +// 暴露启用、原生选择命令和当前节点选择查询,供浏览器脚本与 headless 选择行为对照。 type Control={enable(value:boolean):void;select(ids:string[]):void;selected():string[]}; +// 仅在测试 Window 上声明控制接口与事件日志,不向生产 editor 注入状态。 declare global{interface Window{__fixtureNativeSelection?:Control;__fixtureNativeSelectionEvents?:Event[];}} +// 稳定 useCallback 记录 ID,再注册 useOnSelectionChange;组件卸载由 hook 解除观察。 function Listener(){ const onChange=useCallback((value:{nodes:{id:string}[];edges:{id:string}[]})=>{window.__fixtureNativeSelectionEvents?.push({nodes:value.nodes.map(node=>node.id),edges:value.edges.map(edge=>edge.id)});},[]); useOnSelectionChange({onChange});return null; } -/** Isolated page fixture only; no observer is installed in performance builds. */ +/** 仅在隔离测试页面使用,性能构建不安装此观察器。 */ +// 持有 ReactFlow store 和启用开关;effect 注册控制对象,render 按开关决定是否安装监听器。 export function NativeSelectionFixture(){ const store=useStoreApi(),[enabled,setEnabled]=useState(false); useEffect(()=>{ + // select 先清除再添加原生选中节点,selected 从 nodeLookup 查询;日志独立保存已收到的事件。 const control:Control={enable:setEnabled,select(ids){store.getState().resetSelectedElements();store.getState().addSelectedNodes(ids);},selected(){return [...store.getState().nodeLookup.values()].filter(node=>node.selected).map(node=>node.id);}}; window.__fixtureNativeSelection=control;window.__fixtureNativeSelectionEvents??=[]; + // 只移除本 effect 注册的同一控制对象,旧 cleanup 不删除新挂载的控制接口;事件日志不在此清空。 return()=>{if(window.__fixtureNativeSelection===control)delete window.__fixtureNativeSelection;}; },[store]); return enabled?:null; From f08310b83cbbce5de24da84e761c688cd7963c0b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 11:21:50 +0800 Subject: [PATCH 2/2] docs(tests): explain browser storage history recovery and suite orchestration --- tests/headless/browser-storage-cases.mjs | 144 ++++++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index fbed132..67ff876 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -1,3 +1,9 @@ +/** + * 由隔离浏览器 runner 调用的存储/引擎组合场景。IndexedDB、Web Locks、Blob URL 和 Worker 使用浏览器实现, + * 大多数文档/上传服务为内存替身;网络流场景只连接本地可控 fixture,不代表远程 Foundry 数据库验证。 + * 持久化场景使用独立 scope/数据库,显示借用等 helper 也可复用传入引擎;run 串行编排,通过断言后才追加结果。 + * 版本号需区分:数据库 schema、checkpoint/journal 编码、recovery 镜像、loading 描述和服务协议分别演进。 + */ import {blobCheckpointCase,blockedUpgradeCase} from './blob-checkpoint-browser-cases.mjs'; import {depotFilesCase,depotFilesUpgradeCase,depotAdoptionRaceCase} from './depot-files-browser-cases.mjs'; import {assetAdoptionStorageCase} from './asset-adoption-browser-cases.mjs'; @@ -17,284 +23,399 @@ import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.t import { createResourceView } from '../../src/lib/canvas-bindings/resource-view.ts'; import { createScopedLoader,loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts'; +// 真实流式 fetch 的共享订阅:首块先可见,单个 release 不取消同 query 的其他订阅,错误尾部保留已接收前缀。 async function scopedNetworkLoaderCase(assert) { + // query 身份固定,两个订阅只改变 navigationGeneration;导航代次属于订阅者状态,不应导致重复网络读取。 const scope={accountId:'loader-account',workspaceId:'loader-workspace',projectId:'loader-project',canvasId:'loader-canvas',viewKey:'canvas',format:'fixture-ndjson-v1',queryFingerprint:'all',source:'network-fixture',authEpoch:1,navigationGeneration:1}; + // 分别运行合法尾部与非法 JSON 尾部;每轮随机 key 对应 HTTP fixture 的独立挂起流。 async function run(fail) { const key=crypto.randomUUID();let signal,first;const initial=new Promise(resolve=>{first=resolve;}); + // load 使用实际 fetch/ReadableStream;按换行累积并解析 JSON,给每条结果补 query、版本、序号和覆盖片段。 const loader=createScopedLoader({current:()=>true,async *load(query,abort){ signal=abort;const response=await fetch(`/loader-stream?key=${key}`,{signal:abort}),reader=response.body.getReader(),decoder=new TextDecoder();let pending='',sequence=0; + // TextDecoder 保留跨字节块的字符;只有完整一行才 yield,finally 取消 reader,避免残留流读取。 try{while(true){const next=await reader.read();pending+=decoder.decode(next.value??new Uint8Array(),{stream:!next.done});let end;while((end=pending.indexOf('\n'))>=0){const line=pending.slice(0,end);pending=pending.slice(end+1);if(!line)continue;const value=JSON.parse(line);yield{queryKey:loadQueryKey(query),projectId:'loader-project',snapshotToken:key,serverVersions:{'loader-canvas':1},permissionEpoch:query.authEpoch,sequence:sequence++,coverage:[`part-${value.part}`],complete:value.complete,byteLength:new TextEncoder().encode(line).byteLength,value:Object.freeze(value)};}if(next.done)break;}}finally{await reader.cancel().catch(()=>{});} }}); + // a/b 共享一次加载但各自拥有 ready Promise;错误场景提前接住 b 的拒绝,避免未处理 Promise。 const a=loader.acquire(scope,{onUpdate:state=>{if(state.status==='initial-ready')first();}}),b=loader.acquire({...scope,navigationGeneration:2}); const rejected=fail?b.ready.then(()=>{throw Error('invalid tail became complete');},()=>{}):undefined; try{ + // 等待第一行事件,要求两个订阅都进入 initial-ready,不能等流尾才发布首块。 await initial;assert(a.getSnapshot().chunks.length===1&&b.getSnapshot().status==='initial-ready','first network chunk waited for the tail'); + // 释放 a 后只有它的 ready 拒绝且状态冻结;共享 fetch 的 AbortSignal 必须仍未中止。 const cancelled=a.ready.then(()=>{throw Error('cancelled subscriber completed');},error=>assert(error.code==='load_aborted','subscriber cancellation changed'));a.release();await cancelled;assert(!signal.aborted,'one subscriber cancelled the shared fetch'); + // 经另一 HTTP 请求放行尾部并检查 reads=1;随后分别验证成功完整态或 failed-with-partial。 const response=await(await fetch(`/loader-release?key=${key}${fail?'&fail=1':''}`)).json();assert(response.reads===1,'subscribers fetched the same query twice'); if(fail){await rejected;assert(b.getSnapshot().status==='failed-with-partial'&&b.getSnapshot().chunks.length===1,'invalid network tail discarded or completed the prefix');} else{const result=await b.ready;assert(result.status==='complete'&&result.chunks.length===2&&result.scope.navigationGeneration===2,'stream completion lost subscriber scope or chunks');assert(a.getSnapshot().status==='cancelled'&&a.getSnapshot().chunks.length===1,'cancelled subscriber observed its peer tail');} + // 两个订阅均幂等释放,再关闭 loader;不把一次订阅取消视作整个共享读取终止。 }finally{a.release();b.release();loader.close();} } await run(false);await run(true); } +// 两个 ResourceView 共享同一显示读取与 Blob URL;逐个释放只减少引用,最后一个释放才撤销 URL 和 display pin。 async function sharedDisplayReadersCase(engine, storage, assert) { + // 保存原 readFile/createObjectURL/revokeObjectURL 以便临时计数并在 finally 恢复,防止影响后续场景。 const resource = engine.getSnapshot().document.elements.get('picture').data.assetId, read = storage.readFile, createURL = URL.createObjectURL, revokeURL = URL.revokeObjectURL; const revision = engine.getSnapshot().rootRevision, errors = []; let reads = 0, created = 0, revoked = 0, first = new Map(), second = new Map(); + // 每个 view 保存自己的 source Map,但 resolve 都借用同一 engine.displayResource。 const a = createResourceView({ resolve: (id, signal) => engine.displayResource(id, { signal }), onChange: sources => { first = sources; }, onError: error => errors.push(error) }); const b = createResourceView({ resolve: (id, signal) => engine.displayResource(id, { signal }), onChange: sources => { second = sources; }, onError: error => errors.push(error) }); + // 有界等待各 ResourceView 的异步发布;不是固定 sleep 后假设读取完成。 const wait = async predicate => { for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 5)); } throw Error('shared display did not settle'); }; try { + // 包装生产读字节与 URL API,仅计数,不模拟返回内容或省略真实 Blob。 storage.readFile = (...args) => { reads++; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL.call(URL, bytes); }; URL.revokeObjectURL = url => { revoked++; return revokeURL.call(URL, url); }; a.sync([['picture', resource]]); await wait(() => first.has('picture')); const url = first.get('picture').url; + // 第二视图加入同一资源应共享既有 URL;同一视图再加一处引用也不能重读或替换 handle。 b.sync([['other-view', resource]]); await wait(() => second.has('other-view')); assert(second.get('other-view').url === url && reads === 1 && created === 1, 'two views did not share one IndexedDB read and Blob URL'); a.sync([['picture', resource], ['copy', resource]]); await wait(() => first.has('copy')); assert(first.get('copy').url === url && reads === 1 && created === 1 && revoked === 0, 'adding an image reference replaced existing display handles'); + // 释放第一个视图后 URL 仍可 fetch,display pin 仍在;最后一个视图释放才各撤销一次。 a.dispose(); assert(revoked === 0 && (await (await fetch(url)).arrayBuffer()).byteLength > 0, 'closing one view revoked another view image'); assert(engine.getResourcePins().pins.get(resource).has('display'), 'remaining view lost its display pin'); b.dispose(); assert(revoked === 1 && !engine.getResourcePins().pins.get(resource).has('display'), 'last view did not release its URL and display pin'); + // 全部借用结束后不保留 idle URL,重新借用需新读取、新 URL,释放后再次撤销。 const fresh = await engine.displayResource(resource); assert(fresh.url !== url && reads === 2 && created === 2, 'released URL was retained as an idle cache'); fresh.release(); assert(revoked === 2, 'fresh handle did not revoke'); + // 显示借用仅是资源生命周期,不应新增业务 Root 修订或向观察者报告错误。 assert(errors.length === 0 && engine.getSnapshot().rootRevision === revision, 'display caching changed the business Root or reported a reader failure'); } finally { a.dispose(); b.dispose(); storage.readFile = read; URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; } } +// 真实图片 Worker 将内在尺寸补入原创建历史;尺寸写入成功但 ACK 丢失后经 IndexedDB 重开查询,undo/redo 保持单个创建单元。 async function defaultImageSizeCase(name, assert) { + // 使用真实浏览器存储和解码端口,配置 checkpointEvery=3,使保存可覆盖 checkpoint 和其间的 journal 切点。 const storage = {...createBrowserStorage({ name: `${name}-default-size`, checkpointEvery: 3 })}, images = createBrowserImageDecoder(); const scope = { accountId: 'sizing-account', workspaceId: 'sizing-workspace', projectId: 'sizing-project' }, registry = createRegistry(builtinComponents()); const original = createDocument(scope.projectId, [{ id: 'root', parent: null }], []), receipts = new Map(), writes = [], queries = [], uploads = []; let server = original, version = 1, next = 0, engine; + // 文档服务是替身:compile 固定版本条件,submit 只对 historyEffect 的尺寸变更丢 ACK,query 查询已保存成功结果。 const persistence = { compile(intent, id, snapshot) { return { payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: { root: snapshot.runtime.versions.get('root') } }; }, async submit(request) { writes.push(request); const { intent } = JSON.parse(request.body); server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['root', ++version]]) }); return intent.historyEffect ? { outcome: 'unresolved', blocked: true, error: 'lost_size_ack' } : { outcome: 'applied' }; }, async query(request) { queries.push(request); return receipts.has(request.id) ? { outcome: 'applied' } : { outcome: 'unresolved', blocked: true }; }, canonical(request) { return receipts.get(request.id); }, }; + // 上传替身报告 4×2 元数据并计数;不调用真实资产服务,异常 query 会使本场景失败。 const assets = { sourceURL: id => `/asset/${id}`, displayURL: id => `/asset/${id}`, async upload(request, bytes) { uploads.push(request);return { outcome: 'applied', canonical: { id: JSON.parse(request.body).resource_id, bytes: bytes.size, mime_type: bytes.type, width: 4, height: 2, url: '/stored' } }; }, async query() { throw Error('unexpected asset query'); }, accept: (_request, status) => status.canonical }; const options = { scope, registry, document: original, versions: new Map([['root', 1]]), storage, images, assets, persistence, createId: () => `size-${++next}` }; try { + // 用 OffscreenCanvas 生成真实 4×2 PNG,defaultSize 允许解码后保持宽 260、调整高 130。 engine = await openProjectEngine(options); const canvas = new OffscreenCanvas(4, 2); canvas.getContext('2d').fillRect(0, 0, 4, 2); const bytes = await canvas.convertToBlob({ type: 'image/png' }); const result = engine.stageFiles([{ name: 'pixel.png', bytes, defaultSize: true }], ([ref], tx) => tx.dispatch({ type: 'element.create', element: { id: 'picture', incarnation: 1, componentId: 'image', dataVersion: 1, structure: 'node', ownerCanvasId: 'root', data: { title: 'Picture', text: '', assetId: ref.id, src: ref.src }, geometry: { x: 0, y: 0, width: 260, height: 200, rotation: 0 } } })); assert(result.status === 'applied', 'default image admission failed'); await engine.settle(); assert(engine.getSnapshot().document.elements.get('picture').geometry.height === 130 && engine.getSnapshot().history.undo.length === 1, 'decode did not enrich the original creation'); + // 定位尺寸 historyEffect 请求并保留 body;关闭后直接读取镜像,确认该历史语义需要 recovery v6。 const request = writes.find(request => JSON.parse(request.body).intent.historyEffect); assert(!!request, 'size change was never submitted'); const body = request.body; await engine.close(); const owner = await storage.claim(scope), cut = await storage.recover(owner); assert(cut.formatVersion === 6, 'size effect did not require recovery v6'); decodeRecovery(cut, registry); await owner.release(); + // 重开只查询原尺寸请求且不得重复 submit,再复用共享显示场景验证读取及 URL 引用计数。 engine = await openProjectEngine(options); await engine.settle(); assert(queries.some(query => query.id === request.id && query.body === body), 'lost size receipt did not query the frozen original request'); assert(writes.filter(write => write.id === request.id).length === 1, 'lost size receipt repeated the write'); await sharedDisplayReadersCase(engine, storage, assert); + // undo 删除已定尺寸创建,redo 以第二代恢复同样尺寸;整个恢复及历史遍历都不能重复上传。 assert(engine.editor.history.undo().status === 'applied', 'sized creation could not undo after reopening'); await engine.settle(); assert(!server.elements.has('picture'), 'undo left the server image behind'); assert(engine.editor.history.redo().status === 'applied', 'sized creation could not redo after reopening'); await engine.settle(); assert(server.elements.get('picture').incarnation === 2 && server.elements.get('picture').geometry.height === 130, 'redo lost size or reused a deleted incarnation'); assert(uploads.length === 1, 'size recovery repeated the upload'); } finally { await engine?.close(); images.close(); storage.close(); indexedDB.deleteDatabase(`${name}-default-size`); } } +// 包装真实 Worker 记录并发和释放:三个 PNG 解码峰值为二,坏字节或中止都不能留下活跃 Worker。 async function imageDecodeWorkerCase(assert){ + // 子类保留原生行为,只在构造成功和首次 terminate 时增减计数;finally 恢复全局构造器。 const NativeWorker=globalThis.Worker;let active=0,peak=0,created=0;globalThis.Worker=class extends NativeWorker{constructor(...args){super(...args);active++;created++;peak=Math.max(peak,active);this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}}; + // 先实际生成 PNG 并并发解码,再测试伪图片、立即 abort;检查错误码与 active=0,而非只看 Promise 是否结束。 const pool=createBrowserImageDecoder();try{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),results=await Promise.all([pool.decode(bytes),pool.decode(bytes),pool.decode(bytes)]);assert(results.every(size=>size.width===4&&size.height===2),'real bitmap decoder returned incorrect dimensions');assert(peak===2&&active===0&&created===3,'image worker concurrency/cleanup failed');let code;try{await pool.decode(new Blob(['not an image'],{type:'image/png'}));}catch(error){code=error.code;}assert(code==='image_decode_failed'&&active===0,'invalid image did not fail or leaked a worker');const cancel=new AbortController(),pending=pool.decode(bytes,cancel.signal),cancelled=pending.then(()=>{throw Error('cancelled image returned dimensions');},error=>assert(error.code==='image_decode_aborted','image cancellation code changed'));cancel.abort();await cancelled;assert(active===0,'cancelled image worker leaked');}finally{pool.close();globalThis.Worker=NativeWorker;} } +// 真实摘要 Worker 与原生 SHA-256 对照,验证排队取消不启动第三个 Worker,关闭池后不允许迟到摘要发布。 async function digestWorkerCase(assert){ const NativeWorker=globalThis.Worker;let active=0,peak=0,created=0;const replies=[]; + // 记录每个 Worker 的消息,用 reply.version/byteLength 证明结果确实经过 Worker 协议,而非主线程假返回。 globalThis.Worker=class extends NativeWorker{constructor(...args){super(...args);created++;active++;peak=Math.max(peak,active);this.live=true;this.addEventListener('message',event=>replies.push(event.data));}terminate(){if(this.live){this.live=false;active--;}super.terminate();}}; const pool=createBrowserDigests();try{ + // 四 MiB Blob 交给两个活跃任务和一个已排队任务;排队任务中止后只应创建前两个 Worker。 const bytes=new Blob([new Uint8Array(4*1024*1024).fill(97)]),expected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join(''),cancel=new AbortController(),a=pool.digest(bytes),b=pool.digest(bytes),queued=pool.digest(bytes,cancel.signal),cancelled=queued.then(()=>{throw Error('queued digest ran');},error=>assert(error.code==='import_aborted','queued digest cancellation changed')); + // 等待取消与成功任务后核对摘要、并发峰值和协议回复;正常结束应终止所有 Worker。 cancel.abort();await cancelled;const results=await Promise.all([a,b]);assert(results.every(value=>value===expected),'worker digest differs from native SHA256');assert(peak===2&&created===2&&active===0,'worker pool exceeded bounds or leaked a worker');assert(replies.length===2&&replies.every(reply=>reply.version===1&&reply.byteLength===bytes.size),'worker protocol did not actually run'); + // 另开一项后立即 close;被关闭工作应拒绝 import_aborted,不能留下 Worker 或成功发布。 const late=pool.digest(bytes),closed=late.then(()=>{throw Error('closed worker published');},error=>assert(error.code==='import_aborted','pool close did not cancel work'));await Promise.resolve();pool.close();await closed;assert(active===0,'pool close retained a worker'); }finally{pool.close();globalThis.Worker=NativeWorker;} } +// 异步 checksum 期间仍需持有输入所有权:可变镜像/文件描述先复制,可信 sealed 镜像可复用,浅层 Object.freeze 不能冒充所有权。 async function ownedImageStorageCase(name,assert){ const storage=createBrowserStorage({name:`${name}-owned-image`}),scope={accountId:'owner',workspaceId:'workspace',projectId:'project'};let leader; try{ leader=await storage.claim(scope);const payload={nested:{title:'captured'}},descriptor={id:'captured-file',bytes:new Blob(['captured bytes'])},raw={formatVersion:1,rootRevision:0,localRevision:0,payload}; + // 调用 commit 后立刻改原 payload 和文件描述;实际恢复只能看到调用时标题、ID 和字节。 const first=storage.commit(leader,{image:raw,files:[descriptor]});payload.nested.title='changed';descriptor.id='changed-file';descriptor.bytes=new Blob(['changed bytes']);await first; assert((await storage.recover(leader)).payload.nested.title==='captured','unowned image changed while checksum was pending');assert(await(await storage.readFile(leader,'captured-file')).text()==='captured bytes','file descriptor changed while checksum was pending');assert(await storage.readFile(leader,'changed-file')===undefined,'late descriptor identity leaked into storage'); + // 生产 sealRecoveryImage 的可信镜像也必须经过真实 IndexedDB/checksum 并可恢复。 const sealed=sealRecoveryImage(1,1,1,{nested:{title:'sealed'}});await storage.commit(leader,{image:sealed});assert((await storage.recover(leader)).payload.nested.title==='sealed','sealed image did not survive IndexedDB/checksum'); + // 只冻结外壳的伪 sealed 对象仍有可变嵌套数据,必须防御复制,不能借 Object.isFrozen 绕过捕获。 const nested={title:'before'},impostor=Object.freeze({formatVersion:1,rootRevision:2,localRevision:1,payload:{nested}}),pending=storage.commit(leader,{image:impostor});nested.title='after';await pending;assert((await storage.recover(leader)).payload.nested.title==='before','a shallow-frozen impostor bypassed the defensive copy'); }finally{await leader?.release();storage.close();indexedDB.deleteDatabase(`${name}-owned-image`);} } +// 复制规划必须让出到真实浏览器任务队列;1500 项源中只选一个,也要保持选择范围、源文档和取消语义。 async function cooperativeCopyCase(assert) { + // 大源迫使索引/扫描跨切片,而不是靠一次简单复制碰巧异步完成。 const registry=createRegistry(builtinComponents()),source=createDocument('copy-project',[{id:'copy-root',parent:null}],Array.from({length:1500},(_,index)=>({id:`source-${index}`,incarnation:1,structure:'node',ownerCanvasId:'copy-root',componentId:'text',dataVersion:1,data:{title:`item ${index}`,text:''},geometry:{x:index,y:0,width:100,height:100,rotation:0}}))); let next=0,timerRan=false; + // 零延迟 timer 是独立浏览器任务,只有真正让出主线程而非仅 Promise 微任务才能让它执行。 const timer=setTimeout(()=>{timerRan=true;},0); try { + // maxStepsPerSlice=512 后 timer 应先运行;只选一项仍只产生一条变化,源元素数保持不变。 const plan=await planDuplicateAsync(source,registry,[{id:'source-0',incarnation:1}],{createId:()=>`copy-${++next}`},{maxStepsPerSlice:512}); assert(timerRan,'copy planning starved a real browser task');assert(plan.changes.length===1,'copy planning changed selection scope');assert(source.elements.size===1500,'copy planning mutated its source'); + // 再用更小切片和 timer.abort,要求规划拒绝 copy_aborted;finally 清除两种测试定时器。 const controller=new AbortController(),cancel=setTimeout(()=>controller.abort(),0); try { let code;try { await planDuplicateAsync(source,registry,[{id:'source-0',incarnation:1}],{createId:()=>`unused-${++next}`},{signal:controller.signal,maxStepsPerSlice:128}); } catch(error){code=error.code;}assert(code==='copy_aborted','browser cancellation did not stop planning'); } finally {clearTimeout(cancel);} } finally {clearTimeout(timer);} } +// 恢复审计被后续恢复再次消费时,字段链和结构链都要保持旧请求事实、最新审阅基线及新 incarnation,不把审计只当存档。 async function adoptionConsumerCase(name,assert){ + // 分别测试输入分组的多次替代,以及分区创建后丢弃成员修改,两条分支有不同审计层数和 POST 次数。 for(const structural of [false,true]){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),database=`${name}-adoption-consumer-${structural}`,storage=createBrowserStorage({name:database}),calls=[],saved=new Map();let document=original,versions=new Map([[root,1]]),reject=true,engine; + // 局部模型按参与 canvas 过滤元素,完整读覆盖全部;已移除 canvas 单独列入 removedCanvasIds。 const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + // 默认明确拒绝写入;GET 提供当前完整基线,放行后逐参与版本检查并生成覆盖全部逻辑 ID 的回执。 const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'adoption consumer lost a version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; + // selected 从当前未解决逻辑操作生成 keep-local 选项,后续会显式改一个为 discard-local。 const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})},selected=()=>new Map([...engine.getSnapshot().runtime.ledger.operations].filter(([,record])=>record.phase!=='resolved').map(([id])=>[id,'keep-local'])); + // 每次关闭前保存所有已见冻结 body;再次看到同一请求必须字节相同,再重开真实浏览器存储。 const reopen=async()=>{for(const[id,request]of engine.getSnapshot().runtime.ledger.requests){if(saved.has(id))assert(saved.get(id)===request.body,'an earlier recovery request was rewritten');else saved.set(id,request.body);}await engine.close();engine=await openProjectEngine(options);}; try{ engine=await openProjectEngine(options); + // 结构分支先创建 frame/canvas/member 再编辑 member;字段分支把 one/two 记入同一输入组。 if(structural){engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('frame'),componentId:'frame'}});tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'frame'}}});tx.dispatch({type:'element.create',element:node('member',child)});});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'member',incarnation:1},patch:{title:'discard member edit'}});} else{engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'one'}},{groupId:'chain'});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'two'}},{groupId:'chain'});} + // 第一次恢复后重开,再外部推进根标题和版本,构造后续恢复必须消费旧审计的条件。 await engine.settle();let session=await engine.prepareRecovery(),first=session.plan(selected());await session.resolve(first);await engine.settle();await reopen();document=applyChanges(document,[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'remote 1',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1); + // 第二次选择丢弃一个替代操作;结构分支此时放行发送,字段分支还会增加本地编辑和第三次审阅。 session=await engine.prepareRecovery();const decisions=selected();decisions.set(first.operationIds[structural?1:0],'discard-local');if(structural)reject=false;const second=session.plan(decisions);await session.resolve(second);await engine.settle();await reopen(); + // 字段分支检查三层审计、远程基线 undo 和合批;结构分支检查两层审计、整体 undo/redo 与第二代身份。 if(!structural){engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'after adoption'}},{groupId:'chain'});await engine.settle();document=applyChanges(document,[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'remote 1',after:'remote 2',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1);session=await engine.prepareRecovery();const third=session.plan(selected());reject=false;await session.resolve(third);await engine.settle();await reopen();assert(engine.getSnapshot().runtime.recoveries.size===3,'nested adoption audit lineage was lost');assert(document.elements.get('a').data.title==='two'&&document.elements.get('a').data.text==='after adoption','post-adoption native edit was lost');assert(engine.editor.history.undo().status==='applied','nested field recovery undo failed');await engine.settle();assert(document.elements.get('a').data.title==='remote 2'&&document.elements.get('a').data.text==='','undo did not restore newest reviewed server baseline');assert(engine.editor.history.redo().status==='applied','nested field recovery redo failed');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===6,'nested field recovery did not batch its replacements');} else{assert(engine.getSnapshot().runtime.recoveries.size===2&&document.elements.get('member').data.title==='member','structural choice did not survive adoption');assert(engine.editor.history.undo().status==='applied','adopted structure undo failed');await engine.settle();assert(!document.canvases.has(child),'adopted structure undo left a child canvas');assert(engine.editor.history.redo().status==='applied','adopted structure redo failed');await engine.settle();assert(document.elements.get('member').incarnation===2&&document.elements.get('a').data.title==='remote 1','structural redo reused a life or erased remote data');assert(calls.filter(call=>call.method==='POST').length===5,'structural adoption repeated a request');} + // 最后再重开,逐个比对历次保存的 body,保证重规划没有改写任何历史请求。 await reopen();for(const[id,body]of saved)assert(engine.getSnapshot().runtime.ledger.requests.get(id).body===body,'original frozen request bytes changed after final reopen'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} } } +// 几何与内容混合输入组经过 keep/discard 后,持久化的 historyReplay 必须从旧备份精确重建新历史,并且不泄漏到 wire。 async function adoptionEvidenceCase(name,assert){ const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},original=createDocument(root,[{id:root,parent:null}],[node]),database=`${name}-adoption-evidence`,storage=createBrowserStorage({name:database}),calls=[];let document=original,version=1,reject=true,engine; + // 单根项目的完整或局部模型由同一服务状态生成;区分完整读与替代请求 ACK。 const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:{[root]:version}}); + // 请求体不得包含 historyReplay;拒绝时不写入,放行后核对版本并应用有序操作。 const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);assert(!request.body.includes('historyReplay'),'local history evidence leaked into wire payload');if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};assert(body.expected_versions[root]===version,'adoption evidence changed the version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);version++;return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)}};}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; try{ + // 同组先改宽度,再加入待丢弃标题和保留标题;恢复只让选中的两个贡献进入历史重放。 engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}},{groupId:'adoption'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0],discard=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'discard me'}},{groupId:'adoption'});engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'kept'}},{groupId:'adoption'});await engine.settle();const session=await engine.prepareRecovery(),selected=new Map(session.pendingOperationIds.map(id=>[id,id===discard.operationIds[0]?'discard-local':'keep-local'])),plan=session.plan(selected);reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options); + // 从审计备份与 historyReplay 重建历史再比较;核对新逻辑 ID 共用一个请求,以及分组 undo 回到原宽度和标题。 const snapshot=engine.getSnapshot(),audit=snapshot.runtime.recoveries.get(plan.id),backup=audit.backup.history,before={undo:backup.undo,redo:backup.redo,entries:new Map(backup.entries)},reconstructed=replayHistoryEvidence(before,audit.historyReplay,id=>snapshot.operations.has(id));assert(sameHistory(reconstructed,snapshot.history),'persisted adoption witness did not compose to its history');assert(audit.historyReplay.events.filter(event=>event.kind==='operation').length===2,'discarded contribution became a replay operation');assert(snapshot.runtime.ledger.requests.get(old.id).body===old.body,'adoption evidence changed old request bytes');assert(document.elements.get('a').geometry.width===250&&document.elements.get('a').data.title==='kept','adoption content or geometry changed');const replacementRequests=calls.filter(call=>call.method==='POST'&&plan.operationIds.every(id=>JSON.parse(call.body).operations.some(operation=>operation.id===id)));assert(replacementRequests.length===1,'adopted logical IDs did not share one frozen request');assert(engine.editor.history.undo().status==='applied','geometry evidence broke undo');await engine.settle();assert(document.elements.get('a').geometry.width===200&&document.elements.get('a').data.title==='a','adopted group inverse is incorrect');assert(engine.editor.history.redo().status==='applied','geometry evidence broke redo');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===4,'adoption evidence did not use one replacement batch'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} } +// 较早 undo/redo 被拒绝而后续遍历已确认时,恢复只能重放历史证据,不能重发已确认内容;生命周期映射还要传给更早/剩余历史。 async function confirmedTraversalCase(name,assert){ + // undo 和 redo 分别构造已确认恢复新生命期的场景,避免只验证普通字段回退。 for(const direction of ['undo','redo']){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),database=`${name}-confirmed-${direction}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine; const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + // 服务替身按各 canvas 版本条件接纳写入,拒绝开关只影响指定步骤,已确认请求保留原始事实。 const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'confirmed traversal lost its version condition');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + // 辅助编辑始终取服务文档的当前 incarnation,避免 fixture 自己误用旧代次。 const edit=async(id,text)=>{const result=engine.editor.dispatch({type:'element.data',target:{id,incarnation:document.elements.get(id).incarnation},patch:{text}});assert(result.status==='applied','fixture edit failed');await engine.settle();}; try{ engine=await openProjectEngine(options);await edit('a','edited a'); + // undo 方向先删除 a 并编辑另一 canvas;redo 方向先创建/编辑 new,再撤销三步建立 redo 链。 if(direction==='undo'){engine.editor.dispatch({type:'element.remove',target:{id:'a',incarnation:1}});await engine.settle();await edit('c','edited c');} else{engine.editor.dispatch({type:'element.create',element:node('new',child)});await engine.settle();await edit('new','edited new');for(let i=0;i<3;i++){engine.editor.history.undo();await engine.settle();}} + // 先拒绝一次遍历,再允许下一次确认,然后重开,检查未决前驱与已确认后继能共同恢复。 reject=true;const first=engine.editor.history[direction]();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(first.operationIds[0]));reject=false;const confirmed=engine.editor.history[direction]();await engine.settle();const applied=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(confirmed.operationIds[0]));await engine.close();engine=await openProjectEngine(options); + // 丢弃未决前驱会违反已确认遍历顺序,必须拒绝;保留它后检查原请求字节与已确认 POST 次数不变。 const session=await engine.prepareRecovery();let denied=false;try{session.plan(new Map([[first.operationIds[0],'discard-local']]));}catch(error){denied=error.code==='recovery_history_traversal_order';}assert(denied,'confirmed traversal silently skipped a discarded predecessor');const plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body&&engine.getSnapshot().runtime.ledger.requests.get(applied.id).body===applied.body,'history reconstruction rewrote original request facts');assert(calls.filter(call=>call.method==='POST'&&call.requestId===applied.id).length===1,'confirmed traversal was submitted again'); + // 后续继续操作更早/剩余历史,检查第二代再到第三代映射和各分支精确写次数,防止恢复重复派发。 if(direction==='undo'){assert(document.elements.get('a').incarnation===2&&document.elements.get('c').data.text==='','confirmed undo restoration was lost');assert(engine.editor.history.undo().status==='applied','older remapped history was stale');await engine.settle();assert(document.elements.get('a').data.text==='','older history inverse used the wrong lifecycle');assert(engine.editor.history.redo().status==='applied','older history redo failed');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===8,'confirmed undo recovery duplicated a write');} else{assert(document.elements.get('new').incarnation===2,'confirmed redo incarnation was lost');assert(engine.editor.history.redo().status==='applied','remaining redo entry was not remapped');await engine.settle();for(let i=0;i<3;i++){assert(engine.editor.history.undo().status==='applied','post-recovery undo failed');await engine.settle();}for(let i=0;i<3;i++){assert(engine.editor.history.redo().status==='applied','post-recovery redo failed');await engine.settle();}assert(document.elements.get('new').incarnation===3&&document.elements.get('new').data.text==='edited new','later traversal reused an old lifecycle');assert(calls.filter(call=>call.method==='POST').length===16,'confirmed redo recovery duplicated a write');} }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} } } +// 同一用户输入组横跨两个 canvas:先前 pending 贡献可保留或丢弃,但后来已确认贡献必须保留逻辑身份和分组撤销能力。 async function userJournalRecoveryCase(name,assert){ + // 分别覆盖 keep-local 与 discard-local,两者恢复后仍各只有一个 undo 单元。 for(const keep of [true,false]){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),database=`${name}-user-journal-${keep}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=true,engine; const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + // 完整读与条件写共用内存服务,按参与 canvas 推进版本,检查恢复没有重传已确认 c 的编辑。 const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'group recovery lost its version condition');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; try{ + // 先拒绝 a 的同组文字,再确认 c 的同组文字;重开后只把未解决 a 交给用户选择。 engine=await openProjectEngine(options);const first=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'pending first'}},{groupId:'shared'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0];reject=false;const confirmed=engine.editor.dispatch({type:'element.data',target:{id:'c',incarnation:1},patch:{text:'confirmed later'}},{groupId:'shared'});await engine.settle();await engine.close();engine=await openProjectEngine(options); + // 恢复规划不能把已经成功的 c 当作 replacement 重新发送;恢复和下一次重开之间仍使用真实 IndexedDB。 const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],keep?'keep-local':'discard-local']]));await session.resolve(plan);await engine.settle();assert(calls.filter(call=>call.method==='POST'&&JSON.parse(call.body).operations.some(operation=>operation.changes.some(change=>change.kind==='field'&&change.target.id==='c'))).length===1,'confirmed group contribution was sent again');await engine.close();engine=await openProjectEngine(options); + // 组中必须保留 confirmed 的 operationId;undo/redo 组合选中贡献,丢弃 a 时不得把它重新带回。 const history=engine.getSnapshot().history;assert(history.undo.length===1&&history.entries.values().next().value.operationIds.includes(confirmed.operationIds[0]),'group lost its confirmed logical fact');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'group replan changed rejected bytes');assert(document.elements.get('a').data.text===(keep?'pending first':''),'group prefix choice was ignored');assert(engine.editor.history.undo().status==='applied','group undo failed');await engine.settle();assert(document.elements.get('a').data.text===''&&document.elements.get('c').data.text==='','group undo did not restore both selected contributions');assert(engine.editor.history.redo().status==='applied','group redo failed');await engine.settle();assert(document.elements.get('a').data.text===(keep?'pending first':'')&&document.elements.get('c').data.text==='confirmed later','group redo lost selected content');assert(calls.filter(call=>call.method==='POST').length===(keep?5:4),'group recovery repeated a write'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} } } +// 被拒绝 undo 后出现已确认新分支,redo 已失效;恢复重放要保留分支而不是按旧栈快照复活 redo。 async function journalRecoveryCase(name,assert){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),storage=createBrowserStorage({name:`${name}-journal-recovery`}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine; const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + // 按每个参与 canvas 校验版本并回执;不同 canvas 的后续分支可确认,不代表早先 undo 已确认。 const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'journal recovery lost its version condition');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; try{ + // 先确认 a 的编辑,再拒绝其 undo,随后在 c 上创建已确认新分支,并核对 redo 已清空。 engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'edited'}});await engine.settle();reject=true;const undo=engine.editor.history.undo();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(undo.operationIds[0]));reject=false;const branch=engine.editor.dispatch({type:'element.data',target:{id:'c',incarnation:1},patch:{text:'confirmed branch'}});await engine.settle();assert(engine.getSnapshot().history.redo.length===0,'branch did not invalidate redo');await engine.close();engine=await openProjectEngine(options); + // 仅重规划未决 undo;重开后应是 a 已撤销、c 分支仍在,分支 ID 和旧请求 body 不变,再验证分支 undo/redo。 const session=await engine.prepareRecovery(),plan=session.plan(new Map([[undo.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(document.elements.get('a').data.text===''&&document.elements.get('c').data.text==='confirmed branch','confirmed branch was lost or pending undo was not restored');assert(engine.getSnapshot().history.undo.length===1&&engine.getSnapshot().history.redo.length===0,'journal recovery retained an invalidated redo');assert(engine.getSnapshot().history.entries.values().next().value.operationIds.includes(branch.operationIds[0]),'confirmed branch logical identity changed');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'journal adoption changed the rejected request');assert(engine.editor.history.undo().status==='applied','confirmed branch undo failed');await engine.settle();assert(document.elements.get('c').data.text==='','branch inverse used an incorrect baseline');assert(engine.editor.history.redo().status==='applied','confirmed branch redo failed');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===6,'confirmed content was sent twice during event replay'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-journal-recovery`);} } +// historyTransition 必须记录 redo 被新分支清除前的证据;重开后可逆推旧栈,但逆推元数据不能修改业务文档或进入协议请求。 async function historyEvidenceCase(name,assert){ const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument(root,[{id:root,parent:null}],[node]),storage=createBrowserStorage({name:`${name}-history-evidence`}),calls=[];let engine; + // 全部写入明确拒绝,后续依赖保持 pending;这样只产生一次原请求,便于发现证据是否误入 wire。 const transport=async request=>{calls.push(request);return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};}; + // value 对 entry 表按 ID 排序,比较历史内容而非 Map 的插入次序。 const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})},value=history=>JSON.stringify({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))}); try{ + // 编辑→undo→新分支依次保存栈状态;分支应立即清空 redo,再关闭引擎触发恢复。 engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'first'}});await engine.settle();const first=engine.getSnapshot().history,undo=engine.editor.history.undo();await engine.settle();const beforeBranch=engine.getSnapshot().history,branch=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'branch'}});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(engine.getSnapshot().history.redo.length===0,'new branch did not clear redo');await engine.close(); + // 逆用分支 transition 恢复 beforeBranch,再逆 undo 恢复 first;业务文字仍为 branch,原请求只发一次。 engine=await openProjectEngine(options);const snapshot=engine.getSnapshot(),transition=snapshot.operations.get(branch.operationIds[0]).historyTransition,restored=applyHistoryTransition(snapshot.history,transition,'reverse');assert(value(restored)===value(beforeBranch),'cleared redo evidence did not survive IndexedDB');const baseline=applyHistoryTransition(restored,snapshot.operations.get(undo.operationIds[0]).historyTransition,'reverse');assert(value(baseline)===value(first),'undo stack transition could not be reconstructed after reopening');assert(snapshot.document.elements.get('a').data.text==='branch','metadata reconstruction mutated the document');assert(snapshot.runtime.ledger.requests.get(old.id).body===old.body&&calls.length===1,'evidence capture changed or repeated the old rejected request');assert(calls.every(call=>!String(call.body).includes('historyTransition')),'local evidence leaked into protocol 2'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-history-evidence`);} } +// 分区删除的 undo 已在本地恢复归档结构但被服务拒绝;显式恢复后仍保留 redo 方向,之后每次还原都分配新 incarnation。 async function traversalRecoveryCase(name,assert){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); + // 分区成员带几何和身份,原模型同时有无关 anchor,检查恢复不覆盖远程标题。 const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:300,height:200,rotation:0},data:{title:id,text:''}}); const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('anchor'),{...node('entry'),componentId:'frame'},node('member',child)]),storage=createBrowserStorage({name:`${name}-traversal`}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine; + // 参与集合包括已归档 canvas 的版本及 removedCanvasIds,不能因当前无 canvas 就丢掉版本条件。 const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + // 拒绝 undo 时不改模型;恢复成功按已审阅版本重建结构并回执参与 canvas。 const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'traversal restoration lost an archive version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; try{ + // 先删除入口并确认,再拒绝 undo;另一次远程 anchor 标题修改制造新审阅基线。 engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.remove',target:{id:'entry',incarnation:1}});await engine.settle();reject=true;const undo=engine.editor.history.undo();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(undo.operationIds[0])); document=applyChanges(document,[{kind:'field',target:{id:'anchor',incarnation:1},field:'data.title',before:'anchor',after:'remote',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1); + // keep-local 恢复的是原 undo 意图而非新建普通用户编辑,重开后仍应在 redo 栈。 const session=await engine.prepareRecovery(),plan=session.plan(new Map([[undo.operationIds[0],'keep-local']]));reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options); assert(engine.getSnapshot().history.redo.length===1&&engine.getSnapshot().history.undo.length===0,'recovered undo did not retain its stack transition');assert(document.elements.get('member').incarnation===2,'restoration used the deleted incarnation');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'rejected undo bytes changed'); + // redo 再归档,下一次 undo 用第三代成员;再次重开验证结构和历史处于同一持久化切点。 assert(engine.editor.history.redo().status==='applied','redo after recovered undo failed');await engine.settle();assert(!document.canvases.has(child),'redo did not archive the restored child');assert(engine.editor.history.undo().status==='applied','next lifecycle undo failed');await engine.settle();assert(document.elements.get('member').incarnation===3,'next undo did not advance incarnation');assert(document.elements.get('anchor').data.title==='remote','traversal recovery erased remote content');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===3&&engine.getSnapshot().history.redo.length===1,'restored document and history were not in one durable cut');assert(calls.filter(call=>call.method==='POST').length===5,'traversal recovery duplicated a write'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-traversal`);} } +// 同组已确认入口与待处理分区/成员混合时,恢复保留一个完整 undo 单元,不能替换已确认逻辑事实或丢失原拒绝请求。 async function mixedHistoryCase(name,assert){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:300,height:200,rotation:0},data:{title:id,text:''}}); const original=createDocument(root,[{id:root,parent:null}],[node('anchor')]),storage=createBrowserStorage({name:`${name}-mixed-history`}),calls=[];let document=original,versions=new Map([[root,1]]),reject=false,engine; const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + // 新 canvas 的缺省版本为 0;局部 ACK 同时覆盖参与父子 canvas,便于检查整体结构事务。 const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'mixed history lost its version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; try{ + // 先独立确认 frame,但以相同 groupId 把后续 canvas/member 事务加入一组。 engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}},{groupId:'partition'});await engine.settle();const acknowledged=[...engine.getSnapshot().runtime.ledger.operations.keys()][0]; + // 拒绝分区后另行改远程 anchor;keep-local 只替代未确认部分,入口的 applied operationId 保留。 reject=true;const edit=engine.editor.transaction(tx=>{tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member',child)});},{groupId:'partition'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(edit.operationIds[0])); document=applyChanges(document,[{kind:'field',target:{id:'anchor',incarnation:1},field:'data.title',before:'anchor',after:'remote',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1); const session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options); + // 恢复重开后仍一个分组;undo 连已确认入口一并移除,redo 的入口和成员都用第二代且保留远程数据。 assert(engine.getSnapshot().history.undo.length===1,'mixed group split or lost its acknowledged prefix');assert(engine.getSnapshot().history.entries.values().next().value.operationIds.includes(acknowledged),'applied logical fact was replaced');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'old rejected bytes changed'); assert(engine.editor.history.undo().status==='applied','mixed structural undo failed after reopening');await engine.settle();assert(!document.elements.has('entry')&&!document.canvases.has(child),'mixed undo left its acknowledged entry');assert(document.elements.get('anchor').data.title==='remote','mixed undo erased remote data'); assert(engine.editor.history.redo().status==='applied','mixed structural redo failed');await engine.settle();assert(document.elements.get('entry').incarnation===2&&document.elements.get('member').incarnation===2,'mixed redo reused deleted identities');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===2,'mixed history restoration did not survive another reopen');assert(calls.filter(call=>call.method==='POST').length===5,'mixed recovery duplicated a write'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-mixed-history`);} } +// 在旋转且 contentScale 不同的容器间移动分区,靠入口 unitScale 归一保持后代世界 transform,不直接改后代字体数据。 async function scaledContainerCase(name,assert){ const root=crypto.randomUUID(),child=crypto.randomUUID(),target=crypto.randomUUID(),deep=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); + // 构造两种父分区缩放与更深一层分区;节点局部几何和 fontSize 保留为检查基准。 const node=(id,owner,kind='text',scale=1)=>({id,incarnation:1,structure:'node',componentId:kind,dataVersion:1,ownerCanvasId:owner,geometry:{x:id==='g'?700:100,y:80,width:300,height:200,rotation:id==='g'?70:30},data:{title:id,text:'body',fontSize:17,...(kind==='frame'?{presentation:{contentScale:scale}}:{})}}); const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}},{id:target,parent:{canvasId:root,entryElementId:'g'}},{id:deep,parent:{canvasId:child,entryElementId:'h'}}],[node('f',root,'frame',.75),node('g',root,'frame',2.5),node('h',child,'frame',.8),node('a',deep)]),storage=createBrowserStorage({name:`${name}-units`}),calls=[];let document=original,versions=new Map([...original.canvases.keys()].map(id=>[id,1])),engine; + // 移动及 undo/redo 各按原子条件请求应用全部变化,检查参与 canvas 版本一致再递增。 const transport=async request=>{calls.push(request);const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'scaled move lost version ordering');for(const operation of body.operations)document=applyChanges(document,operation.changes);const scopes=Object.keys(body.expected_versions);for(const id of scopes)versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>scopes.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>scopes.includes(element.ownerCanvasId)),tombstones:{},versions:Object.fromEntries(scopes.map(id=>[id,versions.get(id)]))}}};}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + // 逐层比较 x/y/rotation/scale 浮点容差,并核对 fontSize 仍为 17;在移动、重开和遍历后重复调用。 const check=()=>{for(const id of [child,deep]){const a=canvasTransform(engine.getSnapshot().document,id),b=canvasTransform(original,id);for(const key of ['x','y','rotation','scale'])assert(Math.abs(a[key]-b[key])<1e-7,'descendant transform changed: '+key);}assert(engine.getSnapshot().document.elements.get('a').data.fontSize===17,'subtree typography data was rewritten');}; + // 移动后期望 unitScale=.4;undo 恢复字段原本缺失,redo 再归一,三次写入不能多出补偿任务。 try{engine=await openProjectEngine(options);assert(engine.editor.dispatch({type:'element.move',target:{id:'f',incarnation:1},canvasId:target}).status==='applied','scaled container move failed');await engine.settle();check();assert(Math.abs(engine.getSnapshot().document.elements.get('f').data.presentation.unitScale-.4)<1e-9,'entry unit ratio was not stored');await engine.close();engine=await openProjectEngine(options);check();assert(engine.editor.history.undo().status==='applied','unit-normalized undo failed');await engine.settle();check();assert(engine.getSnapshot().document.elements.get('f').data.presentation.unitScale===undefined,'undo did not restore absent units');assert(engine.editor.history.redo().status==='applied','unit-normalized redo failed');await engine.settle();check();assert(calls.filter(call=>call.method==='POST').length===3,'unit conversion scheduled duplicate writes');await engine.close();engine=await openProjectEngine(options);check(); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-units`);} } +// frame/canvas/member 原子创建被拒绝后,恢复重规划完整 owner 树;原请求不可改写,undo 归档与 redo 新生命期均须重开可恢复。 async function structureRecoveryCase(name,assert){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),storage=createBrowserStorage({name:`${name}-structure`}),calls=[];let document=original,versions=new Map([[root,1]]),reject=true,engine; + // 局部模型同时保留移除 canvas 列表,供结构创建和归档回执使用。 const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + // 首次拒绝并推进根版本,后续按审阅后的条件接纳;新 canvas 尚无版本时以 0 校验。 const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject){reject=false;versions.set(root,2);return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};}for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'structural request version mismatch');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + // 一个本地事务创建完整分区,再 keep-local 生成替代操作;不能拆开发布无入口或无 owner 的中间结构。 try{engine=await openProjectEngine(options);const edit=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('frame'),componentId:'frame'}});tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'frame'}}});tx.dispatch({type:'element.create',element:node('member',child)});});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();await engine.close(); + // 重开后验证 owner 关系与原体,undo 不留孤立 canvas,redo 的成员为第二代并在再次重开后保留。 engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').ownerCanvasId===child,'structural replan did not recover its owner tree');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'old structural request was changed');assert(engine.editor.history.undo().status==='applied','structural undo failed');await engine.settle();assert(!document.canvases.has(child),'undo left an orphan canvas');assert(engine.editor.history.redo().status==='applied','structural redo failed');await engine.settle();assert(document.elements.get('member').incarnation===2,'redo reused a deleted lifecycle');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===2,'restored tree/history lifecycle did not survive reopening'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-structure`);} } +// 成功回执已保存但 canonical 协调器暂时不可解释时,保留 applied/waiting;恢复先承认成功,再把后续几何重规划到新审阅基线。 async function appliedReceiptRecoveryCase(name,assert){ const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1}; const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}}; const original=createDocument(root,[{id:root,parent:null}],[element]),storage=createBrowserStorage({name:`${name}-applied-receipt`}),calls=[],receipts=new Map();let server=original,version=1,engine,broken=true; const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:version}}); + // 服务实际应用 changes 并按请求 ID 保存成功回执;GET 可取完整模型或原请求结果。 const transport=async request=>{ calls.push(request);if(request.method==='GET')return{status:200,body:request.path.endsWith('/model')?{model:model(true)}:receipts.get(request.path.split('/').at(-1))}; const body=JSON.parse(request.body);assert(body.expected_versions[root]===version,'write did not use reviewed version');for(const operation of body.operations)server=applyChanges(server,operation.changes);version++; const receipt={protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)};receipts.set(request.requestId,receipt);return{status:200,body:receipt}; }; + // 只在 canonical 解释处注入故障,不能把已成功的服务执行改成 not-applied。 const base=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport}),persistence={...base,canonical(...args){if(broken)throw Error('codec_temporarily_unavailable');return base.canonical(...args);}}; const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence}; try{ engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target,patch:{title:'first'}});await engine.settle(); + // 原请求虽然 outcome=applied,phase 仍 waiting;后续本地移动可见但暂不能越过未完成协调的 writer。 const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(request.outcome==='applied'&&request.phase==='waiting','failed coordination lost the successful receipt'); const edit=engine.editor.dispatch({type:'element.geometry',target,patch:{x:200}});await engine.settle();await engine.close(); + // 模拟引擎关闭后远程把 x 改为 500、width 改为 400,恢复不能把旧回执当最新全量模型。 server=applyChanges(server,[{kind:'field',target,field:'geometry',before:element.geometry,after:{...element.geometry,x:500,width:400}}]);version++; + // 会话 acknowledgedRequestIds 包含已成功请求,pending 只应剩后来几何修改,不能给用户重执行成功操作的选项。 engine=await openProjectEngine(options);const session=await engine.prepareRecovery();assert(session.acknowledgedRequestIds.includes(request.id),'saved applied receipt was not recovered');assert(session.pendingOperationIds.length===1&&session.pendingOperationIds[0]===edit.operationIds[0],'already executed operation was offered for local re-execution'); + // 只替代待处理几何,修复解释器后一次解决成功回执与重规划;两次 POST 分别是原写和新替代写。 const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));broken=false;await session.resolve(plan);await engine.settle();assert(calls.filter(call=>call.method==='POST').length===2,'successful original operation was sent again'); assert(engine.getSnapshot().runtime.ledger.requests.get(request.id).body===request.body,'original frozen receipt identity changed');await engine.close(); engine=await openProjectEngine(options);assert(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase==='resolved','acknowledgement and rebased document did not recover together');assert(engine.getSnapshot().document.elements.get('a').geometry.width===400,'old receipt rewound newer geometry'); + // 重开后 width 保留远程 400,undo 本地几何只回到审阅的 x=500。 assert(engine.editor.history.undo().status==='applied','rebased geometry history did not recover');await engine.settle();assert(engine.getSnapshot().document.elements.get('a').geometry.x===500,'undo did not return to the reviewed geometry'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-applied-receipt`);} } +// 冲突恢复创建新 key/body 并保留旧拒绝,替代写成功但响应丢失时从 IndexedDB 查询原替代请求;undo 返回审阅的远程值。 async function conflictReplanCase(name,assert){ const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1}; const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}}; const original=createDocument(root,[{id:root,parent:null}],[element]),storage=createBrowserStorage({name:`${name}-conflict-replan`}),calls=[],receipts=new Map();let server=original,version=1,engine,reject=true,lose=true; const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:version}}); + // 首次拒绝时外部改成 remote 并推进版本;后续写入必须用该版本,成功回执保存后再模拟 502 丢响应。 const transport=async request=>{ calls.push(request);if(request.method==='GET')return{status:200,body:request.path.endsWith('/model')?{model:model(true)}:receipts.get(request.path.split('/').at(-1))}; const body=JSON.parse(request.body);if(reject){reject=false;server=applyChanges(server,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);version++;return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};} @@ -304,10 +425,14 @@ async function conflictReplanCase(name,assert){ const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; try{ engine=await openProjectEngine(options);const edit=engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle(); + // 记录原拒绝并以 keep-local 生成新恢复计划,审计、历史和替代身份一起发布。 const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle(); + // 替代服务已写入但客户端仍 unknown,不能猜成功;先关闭,修复网络后重开查询。 const replacement=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.id!==old.id);assert(replacement&&replacement.outcome==='unresolved','lost replacement ACK was guessed successful');await engine.close();lose=false; + // 把恢复时钟前移以触发原请求查询,有限轮询等请求 resolved,再等待引擎收敛。 engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}}); for(let i=0;i<100&&engine.getSnapshot().runtime.ledger.requests.get(replacement.id)?.phase!=='resolved';i++)await new Promise(resolve=>setTimeout(resolve,10));await engine.settle(); + // 恢复审计、旧 not-applied 和两份 body 都保留;总 POST 只应两次,undo 才另发回到 remote 的修改。 const restored=engine.getSnapshot();assert(restored.runtime.recoveries.has(plan.id),'recovery audit disappeared');assert(restored.runtime.ledger.requests.get(old.id).body===old.body&&restored.runtime.ledger.requests.get(old.id).outcome==='not-applied','old rejected request was rewritten'); assert(restored.runtime.ledger.requests.get(replacement.id).body===replacement.body,'replacement identity changed after reopening');assert(calls.filter(call=>call.method==='POST').length===2,'recovery repeated a server write'); assert(engine.editor.history.undo().status==='applied','replanned history did not restore');await engine.settle();assert(engine.getSnapshot().document.elements.get('a').data.title==='remote','undo did not restore the reviewed server value'); @@ -740,15 +865,20 @@ async function scopedAckRecoveryCase(name,assert,structural=false){ }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(name+'-scope-ack');} } +// 串行执行整套浏览器组合检查;只有 helper 返回或本地断言通过才追加对应结果,任何失败都会拒绝整个 run。 export async function run() { + // 随机前缀隔离各 helper 的数据库;总入口另持有基础存储 adapter 与独占 leader。 const results = [], name = `headless-test-${crypto.randomUUID()}`; const scope = { accountId: 'test-account', workspaceId: 'test-workspace', projectId: 'test-project' }; const storage = createBrowserStorage({ name, checkpointEvery: 3 }); + // 基础存储用最小自定义 payload 测序列化和版本,不把它当成经过 decodeRecovery 的真实业务文档。 const image = revision => ({ formatVersion: 1, rootRevision: revision, localRevision: Math.min(revision, 1), payload: { document: { title: `v${revision}` }, ledger: { frozenBody: '{"request_id":"stable"}' }, history: ['history-id'] } }); const assert = (condition, message) => { if (!condition) throw new Error(message); }; + // 要求异步失败的 message 精确匹配;若未抛错也直接失败,避免静默降级被当作成功。 const rejects = async (callback, message) => { try { await callback(); } catch (error) { assert(error.message === message, `expected ${message}, received ${error.message}`); return; } throw new Error(`expected ${message}`); }; let leader, other; try { + // 先运行独立 depot/Blob/升级 helper,覆盖真实浏览器事务和锁;各 helper 管理自己的数据库生命周期。 await depotAdoptionRaceCase(assert);results.push('real IndexedDB GC and a gated older Root cannot consume or release bytes belonging to a newer adoption cut'); await depotFilesCase(assert);results.push('stored uploads use shared-byte file pointers with original MIME, atomic promotion rollback, logical inventory and independent identity eviction/rehydration'); await blockedUpgradeCase(assert);results.push('blocked upgrades abort abandoned opens without upgrading after failure or leaking a connection'); @@ -756,6 +886,7 @@ export async function run() { await depotFilesUpgradeCase(assert);results.push('DB v3 original files/checkpoints upgrade to v5 and lazily promote through an idempotent Root cut'); await assetAdoptionStorageCase(assert);results.push('cross-project adoption uses fresh authorization, survives workspace/IndexedDB reopening and undo/redo, and preserves closed destination pins without project-local byte copies'); await assetDepotStorageCase(assert);results.push('workspace depot atomically shares digest bytes, preserves closed-project pins, blocks unknown/stale coverage and rolls back failed Root cuts'); + // 空 scope 恢复必须返回 null,同时重复 leader 被拒绝;首个 checkpoint 与 Blob 文件一起保存。 leader = await storage.claim(scope); assert(await storage.recover(leader) === null, 'empty scope must be distinguishable from a corrupt checkpoint'); await rejects(() => storage.claim(scope), 'storage_scope_in_use'); @@ -764,6 +895,7 @@ export async function run() { assert(await (await storage.readFile(leader, 'asset')).text() === 'original', 'committed bytes should survive'); results.push('initial checkpoint and file bytes commit together; duplicate leader rejected'); + // 相同切点可幂等重试;同修订内容冲突、跨修订跳号及未来恢复格式均不可接受。 await storage.commit(leader, { image: image(1) }); await storage.commit(leader, { image: image(1) }); await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict'); @@ -771,7 +903,7 @@ export async function run() { await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 18 } }), 'unsupported_recovery_version'); results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject'); - // The first put succeeds before a later duplicate resource aborts the whole transaction. + // 后一个资源身份冲突使整批 abort:前一个新文件、Root 和现有文件不可出现半提交。 await rejects(() => storage.commit(leader, { image: image(2), files: [{ id: 'orphan', bytes: new Blob(['must-rollback']) }, { id: 'asset', bytes: new Blob(['replacement']) }] }), 'resource_identity_conflict'); assert(await storage.readFile(leader, 'orphan') === undefined, 'aborted resource bytes must roll back'); assert((await storage.recover(leader)).rootRevision === 1, 'aborted document/history/ledger cut must not advance'); @@ -779,6 +911,7 @@ export async function run() { results.push('late transaction failure rolls back new bytes and the entire state cut'); await storage.commit(leader, { image: image(2) }); + // 释放 leader 后由新 adapter 接管,fence 必须递增;旧句柄不能写,journal 可恢复并压实为新 checkpoint。 const originalFence = leader.fence; await leader.release(); other = createBrowserStorage({ name, checkpointEvery: 3 }); @@ -791,22 +924,25 @@ export async function run() { leader = next; results.push('fresh adapter replays journal; released handle is fenced; checkpoint compaction preserves latest state'); - // Deliberately remove a journal item to exercise the corruption boundary, not an empty fallback. + // 先篡改再删除 journal 行,分别验证 checksum 与缺口错误;损坏绝不能被解释为空 scope。 await other.commit(next, { image: image(4) }); const database = await new Promise((resolve, reject) => { const request = indexedDB.open(name); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const storageKey = [JSON.stringify([scope.accountId, scope.workspaceId, scope.projectId]), 4]; + // 直接篡改持久 journal 行的有效 JSON 而不更新 checksum,先验证字节校验,再删除该行验证连续性。 const intact=await new Promise((resolve,reject)=>{const request=database.transaction('journal').objectStore('journal').get(storageKey);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);}),corrupted=structuredClone(intact); if(corrupted.image)corrupted.image.payload.document.title='altered';else{const image=JSON.parse(await corrupted.bytes.text());image.payload.document.title='altered';corrupted.bytes=new Blob([JSON.stringify(image)]);} const tamper=database.transaction('journal','readwrite');tamper.objectStore('journal').put(corrupted,storageKey); await new Promise((resolve,reject)=>{tamper.oncomplete=resolve;tamper.onabort=()=>reject(tamper.error);}); await rejects(() => other.recover(next), 'recovery_checksum_mismatch'); results.push('well-formed data tampering is detected by the stored checksum'); + // 真正删除 revision 4 日志并等待事务完成,后续 recover 必须报 journal gap,而不是返回空文档。 const transaction = database.transaction('journal', 'readwrite'); transaction.objectStore('journal').delete(storageKey); await new Promise((resolve, reject) => { transaction.oncomplete = resolve; transaction.onabort = () => reject(transaction.error); }); database.close(); await rejects(() => other.recover(next), 'recovery_journal_gap'); results.push('journal gap is reported as corruption and never converted to an empty document'); + // 以下 helper 使用同一随机命名前缀各自建库,串行验证引擎、资源回收、上传和渐进来源的持久恢复。 await engineRecoveryCase(name, assert); results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit'); await atomicResourceDeletionCase(name,assert); @@ -833,6 +969,7 @@ export async function run() { results.push('v12 scoped images save Blob/history/upload together, recover a lost ACK through IndexedDB without reupload, preserve undo/redo and survive old-source completion'); await loadingImportRecoveryCase(name,assert); results.push('v11 partial import persists its source Blob through IndexedDB pause/reopen, preserves mapped IDs and undo while unrelated model records remain unloaded'); + // 这组单独验证真实 fetch 流、图片/摘要 Worker、显示借用、输入所有权和主线程协作;不是后端集成。 await scopedNetworkLoaderCase(assert); results.push('scoped loader shares one real streamed fetch, exposes the first chunk before tail release, keeps the other subscriber alive after cancellation and preserves partial data after a malformed tail'); await defaultImageSizeCase(name, assert); @@ -845,6 +982,7 @@ export async function run() { results.push('owned immutable recovery images survive IndexedDB checksums; mutable images and file descriptors are captured before asynchronous work'); await cooperativeCopyCase(assert); results.push('cooperative duplicate planning yields to actual browser tasks and cancellation without mutating the source'); + // 随后继续验证导入/复制来源、日志链、背压、抵消及协议恢复;每条英文结果仍由相邻 helper 成功返回后追加。 await sourceIntegrityCase(name,assert); results.push('source SHA256 detects same-length Blob tampering before recovery adds or sends a tail; exact bytes resume with the original prefix and IDs'); await deltaJournalCase(name,assert); @@ -867,6 +1005,7 @@ export async function run() { results.push('protocol 2 service adapter recovers exact frozen bytes and reconciles a lost ACK through real IndexedDB'); await runRecoveryCase(name,assert); results.push('AI admission and RunRecord recover through real IndexedDB; cursor and generated content share a durable cut without a second invocation'); + // 最后一组检查规范刷新、冲突替代、已成功回执及复杂历史证据的真实 IndexedDB 重开。 await canonicalRefreshCase(name,assert); results.push('quiescent canonical refresh preserves original requests and history; the new base/document/version cut survives IndexedDB reopening'); await conflictReplanCase(name,assert); @@ -893,6 +1032,7 @@ export async function run() { results.push('adoption anchor and event evidence survive IndexedDB reopening and compose mixed geometry/content choices exactly without extra wire writes'); await adoptionConsumerCase(name,assert); results.push('adoption evidence is consumed across repeated recovery/reopening, preserving old requests, latest reviewed field baselines and structural lifecycles'); + // 整个串行检查完成才返回结果;finally 释放当前 leader、关闭 adapter 并发起删库,不等待删除事件。 return results; } finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); } }