diff --git a/tests/headless/asset-adoption.test.mjs b/tests/headless/asset-adoption.test.mjs index 5f519f3..da8e817 100644 --- a/tests/headless/asset-adoption.test.mjs +++ b/tests/headless/asset-adoption.test.mjs @@ -63,6 +63,7 @@ const adopt=(engine,id='picture')=>engine.adoptResource('shared',(handle,tx)=>tx test('adoption atomically publishes binding/history/ledger and reopens shared bytes without an upload',async()=>{ const f=fixture();let engine=await f.open();try{ assert.equal((await adopt(engine)).status,'applied');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().runtime.resources.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings.get('shared').digest,digest);await engine.settle(); + // 检查同一提交 cut 中的 depot 写入、文档、历史和 ledger;显示成功本身不足以证明这些状态一起持久保存。 const image=f.roots.get('p');assert.equal(image.formatVersion,13);decodeRecovery(image,registry);const cut=f.batches.find(batch=>batch.image.payload.runtime.assetBindings);assert.equal(cut.depot.writes[0].bytes,bytes);assert.equal(cut.files.length,0);assert.equal(cut.image.payload.document.elements.length,1);assert.equal(cut.image.payload.history.undo.length,1);assert.equal(cut.image.payload.runtime.ledger.operations.some(([,row])=>row.intent.operationType==='asset.upload'),false); await engine.close();engine=await f.open();const handle=await engine.displayResource('shared');assert.equal(await(await fetch(handle.url)).text(),'original bytes');handle.release(); assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(f.calls.filter(v=>v==='read').length,1);assert.equal(f.errors.length,0); @@ -113,7 +114,8 @@ test('an older Root cannot release newly adopted bytes while its unpinned manife const ready=new Promise(resolve=>started=resolve),gate=new Promise(resolve=>release=resolve);let once=true; f.storage.commit=async(leader,batch)=>{if(once){once=false;assert.equal(batch.depot.pins.some(([id])=>id==='shared'),false);started();await gate;}return commit(leader,batch);}; engine.editor.updateRuntime(previous=>({...previous}));await ready; - // GC is allowed here: the old Root has no working/history/ACK/job reference. + // 旧 Root 已没有 working/history/ACK/job 引用,手工清空共享缓存模拟这时发生 GC。 + // 随后的 fresh 接纳属于更新的 Root,旧提交结束不能清掉新 Root 仍需落盘的待写字节。 f.cache.clear();assert.equal((await adopt(engine,'fresh')).status,'applied');release();await engine.settle();assert.equal(await f.cache.values().next().value.bytes.text(),'original bytes');assert.equal(f.roots.get('p').payload.document.elements.some(element=>element.id==='fresh'),true); }finally{release?.();f.storage.commit=commit;await engine.close();} }); diff --git a/tests/headless/asset-depot.test.mjs b/tests/headless/asset-depot.test.mjs index fa29caa..cec8840 100644 --- a/tests/headless/asset-depot.test.mjs +++ b/tests/headless/asset-depot.test.mjs @@ -23,6 +23,7 @@ async function fixture(){ test('new cross-project adoption reauthorizes even when immutable bytes are warm',async()=>{ const f=await fixture(),signal=new AbortController().signal; assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes); + // fixture 的缓存端口不会自行写入;显式放入上次字节来制造热缓存,再检查 resolve 仍调用而 read 不再调用。 f.setCached({ref:f.ref,bytes:f.bytes});assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes);assert.deepEqual(f.counts(),{resolves:2,reads:1}); f.deny();await assert.rejects(f.depot.authorize(f.leader,f.authority,'asset',signal),/denied/);assert.deepEqual(f.counts(),{resolves:3,reads:1});f.depot.close(); }); @@ -44,6 +45,7 @@ test('binary asset transport preserves original bytes and enforces identity and // 用指定 Response 替代 fetch,保留真实 transport 的流读取、大小限制及身份检查。 const transport=response=>createFoundryTransport({accountId,isCurrentIdentity:()=>current,fetch:async()=>response}); const result=await transport(new Response('original',{headers:{'content-type':'image/png'}}))({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'});assert.equal(await result.bytes.text(),'original');assert.equal(result.bytes.type,'image/png'); + // 不给 Content-Length,直接推入超限流块,验证预算按实际接收字节而不是响应头声明执行。 const oversized=new Response(new ReadableStream({start(c){c.enqueue(new Uint8Array(20*1024*1024+1));},cancel(){cancelled=true;}}));await assert.rejects(transport(oversized)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'asset_byte_budget_exceeded'});assert.equal(cancelled,true); const identityResponse=new Response(new ReadableStream({pull(c){current=false;c.enqueue(new Uint8Array([1]));}}));await assert.rejects(transport(identityResponse)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'identity_changed'}); }); diff --git a/tests/headless/asset-service.test.mjs b/tests/headless/asset-service.test.mjs index c6a75fc..7b935a2 100644 --- a/tests/headless/asset-service.test.mjs +++ b/tests/headless/asset-service.test.mjs @@ -32,6 +32,7 @@ test('asset adapter preserves byte identity and headers and validates the scoped const altered = { ...result, canonical: { ...f.receipt, asset: { ...f.receipt.asset, ...patch } } }; assert.throws(() => f.assets.accept(f.request, altered, f.resource), /asset_receipt_mismatch/); } + // different 的长度也不同,因此这里证明预发送校验拒绝了不匹配输入;不是单独隔离“等长但摘要变化”的样本。 const changed = await f.assets.upload(f.request, new Blob(['different']), new AbortController().signal); assert.equal(changed.outcome, 'unresolved'); assert.equal(changed.blocked, true); assert.equal(f.calls.length, 1); }); @@ -64,6 +65,7 @@ test('multipart browser transport leaves the boundary to fetch and binds the exp const transport = createFoundryTransport({ accountId: id(5), isCurrentIdentity: () => true, fetch: async (url, init) => { captured = { url, init }; return Response.json({ status: 'applied' }); } }); const body = new FormData(); body.append('image', new Blob(['image']), 'a.png'); await transport({ method: 'POST', path: 'workspaces/example/assets', body, requestId: id(3), resourceId: id(1) }); + // fetch 是捕获参数的替身,没有真正编码/发送 multipart;断言要求宿主不手写 Content-Type,保留平台生成 boundary 的入口。 const headers = new Headers(captured.init.headers); assert.equal(headers.has('content-type'), false); assert.equal(headers.get('X-Expected-Account'), id(5)); assert.equal(headers.get('X-Resource-Id'), id(1)); assert.equal(headers.get('Idempotency-Key'), id(3)); assert.equal(captured.init.body, body); diff --git a/tests/headless/display-cache.test.mjs b/tests/headless/display-cache.test.mjs index 5207c74..3071acb 100644 --- a/tests/headless/display-cache.test.mjs +++ b/tests/headless/display-cache.test.mjs @@ -36,6 +36,7 @@ test('last release aborts a load; a late source cannot replace or evict a newly const cancelled = assert.rejects(old.ready, { code: 'display_released' }); old.release(); await cancelled; assert.equal(signal.aborted, true); const next = cache.acquire(owner, 'asset', async () => ({ url: 'blob:new', release: () => newDisposed++ })); assert.equal(await next.ready, 'blob:new'); gate.resolve({ url: 'blob:old', release: () => oldDisposed++ }); await tick(); assert.equal(oldDisposed, 1); assert.equal(newDisposed, 0); + // peer 的 load 一旦调用就抛错;成功读到 blob:new 证明迟到旧源没有逐出当前同键条目。 const peer = cache.acquire(owner, 'asset', async () => { throw Error('unexpected duplicate read'); }); assert.equal(await peer.ready, 'blob:new'); peer.release(); next.release(); assert.equal(newDisposed, 1); }); // 读取失败保留原错误码并移除失败条目,新 acquire 可重新读取成功,不永久缓存错误。 @@ -63,6 +64,7 @@ test('cache close rejects waiting readers immediately and disposes late data wit assert.throws(() => cache.acquire({}, 'asset', async () => ({ url: '/late' })), { code: 'display_scope_closed' }); cache.close(); }); // 空 URL 等非法源需清理已分配资源;某个 release 抛错通过 onError 报告,不能阻止其他 owner 的清理。 +// disposed 在这里直接累加每次底层 release 调用,与幂等句柄内部去重后的有效释放计数不同。 test('invalid sources clean their allocation and cleanup errors do not interrupt other owners', async () => { const errors = [], cache = createDisplayCache({ onError: error => errors.push(error) }); let disposed = 0; const invalid = cache.acquire({}, 'invalid', async () => ({ url: '', release: () => disposed++ })); await assert.rejects(invalid.ready, { code: 'invalid_display_source' }); assert.equal(disposed, 1); diff --git a/tests/headless/orphan-files.test.mjs b/tests/headless/orphan-files.test.mjs index bc2abbe..f345796 100644 --- a/tests/headless/orphan-files.test.mjs +++ b/tests/headless/orphan-files.test.mjs @@ -51,6 +51,7 @@ test('retired orphan identity and v5 recovery survive reopening and reject new r }finally{await engine.close();} }); // 文件大小与 MIME 相同也可能换了字节;digest 改变必须重置无引用起点,重新等待完整保留期才能删除。 +// aa→bb 都是两字节,且 MIME 都为空,故新起点只能由内容摘要变化触发;假时钟分别检查旧/新保留期。 test('changed orphan bytes reset the retention interval even when size and MIME stay equal',async()=>{ const f=fixture(new Map([['old',new Blob(['aa'])]])),engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:100});const digest=engine.getSnapshot().runtime.orphanFiles.get('old').digest;f.advance(100);f.files.set('old',new Blob(['bb']));const changed=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(changed.changedIds,['old']);assert.deepEqual(changed.purgedIds,[]);const record=engine.getSnapshot().runtime.orphanFiles.get('old');assert.notEqual(record.digest,digest);assert.equal(record.retirement.unreferencedAt,1100);f.advance(100);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,['old']);}finally{await engine.close();} }); @@ -65,6 +66,7 @@ test('hash budgets preserve pagination progress and a changed Root invalidates r }finally{gate.resolve();await engine.close();} }); // abort 后迟到字节不得发布盘点结果;删除批次保存失败时保留原清理切点及资源访问屏障,persist 重试完成同一次删除。 +// disk failure 在委托原 commit 之前抛出,所以 files 仍有原字节;显示屏障防止未完成的删除被新访问穿过。 test('cancelling verification never publishes late byte results and failures retain the original purge cut',async()=>{ const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile,cancel=new AbortController();let fail=false;const commit=f.storage.commit;try{f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const task=engine.collectOrphanFiles({retentionMs:0,signal:cancel.signal});await entered.promise;cancel.abort();await assert.rejects(task,/file_inventory_aborted/);gate.resolve();await Promise.resolve();assert.equal(engine.getSnapshot().runtime.orphanFiles?.size??0,0);f.storage.readFile=read;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectOrphanFiles({retentionMs:0}),/disk failure/);assert.ok(f.files.has('old'));await assert.rejects(engine.displayResource('old'),/resource_collection_in_progress/);fail=false;await engine.persist();assert.equal(f.files.has('old'),false);assert.ok(decodeRecovery(f.image(),registry).runtime.orphanFiles.get('old').retirement.purgedAt!==undefined); }finally{fail=false;gate.resolve();await engine.close();}