From 1defe9db90aff97e37dd6be0f3aab268a166b1a5 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:53:12 +0800 Subject: [PATCH] docs(tests): explain migration retries and explicit conflict decisions --- tests/headless/conflicts.test.mjs | 5 ++++- tests/headless/migration.test.mjs | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/headless/conflicts.test.mjs b/tests/headless/conflicts.test.mjs index 007e440..e01f02e 100644 --- a/tests/headless/conflicts.test.mjs +++ b/tests/headless/conflicts.test.mjs @@ -63,6 +63,7 @@ test('keep-local freezes a new conditional intent/key, retains the old rejection const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote',text:'untouched remote'}); const before=engine.getSnapshot(),old=[...before.runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); assert.equal(engine.getSnapshot(),before);assert.equal(plan.document.elements.get('a').data.title,'local');assert.equal(plan.document.elements.get('a').data.text,'untouched remote'); + // keep-local 是基于远程 before 值重新表达意图,不是重发旧请求;旧冻结 body 与 not-applied 证据应继续留存。 const replacement=plan.operations.get(plan.operationIds[0]);assert.notEqual(replacement.id,old.operationIds[0]);assert.equal(replacement.changes[0].before,'remote'); assert.equal(plan.ledger.requests.get(old.id).body,old.body);assert.equal(plan.ledger.operations.get(old.operationIds[0]).resolution,'superseded'); f.reject=false;assert.equal((await session.resolve(plan)).status,'applied');await engine.settle(); @@ -122,6 +123,7 @@ test('recovery publication failure keeps one plan and blocks sends until the exa const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine)); f.reject=false;f.broken=true;await assert.rejects(session.resolve(plan),/quota/);const revision=engine.getSnapshot().rootRevision;assert.equal(f.calls.length,1); await assert.rejects(session.apply(),/recovery_already_committed/);await assert.rejects(session.resolve({...plan}),/invalid_recovery_plan/); + // 使用原 plan 对象重试保存,避免同一恢复决策被重复发布;持久屏障解除后才允许替代请求发送。 f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,2);assert.ok(engine.getDurability().durableRootRevision>revision);assert.equal(engine.getSnapshot().runtime.recoveries.size,1);await engine.close(); }); @@ -184,7 +186,7 @@ test('discard-only resolution rechecks current identity and a cancelled read can const f=fixture();let allowed=true,release,signal;const engine=await f.open();title(engine,'local');await engine.settle(); const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));await session.cancel(); const actualRead=f.port.read;f.port.read=async input=>{signal=input;await new Promise(resolve=>{release=resolve;});return actualRead(input);};const preparing=engine.prepareRecovery();await tick();await engine.cancelRecovery();assert.equal(signal.aborted,true);release();await assert.rejects(preparing,/recovery_session_closed/);assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'more'}}).enabled,true);await engine.close(); - // The policy is evaluated again even when all chosen operations will be discarded. + // 全部 discard 仍会改变本地历史/账本,提交时须再次检查策略;不能因没有新文档请求就跳过当前身份检查。 const other=fixture(),guarded=await openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:other.storage,persistence:other.port,policy:()=>allowed?{enabled:true}:{enabled:false,code:'identity_changed',targets:[]}}); title(guarded,'local');await guarded.settle();const review=await guarded.prepareRecovery(),discard=review.plan(choices(guarded,'discard-local'));allowed=false;assert.equal((await review.resolve(discard)).code,'identity_changed');assert.equal(guarded.getSnapshot().runtime.recoveries.size,0);await review.cancel();await guarded.close(); }); @@ -200,6 +202,7 @@ test('later locally elided history survives keep/discard of an earlier rejected const localIds=[...created.operationIds,...removed.operationIds];assert.equal(f.calls.length,1);for(const id of localIds)assert.equal(engine.getSnapshot().runtime.ledger.operations.get(id).resolution,'elided'); f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,keep?'keep-local':'discard-local'));assert.equal(plan.document.tombstones.get(temporary.id),1);f.reject=false;await session.resolve(plan);await engine.settle(); await engine.close();engine=await f.open();assert.equal(engine.getSnapshot().document.tombstones.get(temporary.id),1);assert.equal(engine.getSnapshot().runtime.acknowledged.tombstones.has(temporary.id),false);assert.equal(engine.getSnapshot().history.undo.length,keep?3:2); + // localIds 已被本地抵消但仍有撤销历史;逐请求检查它们未被重新塞入网络同步,同时重开后墓碑仍有效。 for(const request of engine.getSnapshot().runtime.ledger.requests.values())assert.equal(request.operationIds.some(id=>localIds.includes(id)),false); assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(temporary.id).incarnation,2); assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(temporary.id),false);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,keep?'pending':'remote'); diff --git a/tests/headless/migration.test.mjs b/tests/headless/migration.test.mjs index 5c2593b..5187210 100644 --- a/tests/headless/migration.test.mjs +++ b/tests/headless/migration.test.mjs @@ -73,6 +73,7 @@ test('lost committed migration ACK is recovered by original query after reload w // 提交前断线的 durable 请求先 query unknown,再保留原 ID 和包含额外空白的 body 精确重试,不能重序列化。 test('a durable uncommitted request queries unknown then retries only its exact original body and key',async()=>{ const f=fixture();f.failBeforeCommit=true;const first=f.make();await first.start(root,async()=>{}); + // 空白不会改变 JSON 语义,却会改变请求原字节;人为补空白后检查恢复转发原字符串,不能 parse 后重新 stringify。 const original=first.read(),spaced={...original,body:' \n'+original.body+' '};f.storage.setItem(canvasMigrationStorageKey(scope),JSON.stringify(spaced));f.failBeforeCommit=false; const recovered=f.make({createId(){assert.fail('must retain original key');}}); assert.equal((await recovered.resume()).status,'applied'); @@ -108,6 +109,7 @@ test('bare rejection and malformed or foreign success retain the frozen request response=>({...response,body:{...response.body,identity_map:[{sourceCanvasId:child,structure:'node',sourceId:'a',elementId:id(20)}]}}), response=>({...response,body:{...response.body,identity_map:[{sourceCanvasId:root,structure:'connection',sourceId:'a',elementId:id(20)}]}}), ]){ + // alter 只破坏返回给客户端的副本;假服务已保存合法回执,所以关闭篡改后可查询同一成功请求。 const f=fixture();f.alter=alter;const migration=f.make();assert.equal((await migration.start(root,async()=>{})).status,'pending'); const frozen=migration.read();assert.equal(frozen.phase,'pending');assert.equal(migrationBlocksCanvas(frozen,root),true); f.alter=undefined;assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1); @@ -147,6 +149,7 @@ test('failed result retirement remains recoverable and a storage port that drops const f=fixture(),storage={...f.storage,removeItem(){throw Error('storage unavailable');}}; await assert.rejects(()=>f.make({storage}).start(root,async()=>{}),/storage unavailable/); assert.equal(f.records.size,1);assert.equal((await f.make().resume()).status,'applied');assert.equal(f.posts().length,1); + // setItem 不抛错但吞掉写入,读回检查仍必须发现未持久化;不能把函数返回当作落盘成功证据。 const g=fixture();await assert.rejects(()=>g.make({storage:{...g.storage,setItem(){}}}).start(root,async()=>{}),/migration_not_durable/);assert.equal(g.posts().length,0); });