import assert from 'node:assert/strict'; export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyCanvas,event,tick,mock}) { 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; const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}); const revoked=[],originalRevoke=URL.revokeObjectURL;URL.revokeObjectURL=(url)=>{revoked.push(url);originalRevoke(url);}; const NativeImage=window.Image; 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);}; 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; 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); 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();}); } 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 { 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; const editable=()=>document.querySelector(`.react-flow__node[data-id="${imageNodeID}"]`); assert.ok(editable().classList.contains('draggable'),'Uploading images are real draggable nodes'); 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; 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'); 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'); 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'); 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'); 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();});} }