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

161 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧画布迁移的浏览器集成 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');
}