/** * 旧画布迁移的浏览器集成 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 返回 409,响应正文含 migration_required 和 source_versions;不是靠响应 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; } return false; }}; } // 执行实际页面输入/重载/多视图场景,参数均由外部 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 与服务实际收到字节。 // 原请求尚未知时保留同一 key/body;只有 terminal not-applied 证明零写入后,显式重新准备才可以取新版本生成新请求。 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'); await click('升级画布');await until(`document.querySelector('[aria-label="画布升级"]').textContent.includes('其他旧画布页面')&&!document.querySelector('.react-flow').parentElement.inert`,'another editing view did not prevent the identity handoff');assert.equal(b.posts.length,0); await evaluate(`document.getElementById('migration-peer').remove()`); await until(`navigator.locks.query().then(value=>value.held.filter(lock=>lock.name===${JSON.stringify(storageKey+':editing')}).length===1)`,'peer editing lease was not released'); 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); await click('继续查询升级');await until(`!!${button('重新准备升级')}&&!document.querySelector('.react-flow').parentElement.inert`,'known zero-effect rejection did not release editing'); assert.equal(c.posts.length,2);assert.deepEqual(c.posts[1],original,'recovery rebuilt a request with newer source versions'); await click('重新准备升级');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${c.element}"]')`,'explicit fresh preparation did not upgrade after a version conflict'); assert.equal(c.posts.length,3);assert.notEqual(c.posts[2].id,original.id);assert.equal(JSON.parse(c.posts[2].body).expected_versions[c.root],2); assert.equal(c.document.nodes[0].data.text,'Remote edit before recovery'); 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}))})`); await click('升级画布');await until(`!!document.querySelector('dialog[open]')&&!document.querySelector('.react-flow').parentElement.inert`,'different cached draft was not surfaced before migration'); assert.equal(d.posts.length,0);assert.equal(d.patches,0); const restorePoint=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',...restorePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...restorePoint,button:'left',buttons:0,clickCount:1}); await until(`!document.querySelector('dialog[open]')`,'explicit draft choice did not close recovery'); await click('升级画布');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${d.element}"]')`,'recovered draft did not survive migration'); assert.equal(d.document.nodes[0].data.text,draft.nodes[0].data.text);assert.equal(d.posts.length,1); 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'; const recoveryKey=`endoleap_canvas_v1:${workspace}:${e.root}:recovery`; await evaluate(`localStorage.setItem(${JSON.stringify(recoveryKey)},${JSON.stringify(JSON.stringify({document:retained,baseVersion:e.version,dirty:true}))})`); e.version++;e.document.nodes[0].data.text='Remote version invalidates the pending upgrade'; await call('Page.reload');await until(`!!${button('继续查询升级')}&&document.querySelector('.react-flow').parentElement.inert`,'pending upgrade did not keep its editing gate'); assert.equal(await evaluate(`!!document.querySelector('dialog[open]')`),false,'draft dialog trapped the original receipt query behind a modal'); 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'); await click('重新准备升级');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${e.element}"]')`,'explicitly recovered pending draft did not reach the new editor'); assert.equal(e.posts.length,3);assert.notEqual(e.posts[2].id,e.posts[0].id); assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${e.element}"] textarea').value`),retained.nodes[0].data.text); console.log('PASS a retained draft cannot trap receipt recovery behind a modal and is restored only after terminal rejection'); }