import assert from 'node:assert/strict'; import {writeFile} from 'node:fs/promises'; import {createDocument} from '../../src/lib/canvas-core/index.ts'; export function createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}){ 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}}}; }); 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; 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;} 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++; 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; } 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;} 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; } 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()}); if(state.loss==='before'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;} 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{ 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))}; } 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; }}; } export async function verifyRendererMigration({fixture,call,evaluate,until,port,workspace,account,pause}){ const route=root=>`http://127.0.0.1:${port}/dashboard/${workspace}/${root}`; const button=label=>`[...document.querySelectorAll('[aria-label="画布升级"] button')].find(button=>button.textContent.trim()===${JSON.stringify(label)})`; 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});} const editable=()=>`!!${button('升级画布')}&&!document.querySelector('.react-flow').parentElement.inert`; const storageKey=`endoleap_canvas_migration_v1:${account}:${workspace}`; const[a,b,c,d,e]=fixture.states; await until(editable(),'legacy editor did not claim an editing lease'); 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'); 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); 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')); 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'); 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'); 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'); 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'); 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]); 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'); }