3 Commits

11 changed files with 335 additions and 8 deletions
@@ -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);}
}
+142 -2
View File
@@ -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); }
}
+23 -1
View File
@@ -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('<title>Headless storage checks</title>'); 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;
+14 -1
View File
@@ -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);}
});
+9
View File
@@ -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);});
+6
View File
@@ -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));});
+44
View File
@@ -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<void>|undefined;
// 分别保存本地 Blob、文档回执及异步错误,read 次数用于验证资源 hooks 共享读取。
const files=new Map<string,Blob>(),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<void>){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(<StrictMode><CanvasProvider engine={owner} viewId="v">{children}</CanvasProvider></StrictMode>));await tick();};
try{
// 先等 synced 再记录动态基线;StrictMode 可能重复渲染,因此断言增量而非写死初始次数。
await render(<><Title/><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;}
+9
View File
@@ -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)}</title><BaseEdge id={props.id} path={path}/></g>;}
// 两种模型分别校验 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}],
+40 -3
View File
@@ -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()<deadline){if(exceptions.length)throw Error(exceptions.join('\n'));if(copying&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'&&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;y<rect.bottom;y+=20)for(let x=rect.x+5;x<rect.right;x+=20)if(document.elementFromPoint(x,y)===pane)return{x,y};throw Error('no empty native pane point');})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...panePoint,button:'left',buttons:1,clickCount:1});
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...panePoint,button:'left',buttons:0,clickCount:1});
await wait('document.querySelectorAll(".react-flow__node-toolbar").length===0','native edit selection did not clear before frame selection');
// 选择 frame 后只允许一个工具栏,portal 标题编辑作为新的离屏保留 owner。
await evaluate(`document.querySelector('.react-flow__node[data-id="perf-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
await wait(`!!document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'partition selection did not enable deep copy');
await evaluate(`new Promise(resolve=>requestAnimationFrame(()=>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.nodeCount<target,'offscreen DOM was not culled');assert.ok(await evaluate(`document.querySelector('[aria-label="画布导入"]').textContent.includes('${count+3} 项')`),'copy progress did not include all source records');
if(concurrentInput){
@@ -105,17 +133,26 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
assert.equal(copiedFirst?.data.text,sourceText,'copy snapshot absorbed a later original edit');
assert.equal(document.elements.get('perf-0').data.text,duringCopyInput.value,'ACK overwrote concurrent original text');
}
// 再次核对整个配置/复制场景没有启动 AI 任务;生成计费不属于深复制的副作用。
const runStartsDuringScenario=calls.filter(call=>call.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);
}
+12
View File
@@ -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');
+12 -1
View File
@@ -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?<Listener/>:null;