Files
Endoleap/tests/headless/renderer-migration.mjs

125 lines
15 KiB
JavaScript

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');
}