160 lines
19 KiB
JavaScript
160 lines
19 KiB
JavaScript
/**
|
||
* 旧画布迁移的浏览器集成 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;
|
||
}
|
||
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 与服务实际收到字节。
|
||
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');
|
||
}
|