From 9ae7d2d56af9a131ac880dcd07e57d585e93d18b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 10:36:36 +0800 Subject: [PATCH] docs(tests): explain legacy migration gates receipts and UI recovery --- tests/headless/browser-migration.test.mjs | 13 +++++++++ tests/headless/entry.test.mjs | 11 +++++++ tests/headless/migration.test.mjs | 27 +++++++++++++++++ tests/headless/renderer-migration.mjs | 35 +++++++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/tests/headless/browser-migration.test.mjs b/tests/headless/browser-migration.test.mjs index 52516b0..9ae7ac8 100644 --- a/tests/headless/browser-migration.test.mjs +++ b/tests/headless/browser-migration.test.mjs @@ -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); diff --git a/tests/headless/entry.test.mjs b/tests/headless/entry.test.mjs index 0b22544..9fa696d 100644 --- a/tests/headless/entry.test.mjs +++ b/tests/headless/entry.test.mjs @@ -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); diff --git a/tests/headless/migration.test.mjs b/tests/headless/migration.test.mjs index b3d67b8..5c2593b 100644 --- a/tests/headless/migration.test.mjs +++ b/tests/headless/migration.test.mjs @@ -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); diff --git a/tests/headless/renderer-migration.mjs b/tests/headless/renderer-migration.mjs index d819454..4a02dcb 100644 --- a/tests/headless/renderer-migration.mjs +++ b/tests/headless/renderer-migration.mjs @@ -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');