Files
Endoleap/tests/canvas-upload-scenario.mjs
T

106 lines
12 KiB
JavaScript

/**
* 旧快照画布的 DOM 上传回归场景,由 canvas-ui-check 注入 React root、事件和 API 替身。
* 不访问真实上传服务;文件内容是字符串 test,图片尺寸/加载事件由测试提供,不能证明真实图片解码或浏览器布局。
*/
import assert from 'node:assert/strict';
// 依次验证本地预览、上传期间编辑、失败重试、取消/删除、迟到响应、尺寸和卸载清理。
export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyCanvas,event,tick,mock}) {
// 固定画布与资源身份;asset 是模拟服务返回的受管原图地址和元数据。
const parent='cccccccc-cccc-4ccc-8ccc-cccccccccccc',assetID='dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const asset={id:assetID,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${assetID}`,width:640,height:1280,bytes:4,mime_type:'image/png'};
let snapshot,uploads,saves,release,mode;
// 把替身结果编码为 Response,走应用实际的响应处理路径。
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
// 包装 revokeObjectURL 记录释放行为,同时调用原实现;finally 恢复包装和 Image 构造器。
const revoked=[],originalRevoke=URL.revokeObjectURL;URL.revokeObjectURL=(url)=>{revoked.push(url);originalRevoke(url);};
const NativeImage=window.Image;
// 最多轮询 100 次,每轮等待 30ms 并通过 act 推进 effect;超时用 label 标记未完成阶段。
const wait=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);};
// 先卸载旧画布再清空草稿,为每种响应模式重建空快照、上传记录和 PATCH 记录。
async function reset(nextMode='delayed'){
await act(async()=>{root.render(null);await tick();});localStorage.clear();snapshot={version:1,document:{...emptyCanvas(),schema_version:2}};uploads=[];saves=[];release=null;mode=nextMode;
// 内存服务:上传可立即成功、403 失败或挂起;快照 PATCH 检查版本,并禁止持久化 blob URL。
mock(async(url,options={})=>{
const parts=new URL(url,'http://localhost').pathname.split('/'),suffix=parts.slice(8).join('/'),method=options.method||'GET';
if(parts.at(-1)==='assets'&&method==='POST'){
const file=options.body.get('image');uploads.push({key:options.headers['Idempotency-Key'],bytes:Buffer.from(await file.arrayBuffer()),signal:options.signal});
if(mode==='failed')return json({error:'forbidden'},403);
// 仅延迟模式保留 release,场景手动释放后才返回成功,制造本地编辑与 ACK 的竞态。
if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
return json(asset,201);
}
if(suffix==='capabilities')return json({subgraphs:{member_positions:true}});
if(suffix==='ai/runs')return json({runs:[]});
if(!suffix&&method==='PATCH'){
assert.ok(!options.body.includes('blob:'),'Local object URLs must never be saved');assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);
snapshot.document=JSON.parse(options.body).document;snapshot.version++;saves.push(structuredClone(snapshot.document));return json({version:snapshot.version});
}
if(!suffix)return json(snapshot);
throw new Error('Unhandled upload request '+url);
});
await act(async()=>{root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Upload test',version:1},snapshot:structuredClone(snapshot)}));await tick();});
}
// 点击工具栏并注入 FileList 替身,再派发 change;只模拟选文件流程,不调用系统文件选择器。
async function choose(label='添加图片'){
await event(document.querySelector(`[aria-label="${label}"]`),'click');
const input=document.querySelector('input[type="file"][accept^="image/"]');
Object.defineProperty(input,'files',{configurable:true,value:[new File(['test'],'local.png',{type:'image/png'})]});
await act(async()=>{input.dispatchEvent(new window.Event('change',{bubbles:true}));await tick();});
await wait(()=>uploads.length,'upload started');
}
try {
// 首次上传先检查 blob 预览已出现、字节到达替身,但服务端文档和保存记录仍为空。
await reset();await choose();
const local=document.querySelector('.react-flow__node img[src^="blob:"]');assert.ok(local,'Selected file immediately appears on the canvas before upload acknowledgement');const localURL=local.getAttribute('src');
assert.ok(document.querySelector('[aria-label="图片上传状态"]'));assert.equal(snapshot.document.nodes.length,0);assert.equal(saves.length,0);assert.deepEqual(uploads[0].bytes,Buffer.from('test'));
const imageNodeID=local.closest('.react-flow__node').dataset.id;
// 按最初节点 ID 每次重查 DOM,避免异步重渲染后继续持有已替换元素。
const editable=()=>document.querySelector(`.react-flow__node[data-id="${imageNodeID}"]`);
assert.ok(editable().classList.contains('draggable'),'Uploading images are real draggable nodes');
// 上传挂起时实际派发拖动、标题输入和缩放事件,记录结果用于 ACK 后比较。
await event(editable(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:280,clientY:250,buttons:1});await event(window,'mouseup',{clientX:280,clientY:250,button:0});
const editedPosition=editable().style.transform;
await event(editable().querySelector('[title="双击编辑名称"]'),'dblclick');
const titleInput=document.querySelector('[aria-label="节点名称"]');
await act(async()=>{Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set.call(titleInput,'上传前改名');titleInput.dispatchEvent(new window.Event('input',{bubbles:true}));await tick();});
await event(titleInput,'keydown',{key:'Enter'});
assert.ok(editable().textContent.includes('上传前改名'),'Uploading images can be renamed');
await event(editable(),'click');await event(document.querySelector('[aria-label="八向拉伸模式"]'),'click');
const resize=document.querySelector('[data-resize-handle="bottom-right"]'),surface=editable().querySelector('[style*="rotate("]');
await event(resize,'pointerdown',{clientX:400,clientY:400,button:0});await event(surface,'pointermove',{clientX:460,clientY:440,buttons:1});await event(surface,'pointerup',{clientX:460,clientY:440});await event(window,'keydown',{key:'Escape'});
const editedWidth=editable().style.width,editedHeight=editable().style.height;
// 上传不锁住其他内容创建;确认后先保留本地预览,等远端 load 事件才释放 blob URL。
await event(document.querySelector('[aria-label="添加文本卡片"]'),'click');assert.equal(document.querySelectorAll('.react-flow__node').length,2,'Other content can be added during upload');
await act(async()=>{release();await tick();});await wait(()=>document.querySelector('.react-flow__node img[src$="?variant=preview"]'),'managed image rendered');
assert.ok(document.querySelector(`img[src="${localURL}"]`),'Local image remains visible while remote preview loads');
const remote=document.querySelector('.react-flow__node img[src$="?variant=preview"]');await act(async()=>{remote.dispatchEvent(new window.Event('load'));await tick();});
assert.ok(!document.querySelector(`img[src="${localURL}"]`));assert.ok(revoked.includes(localURL));await wait(()=>snapshot.document.nodes.some(n=>n.data.src===asset.url),'original reference saved');
// 确认资源引用只保存一次,并保留上传期间的标题、位置和尺寸修改。
const persisted=snapshot.document.nodes.find(n=>n.id===imageNodeID);assert.equal(persisted.data.title,'上传前改名');assert.equal(editable().style.transform,editedPosition);assert.equal(editable().style.width,editedWidth);assert.equal(editable().style.height,editedHeight);
assert.equal(snapshot.document.nodes.filter(n=>n.data.src===asset.url).length,1);assert.equal(snapshot.document.nodes.find(n=>n.data.src===asset.url).data.assetId,assetID);
console.log('PASS uploads: immediate local preview, unrelated editing, original-byte attachment and no blob URL persistence');
// 403 后预览和文件仍保留;手动重试沿用第一次幂等键,最终只落一个节点。
await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'visible upload failure');
assert.ok(document.querySelector('img[src^="blob:"]'));assert.equal(saves.length,0);const key=uploads[0].key;mode='delayed';
await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='重试'),'click');assert.equal(uploads.length,2);assert.equal(uploads[1].key,key,'Manual retry retains the original idempotency key');
await act(async()=>{release();await tick();});await wait(()=>snapshot.document.nodes.length===1,'retry saves exactly one image');assert.equal(snapshot.document.nodes[0].data.src,asset.url);
console.log('PASS upload retry: retained file/preview, stable idempotency key and exactly one persisted node');
// 取消相当于可撤销的本地删除;随后单独检查上传中删除、ACK 迟到以及撤销后的受管资源恢复。
await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'failed cancellation');
const cancelledURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='取消'),'click');assert.equal(document.querySelector('img[src^="blob:"]'),null);assert.equal(saves.length,0);await event(document.querySelector('[aria-label="撤销"]'),'click');assert.ok(document.querySelector(`img[src="${cancelledURL}"]`),'Undo restores a locally deleted upload');
await reset();await choose();const deletedID=document.querySelector('.react-flow__node').dataset.id;
await event(document.querySelector('.react-flow__node'),'click');await event(document,'keydown',{key:'Backspace',code:'Backspace',keyCode:8});await wait(()=>!document.querySelector('.react-flow__node'),'delete before acknowledgement');
await act(async()=>{release();await tick();});await act(async()=>{await new Promise(r=>setTimeout(r,900));});assert.equal(snapshot.document.nodes.length,0,'Late upload completion must not resurrect a deleted node');
await event(document.querySelector('[aria-label="撤销"]'),'click');await wait(()=>snapshot.document.nodes.length===1,'undo after upload completed');assert.equal(snapshot.document.nodes[0].id,deletedID);assert.equal(snapshot.document.nodes[0].data.src,asset.url);
console.log('PASS editable uploads: move/resize/rename persist through acknowledgement, late completion respects deletion and undo');
// 尺寸替身异步回调 640×960;立即/延迟上传两条路径都应形成 320×480,元数据确认不额外占撤销步骤。
window.Image=class {naturalWidth=640;naturalHeight=960;set src(_){setTimeout(()=>this.onload?.(),0);}};
await reset('immediate');await choose('添加纯图片');await wait(()=>snapshot.document.nodes.length===1,'fast upload dimensions');assert.equal(snapshot.document.nodes[0].width,320);assert.equal(snapshot.document.nodes[0].height,480);await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(document.querySelector('.react-flow__node'),null,'Intrinsic size and attachment acknowledgement do not add undo steps');
await reset();await choose('添加纯图片');assert.equal(document.querySelector('.react-flow__node').style.width,'320px');assert.equal(document.querySelector('.react-flow__node').style.height,'480px');
// 卸载后检查 signal 已取消与 URL 已释放;再放行旧响应,不能触发文档保存。
const unmountedURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await act(async()=>{root.render(null);await tick();});assert.ok(uploads[0].signal.aborted);assert.ok(revoked.includes(unmountedURL));release();await tick();assert.equal(saves.length,0);
console.log('PASS upload cleanup: undoable deletion, unmount revocation/abort and plain-image aspect ratio');
// 无论断言是否失败,都恢复全局替身并卸载此场景的组件。
} finally {window.Image=NativeImage;URL.revokeObjectURL=originalRevoke;await act(async()=>{root.render(null);await tick();});}
}