From abc824771085b986a880f7d2ee6837881934a07a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 18:40:39 +0800 Subject: [PATCH] docs(tests): clarify browser storage evidence and lifecycle checks --- tests/headless/browser-storage-cases.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index a3cfd57..13bfe6d 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -3,6 +3,7 @@ * 大多数文档/上传服务为内存替身;网络流场景只连接本地可控 fixture,不代表远程 Foundry 数据库验证。 * 持久化场景使用独立 scope/数据库,显示借用等 helper 也可复用传入引擎;run 串行编排,通过断言后才追加结果。 * 版本号需区分:数据库 schema、checkpoint/journal 编码、recovery 镜像、loading 描述和服务协议分别演进。 + * engine 的“可见”指工作模型已发布,显示借用检查 Blob URL;本文件未挂载 renderer,不凭模型状态证明屏幕已绘制。 */ import {blobCheckpointCase,blockedUpgradeCase} from './blob-checkpoint-browser-cases.mjs'; import {depotFilesCase,depotFilesUpgradeCase,depotAdoptionRaceCase} from './depot-files-browser-cases.mjs'; @@ -88,7 +89,7 @@ async function defaultImageSizeCase(name, assert) { 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 查询已保存成功结果。 + // 文档服务是替身:compile 读取当前 ACK 版本作为条件,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' }; }, @@ -119,7 +120,8 @@ async function defaultImageSizeCase(name, assert) { } finally { await engine?.close(); images.close(); storage.close(); indexedDB.deleteDatabase(`${name}-default-size`); } } -// 包装真实 Worker 记录并发和释放:三个 PNG 解码峰值为二,坏字节或中止都不能留下活跃 Worker。 +// 包装真实 Worker 记录并发和释放:三个 PNG 解码峰值为二,坏字节或中止都不能留下未终止的句柄。 +// active 在首次 terminate 调用时减一;它验证客户端调用了清理 API,不是浏览器内部线程已经退出的直接测量。 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();}}; @@ -646,6 +648,7 @@ async function sourceIntegrityCase(name,assert){ const node=id=>({kind:'element',key:id,record:{id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'$target',data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}}}),bytes=new Blob([[node('first'),node('last')].map(row=>JSON.stringify(row)).join('\n')]),options={scope,registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; try{ // 等一个 committedSourceKey 后 pause 并核对源摘要已存在,再关闭引擎保留恢复点。 + // 摘要断言发生在 settle 后,不证明首项发布前就算完 SHA;读取证明允许早发布,完整 SHA 封存控制后端发送。 engine=await openProjectEngine(options);const id=engine.imports.startNdjson(bytes,root);await new Promise(resolve=>{const stop=engine.editor.subscribe((_,snapshot)=>{if(snapshot.runtime.imports.get(id).committedSourceKeys.length===1){stop();resolve();}});});await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),first=before.stableIds['element:first'];assert(/^[a-f0-9]{64}$/.test(before.source.sha256),'source hash was not bound before publication');await engine.close();const sent=calls; // 原始 IDB 连接定位作用域内 source.fileId,绕过正常存储不可变规则以制造损坏。 raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(databaseName);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const key=[JSON.stringify([scope.accountId,scope.workspaceId,scope.projectId]),before.source.fileId]; @@ -695,7 +698,8 @@ async function deltaJournalCase(name,assert){ await storage.commit(leader,{image:image(6)});const extended={marker:'retained header',...image(7)};await storage.commit(leader,{image:extended});assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(extended),'noncanonical header lost fields or property order');const fullRow=await read(raw.transaction('journal').objectStore('journal').get([key,7]));assert(fullRow.checkpointVersion===1&&fullRow.bytes instanceof Blob,'noncanonical header was encoded as a lossy delta'); // 手工删掉 durable head 后不能凭内存缓存继续写;失败不推进基线,还原 metadata 后仍恢复 revision 7。 const metadata=await read(raw.transaction('metadata').objectStore('metadata').get(key));let metadataTx=raw.transaction('metadata','readwrite'),metadataDone=done(metadataTx);const missingHead={...metadata};delete missingHead.throughRevision;metadataTx.objectStore('metadata').put(missingHead,key);await metadataDone;code=undefined;try{await storage.commit(leader,{image:image(8)});}catch(error){code=error.code;}assert(code==='recovery_delta_base_mismatch','cached baseline overwrote missing durable head');metadataTx=raw.transaction('metadata','readwrite');metadataDone=done(metadataTx);metadataTx.objectStore('metadata').put(metadata,key);await metadataDone;assert((await storage.recover(leader)).rootRevision===7,'head validation failure advanced cached state'); - // 旧 DB schema 1 客户端必须得到 VersionError,不能用旧解释器读取新差量编码。 + // 当前数据库版本高于 1,显式用 schema 1 打开必须得到浏览器 VersionError。 + // 这验证 IDB 版本门禁;journalVersion=99 的解释器拒绝由上面的 future 日志损坏用例单独验证。 const oldError=await new Promise(resolve=>{const request=indexedDB.open(databaseName,1);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(oldError==='VersionError','old database reader accepted the delta format'); }finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(databaseName);} } @@ -730,6 +734,7 @@ async function referenceCycleCase(name,assert){ // 先导入 F/S/A 并核对端口指向新 A,再复制 F 的深闭包,要求 F′ 端口指向 A′。 engine=await openProjectEngine(options);const imported=engine.imports.startNdjson(new Blob([[node('F','$target','frame'),{kind:'canvas',key:'S',record:{id:'S',parent:{canvasId:'$target',entryElementId:'F'}}},node('A','S')].map(row=>JSON.stringify(row)).join('\n')]),root);await engine.settle();const first=engine.imports.getJobs().get(imported),F=first.stableIds['element:F'],A=first.stableIds['element:A'];assert(first.status==='completed'&&first.committedSourceKeys.length===3,'native cycle did not complete atomically');assert(document.elements.get(F).data.ports[0].nodeId===A,'native port kept the source ID'); const copied=await engine.imports.duplicate([{id:F,incarnation:1}]);await engine.settle();const job=engine.imports.getJobs().get(copied),F2=job.stableIds[`element:${F}`],A2=job.stableIds[`element:${A}`];assert(job.status==='completed'&&job.committedSourceKeys.length===3,'copy cycle lost a member');assert(document.elements.get(F2).data.ports[0].nodeId===A2&&A2!==A,'copy port attached to the original child'); + // 此 helper 核对提交后、undo/redo 和重开后的闭包,不订阅每个中间 Root;不能仅凭最终结果证明发布期间从无半组。 // 重开保持重映射;undo 不留下半组,redo 后子元素是第二代且端口仍连接同一新 ID。 await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get(F2).data.ports[0].nodeId===A2,'IndexedDB lost the copied reference');assert(engine.editor.history.undo().status==='applied','cycle undo failed');await engine.settle();assert(!document.elements.has(F2)&&!document.elements.has(A2)&&document.elements.has(F),'cycle undo exposed half a group');assert(engine.editor.history.redo().status==='applied','cycle redo failed');await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get(F2).data.ports[0].nodeId===A2&&engine.getSnapshot().document.elements.get(A2).incarnation===2,'cycle redo/reopen lost the restored identity'); }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}