docs(tests): explain legacy migration gates receipts and UI recovery

This commit is contained in:
2026-11-12 10:36:36 +08:00
parent bad68c6d59
commit 9ae7d2d56a
4 changed files with 86 additions and 0 deletions
+13
View File
@@ -1,21 +1,32 @@
/**
* 浏览器迁移控制器与旧画布编辑租约测试。旧编辑视图持 shared lease,迁移要求独占且不等待的 lease,避免新旧写入并行。
* 使用内存 localStorage/Web Locks 替身验证协调逻辑,不代表真实多标签 Web Locks 已在本文件执行。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {claimLegacyCanvasEditing,createBrowserCanvasMigration} from '../../src/lib/browser-canvas-migration.ts';
import {canvasMigrationStorageKey} from '../../src/lib/canvas-migration.ts';
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
// 确定性 UUID 及账户/workspace 作用域;editing 锁名与迁移持久化键都从此作用域派生。
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,scope={accountId:id(1),workspaceId:id(2)},root=id(3),registry=createRegistry(builtinComponents());
// 搭建 records、held 锁表及调用记录,controller 控制当前视图生命周期。
function fixture(){
const records=new Map(),held=new Map(),calls=[];
// 最小 localStorage 接口,保存原字符串以检查未来/损坏材料不会被覆盖。
const storage={getItem:key=>records.get(key)??null,setItem:(key,value)=>records.set(key,value),removeItem:key=>records.delete(key)};
// 异步模拟共享/独占互斥:不可用时调用 work(null),成功期间增加持有计数,finally 总是归还锁。
const locks={request(name,options,work){calls.push({name,options});return Promise.resolve().then(async()=>{
// shared 只排斥 exclusive,exclusive 排斥任意持有者;这是 fixture 的即时 ifAvailable 模拟,不实现公平等待队列。
const state=held.get(name)??{shared:0,exclusive:false},granted=options.mode==='shared'?!state.exclusive:!state.exclusive&&!state.shared;
if(!granted)return work(null);
if(options.mode==='shared')state.shared++;else state.exclusive=true;held.set(name,state);
try{return await work({name,mode:options.mode});}finally{if(options.mode==='shared')state.shared--;else state.exclusive=false;if(!state.shared&&!state.exclusive)held.delete(name);}
});}};
// 所有租约和迁移实例共享当前 AbortSignal;isCurrentIdentity 可在用例中覆写为过期身份。
const controller=new AbortController(),options={scope,registry,locks,storage,signal:controller.signal,isCurrentIdentity:()=>true};
return{records,held,calls,controller,options};
}
// 两个旧编辑器可共享锁,释放一个仍不能迁移;全部释放后 exclusive 迁移检查可进入 idle,所有请求均 ifAvailable。
test('editable legacy views share a lease and exclude migration until every editor releases',async()=>{
const f=fixture(),one=await claimLegacyCanvasEditing({...f.options,canvasId:root}),two=await claimLegacyCanvasEditing({...f.options,canvasId:root});
const migration=createBrowserCanvasMigration(f.options);
@@ -24,6 +35,7 @@ test('editable legacy views share a lease and exclude migration until every edit
assert.ok(f.calls.every(call=>call.name===canvasMigrationStorageKey(scope)+':editing'&&call.options.ifAvailable===true));
assert.deepEqual(f.calls.slice(0,2).map(call=>call.options.mode),['shared','shared']);assert.ok(f.calls.slice(2).every(call=>call.options.mode==='exclusive'));
});
// pending 迁移只阻止受影响旧 canvas,其他 canvas 可编辑;未来记录版本无法解释时拒绝但原字符串保持。
test('pending migration blocks affected old views and corrupt records cannot enable editing',async()=>{
const f=fixture(),key=canvasMigrationStorageKey(scope),job={version:1,...scope,rootId:root,id:id(4),phase:'pending',body:JSON.stringify({protocol_version:2,request_id:id(4),expected_versions:{[root]:1}})};
f.storage=f.options.storage;f.storage.setItem(key,JSON.stringify(job));
@@ -32,6 +44,7 @@ test('pending migration blocks affected old views and corrupt records cannot ena
const saved=JSON.stringify({version:2,data:'retain'});f.storage.setItem(key,saved);
await assert.rejects(()=>claimLegacyCanvasEditing({...f.options,canvasId:root}),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.held.size,0);
});
// 视图 abort 或身份失效不能取得迟到编辑授权;已领取和未领取路径都归还锁,重复 release 安全。
test('aborted or stale view lifetimes release leases and cannot acquire a late editing grant',async()=>{
const f=fixture(),lease=await claimLegacyCanvasEditing({...f.options,canvasId:root});f.controller.abort();await lease.release();assert.equal(f.held.size,0);await lease.release();
const g=fixture(),waiting=claimLegacyCanvasEditing({...g.options,canvasId:root});g.controller.abort();await assert.rejects(waiting,{name:'AbortError'});assert.equal(g.held.size,0);
+11
View File
@@ -1,21 +1,31 @@
/**
* 画布入口的 canonical-first 选择测试。已迁移项目直接进入 headless,只有明确迁移需求或未部署接口可走旧快照。
* 权限失败不能降级成旧数据读取;入口只保留已验证迁移材料,不自动发迁移请求。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
// UUID 工厂区分 board、workspace、profile,供入口解析和 service 身份验证。
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
// 模拟已解析 board,workspaceRef/boardRef 以明确 ID 输入,避免把 slug 解析与模式选择混成同一个断言。
const board = { id: id(1), workspace_id: id(2), kind: 'board', title: 'Board', version: 1 };
// 当前账户与内置 registry,transport 工厂必须收到该 profile 身份。
const profile = { id: id(3) }, registry = createRegistry(builtinComponents());
// request 提供 profile/board/旧 canvas,transport 返回注入 canonical 响应;所有路径记录到 calls 检查不应发生的 fallback。
function fixture(response) {
const calls = [], request = async path => { calls.push(path); if (path === 'profile') return profile; if (path.endsWith('/canvas')) return { version: 1, document: { schema_version: 2, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; return board; };
return { calls, options: { request, workspaceRef: board.workspace_id, boardRef: board.id, registry, transport: accountId => { assert.equal(accountId, profile.id); return async input => { calls.push(input.path); return response; }; } } };
}
// canonical 完整模型已可用时只进入 headless,不能再读取旧 canvas 快照制造第二份文档来源。
test('canonical-first entry never reads a legacy snapshot for an adopted project', async () => {
const f = fixture({ status: 200, body: { model: { documentFormatVersion: 1, projectId: board.id, complete: true, canvases: [{ id: board.id, parent: null }], elements: [], tombstones: {}, versions: { [board.id]: 1 }, removedCanvasIds: [] } } });
const result = await loadCanvasEntry(f.options); assert.equal(result.mode, 'headless'); assert.equal(result.model.document.projectId, board.id);
assert.equal(f.calls.some(path => path.endsWith('/canvas')), false);
});
// 404 未部署或合法 migration_required 允许读取旧快照;403 权限失败直接报错,绝不退回 legacy。
test('explicit migration requirement or missing deployment keeps legacy reads; permission failure does not fall back', async () => {
for (const response of [{ status: 404, body: { error: 'not_found' } }, { status: 409, body: { error: 'canonical_migration_required', root_id: board.id, document_format_version: 1, source_versions: { [board.id]: 1 } } }]) {
const f = fixture(response); assert.equal((await loadCanvasEntry(f.options)).mode, 'legacy'); assert.equal(f.calls.filter(path => path.endsWith('/canvas')).length, 1);
@@ -24,6 +34,7 @@ test('explicit migration requirement or missing deployment keeps legacy reads; p
await assert.rejects(() => loadCanvasEntry(f.options), /forbidden/); assert.equal(f.calls.some(path => path.endsWith('/canvas')), false);
});
// legacy 入口保留验证过的 root/source 版本向量及 profile/snapshot,不发 migrate;404 未部署则不提供虚构迁移材料。
test('legacy entry retains verified migration root and the full source vector without offering migration for an unavailable API',async()=>{
const child=id(4),f=fixture({status:409,body:{error:'canonical_migration_required',root_id:board.id,document_format_version:1,source_versions:{[board.id]:3,[child]:7}}});
const result=await loadCanvasEntry(f.options);
+27
View File
@@ -1,14 +1,24 @@
/**
* 旧快照到 canonical 身份迁移的持久请求状态机测试。先 drain 旧写、读取最新源版本、保存冻结 body,再派发迁移。
* 未知结果保留原 ID/字节,恢复先查询后按原请求重试;只有验证过的终态才可清理恢复材料或放开旧编辑。
* 内存存储与假服务端注入响应丢失、版本冲突及配额问题;真实 UI/Web Locks 场景由 renderer 集成探针另测。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
import {createCanvasMigration,readCanvasMigration,canvasMigrationStorageKey,migrationBlocksCanvas} from '../../src/lib/canvas-migration.ts';
// 固定 UUID 作用域及新元素身份;源旧 ID 为 a,成功迁移须返回声明映射到新元素的证明。
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),scope={accountId:id(3),workspaceId:id(4)},registry=createRegistry(builtinComponents());
// 保存迁移字符串、回执、冻结 body 和请求记录,开关区分提交前失败、提交后丢 ACK、明确拒绝和篡改响应。
function fixture(){
const records=new Map(),receipts=new Map(),bodies=new Map(),calls=[];
const f={records,receipts,calls,version:1,canonical:false,current:true,quota:false,loseResponse:false,failBeforeCommit:false,reject:false,alter:undefined};let sequence=100;
// 模拟 localStorage;quota 在 setItem 前抛错,removeItem 真正删除恢复记录。
const storage={getItem:key=>records.get(key)??null,setItem(key,value){if(f.quota)throw Error('quota');records.set(key,value);},removeItem:key=>records.delete(key)};
// 完整 canonical 模型与新身份 id(20),供成功回执的版本、元素类型和 identity_map 交叉校验。
const model=version=>({documentFormatVersion:1,projectId:root,complete:true,canvases:[{id:root,parent:null}],elements:[{id:id(20),incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'body'},geometry:{x:0,y:0,width:240,height:72,rotation:0}}],versions:{[root]:version},tombstones:{},removedCanvasIds:[]});
// GET 分别查迁移需求/完整模型和原回执;POST 先断言已持久化同一 body,再按 requestId 防止重复应用。
const transport=async input=>{
calls.push(input);
if(input.method==='GET'&&input.path.endsWith('/model'))return f.canonical?{status:200,body:{model:model(f.version)}}:{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:f.version}}};
@@ -17,10 +27,13 @@ function fixture(){
const response=known?{status:200,body:known.body}:{status:404,body:{status:'unknown',request_id:key,error:'operation_receipt_unknown'}};
return f.alter?f.alter(response,input):response;
}
// 网络派发前直接读取保存字符串,对照请求体,证明持久化屏障而非只检查内存中存在请求。
assert.equal(JSON.parse(storage.getItem(canvasMigrationStorageKey(scope))).body,input.body,'request must be durable before POST');
assert.equal(input.responseFormat,'canvas-v2');
// 提交前失败没有服务端回执;恢复只能查询 unknown 后精确重试,不能据传输异常推定 applied。
if(f.failBeforeCommit)throw Error('connection lost before commit');
const raw=JSON.parse(input.body);assert.equal(raw.request_id,input.requestId);
// 已知 key 必须使用相同冻结字节;新 key 根据 reject 开关生成可信零写入拒绝或带映射/完整模型的成功。
let response=receipts.get(input.requestId);
if(response){assert.equal(bodies.get(input.requestId),input.body,'retry changed frozen bytes');}
else{
@@ -28,13 +41,17 @@ function fixture(){
receipts.set(input.requestId,response);bodies.set(input.requestId,input.body);
if(!f.reject){f.version++;f.canonical=true;}
}
// 此时服务端事实与回执已保存,再模拟 ACK 丢失;客户端看到错误也不能生成另一次迁移。
if(f.loseResponse)throw Error('connection lost after commit');
return f.alter?f.alter(structuredClone(response),input):response;
};
// 组装迁移控制器,默认 lock 只执行 work(不证明真实互斥);current、createId 和 signal 可被用例注入。
f.storage=storage;f.make=extra=>createCanvasMigration({scope,storage,registry,transport,isCurrentIdentity:()=>f.current,createId:()=>id(++sequence),lock:async work=>work(),...extra});
// 从全部调用中过滤 POST,分别检查旧请求精确重试与明确重新准备产生新请求。
f.posts=()=>calls.filter(call=>call.method==='POST');return f;
}
// drain 必须在任何读请求前完成,之后重新读源版本;POST 使用 drain 后的版本和预先持久化 requestId,成功后清理记录。
test('migration drains first, refreshes source versions and persists exact request identity before dispatch',async()=>{
const f=fixture(),migration=f.make();let drained=false;
const result=await migration.start(root,async()=>{assert.equal(f.calls.length,0);drained=true;f.version=4;});
@@ -43,6 +60,7 @@ test('migration drains first, refreshes source versions and persists exact reque
assert.equal(f.posts()[0].requestId,JSON.parse(f.posts()[0].body).request_id);
});
// 服务提交成功但响应丢失,重建控制器先查原 key 即恢复成功;不再次 drain、不分配新 ID,也不发第二个 POST。
test('lost committed migration ACK is recovered by original query after reload without flushing or invoking twice',async()=>{
const f=fixture();f.loseResponse=true;const first=f.make();
assert.equal((await first.start(root,async()=>{})).status,'pending');const job=first.read();
@@ -52,6 +70,7 @@ test('lost committed migration ACK is recovered by original query after reload w
assert.equal(f.posts().length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/operations/'+job.id)));assert.equal(recovered.read(),undefined);
});
// 提交前断线的 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()=>{});
const original=first.read(),spaced={...original,body:' \n'+original.body+' '};f.storage.setItem(canvasMigrationStorageKey(scope),JSON.stringify(spaced));f.failBeforeCommit=false;
@@ -61,6 +80,7 @@ test('a durable uncommitted request queries unknown then retries only its exact
assert.equal(f.calls.at(-2).method,'GET');assert.equal(f.calls.at(-2).body,undefined);
});
// 存储配额或旧 writer drain 失败均不得发迁移;drain 失败也不能留下新恢复任务或触发网络读取。
test('storage and legacy-drain failure prevent dispatch and do not silently discard recovery state',async()=>{
const f=fixture();f.quota=true;
await assert.rejects(()=>f.make().start(root,async()=>{}),/quota/);assert.equal(f.posts().length,0);
@@ -69,6 +89,7 @@ test('storage and legacy-drain failure prevent dispatch and do not silently disc
assert.equal(f.calls.length,0);assert.equal(f.records.size,0);
});
// 可信 terminal not-applied 才将 phase 置 rejected 并释放编辑 gate;resume 不重发,显式 start 才刷新版本并生成新 key。
test('only verified terminal rejection releases the editing gate; a new explicit preparation refreshes and allocates',async()=>{
const f=fixture();f.reject=true;const migration=f.make();const result=await migration.start(root,async()=>{});
assert.equal(result.status,'rejected');const rejected=migration.read();assert.equal(rejected.phase,'rejected');assert.equal(migrationBlocksCanvas(rejected,root),false);
@@ -77,6 +98,7 @@ test('only verified terminal rejection releases the editing gate; a new explicit
assert.equal((await migration.start(root,async()=>{})).status,'applied');assert.notEqual(f.posts()[1].requestId,rejected.id);assert.deepEqual(JSON.parse(f.posts()[1].body).expected_versions,{[root]:7});
});
// 裸 403、错 key/源版本/空或错类型/跨 canvas 映射不构成成功证明,保持 pending gate;原回执查询修复后仍只有一次 POST。
test('bare rejection and malformed or foreign success retain the frozen request until the original query repairs it',async()=>{
for(const alter of [
response=>({status:403,body:{error:'forbidden'}}),
@@ -92,6 +114,7 @@ test('bare rejection and malformed or foreign success retain the frozen request
}
});
// 未来迁移格式及同 workspace 另一 pending 根不能被覆盖;换账户读取不同存储键,不泄漏旧请求。
test('future/corrupt migration material and another pending project cannot be overwritten',async()=>{
const f=fixture(),key=canvasMigrationStorageKey(scope);f.storage.setItem(key,JSON.stringify({version:2,payload:'retain'}));
const saved=f.storage.getItem(key);await assert.rejects(()=>f.make().start(root,async()=>assert.fail()),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.calls.length,0);
@@ -100,6 +123,7 @@ test('future/corrupt migration material and another pending project cannot be ov
assert.equal(readCanvasMigration(f.storage,{...scope,accountId:id(99)}),undefined);
});
// 响应回来时身份已变或 signal abort,不能清理旧账户恢复材料;回到有效身份后查询原回执,不重复迁移。
test('identity changes and aborted lifetimes cannot clear an old account request on a late success',async()=>{
const f=fixture();f.alter=response=>{f.current=false;return response;};
await assert.rejects(()=>f.make().start(root,async()=>{}),/identity_changed/);assert.equal(f.records.size,1);
@@ -108,6 +132,7 @@ test('identity changes and aborted lifetimes cannot clear an old account request
await assert.rejects(()=>g.make({signal:controller.signal}).start(root,async()=>{}),{name:'AbortError'});assert.equal(g.records.size,1);assert.equal(g.posts()[0].signal,controller.signal);
});
// 同控制器重入 resume 被 migration_busy 拒绝;完成后新控制器识别已 canonical,只返回 already-migrated 不再 POST。
test('reentrant actions cannot create a second in-flight migration and already migrated projects do not POST',async()=>{
const f=fixture();let entered,release;const started=new Promise(resolve=>entered=resolve),gate=new Promise(resolve=>release=resolve);
const original=f.make();
@@ -117,6 +142,7 @@ test('reentrant actions cannot create a second in-flight migration and already m
assert.equal((await original.start(root,async()=>{})).status,'already-migrated');assert.equal(f.posts().length,1);
});
// 成功后清理记录失败仍保留可恢复请求,重试查询即可;假存储吞掉 setItem 时以 migration_not_durable 拒绝派发。
test('failed result retirement remains recoverable and a storage port that drops writes cannot dispatch',async()=>{
const f=fixture(),storage={...f.storage,removeItem(){throw Error('storage unavailable');}};
await assert.rejects(()=>f.make({storage}).start(root,async()=>{}),/storage unavailable/);
@@ -124,6 +150,7 @@ test('failed result retirement remains recoverable and a storage port that drops
const g=fixture();await assert.rejects(()=>g.make({storage:{...g.storage,setItem(){}}}).start(root,async()=>{}),/migration_not_durable/);assert.equal(g.posts().length,0);
});
// 合法空模型允许 identity_map 为空,不应为了满足校验凭空生成不存在元素的映射。
test('a valid empty migration has no fabricated identity entries',async()=>{
const f=fixture();f.alter=response=>({...response,body:{...response.body,identity_map:[],model:{...response.body.model,elements:[]}}});
assert.equal((await f.make().start(root,async()=>{})).status,'applied');assert.equal(f.records.size,0);
+35
View File
@@ -1,42 +1,61 @@
/**
* 旧画布迁移的浏览器集成 fixture 与 CDP 验证流程,由 renderer runner 提供页面、输入、轮询和假服务端。
* 五份独立旧画布分别验证脏写排空、其他视图共享锁、未知请求精确恢复、已有草稿及 pending 期间新增草稿。
* 这是可执行探针定义;Node 单元测试不会自动执行这些浏览器步骤,也不把控制台 PASS 文本当作已运行证据。
* evaluate/until 的页面脚本字符串保持原字节,在调用边界解释其中的查找、注入和状态检查。
*/
import assert from 'node:assert/strict';
import {writeFile} from 'node:fs/promises';
import {createDocument} from '../../src/lib/canvas-core/index.ts';
// 创建可安装到测试 HTTP server 的路由处理器;复用外部 boards/documents/versions/model,迁移成功后让主 runner 提供 canonical 路由。
export function createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}){
// 五个 fixture 各有旧节点、版本、迁移回执与故障开关;loss before/after 区分提交前失败与提交后丢响应。
const states=Array.from({length:5},(_,index)=>{
const root=id(7000+index*10),element=id(7001+index*10);
boards.push({id:root,workspace_id:workspace,kind:'board',version:1,title:'Legacy upgrade '+index});
return{root,element,version:1,ready:false,loss:undefined,holdPatch:false,patchWait:undefined,releasePatch:undefined,receipts:new Map(),keys:new Map(),patches:0,posts:[],document:{schema_version:2,nodes:[{id:'old-card',type:'plainText',position:{x:500,y:200},width:240,height:100,data:{kind:'plain-text',title:'Old card',text:'Legacy content'}}],edges:[],viewport:{x:0,y:0,zoom:1}}};
});
// handle 只处理匹配 fixture 根的旧 canvas/迁移路由,返回 true 表示已响应,其余交给外层服务。
return{states,async handle(request,path,reply,bytes){
const state=states.find(value=>path.includes('/materials/'+value.root+'/canvas'));if(!state)return false;
const base=`/materials/${state.root}/canvas`,raw=bytes.length?JSON.parse(bytes.toString()):undefined;
// 未迁移时 canonical model 返回带 source_versions 的明确 migration_required;此 header 才能开启升级入口。
if(path.endsWith(base+'/model')&&!state.ready){reply(409,{error:'canonical_migration_required',root_id:state.root,document_format_version:1,source_versions:{[state.root]:state.version}});return true;}
// 旧快照 GET 返回当前 document;PATCH 校验账户和 If-Match,并禁止 ready 后旧 writer 再写入。
if(path.endsWith(base)){
if(request.method==='PATCH'){
assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['if-match'],`"${state.version}"`);assert.equal(state.ready,false,'retired snapshot writer sent after migration');
state.document=raw.document;state.version++;state.patches++;
// 可把旧 PATCH 的响应停在 gate,让 UI 升级必须等待脏 writer 收到确认,而不是仅等本地文本更新。
if(state.holdPatch){state.patchWait=new Promise(resolve=>state.releasePatch=resolve);await state.patchWait;}
reply(200,{version:state.version});
}else reply(200,{version:state.version,document:state.document});return true;
}
// 提供旧画布的最小能力与空 run 列表,保持 UI 能正常打开,避免无关加载缺口干扰迁移。
if(path.endsWith(base+'/capabilities')){reply(200,{subgraphs:{member_positions:true}});return true;}
if(path.endsWith(base+'/ai/runs')&&request.method==='GET'){reply(200,{runs:[]});return true;}
// 按原 key 查询已保存回执,未命中回 unknown;恢复测试靠这条路径消除执行不确定性。
if(path.includes(base+'/model/operations/')&&request.method==='GET'){
const key=path.split('/').at(-1),receipt=state.receipts.get(key);reply(receipt?200:404,receipt??{status:'unknown',request_id:key});return true;
}
// 迁移 POST 校验账户/幂等头并记录原始字符串;明确失败、精确重试与成功转换分别处理。
if(path.endsWith(base+'/model/migrate')){
assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],raw.request_id);
state.posts.push({id:raw.request_id,body:bytes.toString()});
// 模拟尚未应用就返回 503,不能因此生成成功回执;恢复时必须再查 unknown 并精确重试。
if(state.loss==='before'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
// 已有请求校验完全相同字节;新请求版本冲突返回 terminal not-applied,否则只执行一次身份转换。
let receipt=state.receipts.get(raw.request_id);
if(receipt){assert.equal(state.keys.get(raw.request_id),bytes.toString());}
else if(raw.expected_versions[state.root]!==state.version){
receipt={protocol_version:2,request_id:raw.request_id,status:'not-applied',terminal:true,error:'migration_version_conflict'};
}else{
// 读取最后一次已确认的旧节点内容,分配 fixture 的新 canonical ID,增加版本并退休旧 writer。
const old=state.document.nodes[0];state.version++;state.ready=true;versions.set(state.root,state.version);
documents.set(state.root,createDocument(state.root,[{id:state.root,parent:null}],[{id:state.element,incarnation:1,ownerCanvasId:state.root,componentId:'plain-text',dataVersion:1,structure:'node',data:{title:old.data.title,text:old.data.text},geometry:{...old.position,width:old.width,height:old.height,rotation:0}}]));
receipt={protocol_version:2,request_id:raw.request_id,status:'applied',source_versions:raw.expected_versions,identity_map:[{sourceCanvasId:state.root,sourceId:old.id,structure:'node',elementId:state.element}],model:model(documents.get(state.root))};
}
// 先保存请求事实和字节,再处理 loss after;即使客户端收到 503,后端已经可查询成功。
state.receipts.set(raw.request_id,receipt);state.keys.set(raw.request_id,bytes.toString());
if(state.loss==='after'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
reply(receipt.status==='applied'?200:412,receipt);return true;
@@ -45,31 +64,43 @@ export function createRendererMigrationFixture({id,account,workspace,boards,docu
}};
}
// 执行实际页面输入/重载/多视图场景,参数均由外部 runner 注入;本函数不自己启动浏览器或应用。
export async function verifyRendererMigration({fixture,call,evaluate,until,port,workspace,account,pause}){
// 统一构造测试本机路由,后续在五个独立画布之间跳转。
const route=root=>`http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
// 返回页面求值表达式,在“画布升级”区域按精确文字找按钮;JSON.stringify 保证文字安全嵌入脚本。
const button=label=>`[...document.querySelectorAll('[aria-label="画布升级"] button')].find(button=>button.textContent.trim()===${JSON.stringify(label)})`;
// 先在页面取已启用按钮中心,再用 CDP 发送真实鼠标按下/释放;不是调用 DOM click 的合成事件。
async function click(label){const point=await evaluate(`(()=>{const button=${button(label)};if(!button||button.disabled)throw Error('missing enabled upgrade button');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});}
// 旧编辑器可用条件同时检查升级入口和画布非 inert,避免只凭按钮出现就认为旧写入已放开。
const editable=()=>`!!${button('升级画布')}&&!document.querySelector('.react-flow').parentElement.inert`;
// 账户/workspace 级持久化迁移键,稍后比较保存的冻结 body 与服务实际收到字节。
const storageKey=`endoleap_canvas_migration_v1:${account}:${workspace}`;
// 五份状态按下方场景依次使用,互不污染版本、回执和旧 PATCH 数量。
const[a,b,c,d,e]=fixture.states;
await until(editable(),'legacy editor did not claim an editing lease');
// 场景 A:先找原旧卡片可编辑位置并用 elementFromPoint 验证没被遮挡,再真实双击/输入形成脏稿。
const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="old-card"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.4,y:r.y+Math.min(25,r.height*.3)}})()`);
assert.equal(await evaluate(`document.elementFromPoint(${point.x},${point.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'old-card','legacy edit point is covered by another surface');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:2});
await until(`document.activeElement?.tagName==='TEXTAREA'&&!document.activeElement.readOnly`,'legacy text did not enter editing');
await call('Input.insertText',{text:' migration edit'});const edited=await evaluate('document.activeElement.value');
// 暂停旧 PATCH 响应并让随后迁移丢 ACK,检查迁移没有越过旧写入确认屏障且已包含最新正文。
a.holdPatch=true;a.loss='after';await click('升级画布');
for(let i=0;i<100&&!a.releasePatch;i++)await pause(20);assert.ok(a.releasePatch,'upgrade did not drain its dirty legacy writer');assert.equal(a.posts.length,0,'migration crossed an unacknowledged legacy PATCH');assert.equal(a.document.nodes[0].data.text,edited);
a.holdPatch=false;a.releasePatch();
await until(`!!${button('继续查询升级')}`,'lost migration acknowledgement did not leave a resumable UI');
assert.equal(a.posts.length,1);assert.equal(await evaluate(`document.querySelector('.react-flow').parentElement.inert`),true);
// 未知期间旧画布应 inert;保存 body 必须与实际 POST 一致,并输出该 UI 状态截图供测试产物审查。
const frozen=await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}))`);assert.equal(frozen.body,a.posts[0].body);
await writeFile('/private/tmp/headless-migration-unknown.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
// 刷新页面后自动查询原迁移并进入 canonical,检查只迁移一次、旧 PATCH 只一次,已编辑正文保留。
await call('Page.reload');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${a.element}"]')&&!localStorage.getItem(${JSON.stringify(storageKey)})`,'canonical reload did not reconcile its saved migration');
assert.equal(a.posts.length,1);assert.equal(a.patches,1);assert.equal(a.version,3);assert.equal(JSON.parse(a.posts[0].body).expected_versions[a.root],2);
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${a.element}"] textarea').value`),edited);
console.log('PASS legacy dirty writer drains before identity migration; lost ACK reload queries once and retires snapshot writes');
// 场景 B:隐藏同源 iframe 再开同一旧画布,验证实际 Web Locks 共享租约阻止独占迁移;移除 peer 后才能成功。
await call('Page.navigate',{url:route(b.root)});await until(editable(),'second legacy editor did not open');
await evaluate(`(()=>{const frame=document.createElement('iframe');frame.id='migration-peer';frame.src=location.href;frame.style.display='none';document.body.append(frame);})()`);
await until(`(()=>{const doc=document.getElementById('migration-peer')?.contentDocument;return !!doc?.querySelector('[aria-label="画布升级"] button')&&!doc.querySelector('.react-flow')?.parentElement.inert;})()`,'another legacy view did not acquire its shared lease');
@@ -79,6 +110,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
await click('升级画布');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${b.element}"]')`,'upgrade did not open the canonical editor after the peer closed');assert.equal(b.posts.length,1);
console.log('PASS actual Web Locks exclude identity migration while another legacy view is editable');
// 场景 C:迁移提交前失败再发生远端版本变化,重载仍保持原请求 gate;精确重试零写入拒绝后才允许显式重新准备。
await call('Page.navigate',{url:route(c.root)});await until(editable(),'third legacy editor did not open');c.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'uncommitted request did not remain resumable');
const original=c.posts[0];c.version++;c.document.nodes[0].data.text='Remote edit before recovery';
await call('Page.reload');await until(`!!${button('继续查询升级')}&&document.querySelector('.react-flow').parentElement.inert`,'reload enabled old writes before resolving the frozen migration');await pause(1000);assert.equal(c.patches,0);
@@ -90,6 +122,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${c.element}"] textarea').value`),'Remote edit before recovery');
console.log('PASS legacy reload remains frozen until exact retry resolves; known rejection requires explicit fresh preparation');
// 场景 D:已有不同本地恢复草稿,升级必须先展示并等待显式草稿选择,不能在恢复选择前 PATCH 或迁移。
await call('Page.navigate',{url:route(d.root)});await until(editable(),'draft conflict fixture did not open');
const draft=structuredClone(d.document);draft.nodes[0].data.text='Draft from another closed editor';
await evaluate(`localStorage.setItem(${JSON.stringify(`endoleap_canvas_v1:${workspace}:${d.root}:recovery`)},${JSON.stringify(JSON.stringify({document:draft,baseVersion:1,dirty:true}))})`);
@@ -103,6 +136,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${d.element}"] textarea').value`),draft.nodes[0].data.text);
console.log('PASS a different cached legacy draft blocks migration until explicit recovery and survives the handoff');
// 场景 E:迁移结果未知期间出现恢复草稿,先保留草稿但不弹 modal 阻塞查询;明确拒绝后才允许恢复草稿和新准备。
await call('Page.navigate',{url:route(e.root)});await until(editable(),'pending draft fixture did not open');
e.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'pending draft fixture did not retain the original upgrade');
const retained=structuredClone(e.document);retained.nodes[0].data.text='Retained while migration is unknown';
@@ -114,6 +148,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
assert.equal(await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(recoveryKey)})).document.nodes[0].data.text`),retained.nodes[0].data.text);assert.equal(e.patches,0);
await click('继续查询升级');await until(`!!document.querySelector('dialog[open]')&&!document.querySelector('.react-flow').parentElement.inert`,'terminal rejection did not make the retained draft recoverable');
assert.equal(e.posts.length,2);assert.deepEqual(e.posts[1],e.posts[0]);
// 用户明确恢复保留稿后,再点击重新准备产生新迁移 key;新 canonical 编辑器应显示该草稿正文。
const retainedPoint=await evaluate(`(()=>{const button=[...document.querySelectorAll('dialog button')].find(button=>button.textContent==='恢复本地草稿');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...retainedPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...retainedPoint,button:'left',buttons:0,clickCount:1});
await until(`!document.querySelector('dialog[open]')`,'retained draft choice did not close recovery');