Fix canvas transfer timeouts and move images out of snapshots
This commit is contained in:
+10
-1
@@ -20,10 +20,16 @@ const upstream = createServer(async (req, res) => {
|
||||
if (req.url.includes("/canvas")) assert.equal(req.headers.accept, "application/vnd.foundry.canvas+json;version=2");
|
||||
if (req.url.startsWith("/projects/endoleap/workspaces/space/assets")) {
|
||||
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({error:"invalid_session"})); return; }
|
||||
if (req.method === "GET") { res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; }
|
||||
if (req.method === "GET") { res.setHeader("X-Asset-Variant", new URL(req.url,"http://mock").searchParams.get("variant") === "preview" ? "preview" : "original"); res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; }
|
||||
let bytes = Buffer.alloc(0); for await (const chunk of req) bytes = Buffer.concat([bytes, chunk]);
|
||||
res.end(JSON.stringify({type:req.headers["content-type"],bytes:bytes.length,key:req.headers["idempotency-key"],multipart:bytes.includes(Buffer.from('name="image"'))})); return;
|
||||
}
|
||||
if (canonical.endsWith("/materials/slow-body-board/canvas")) {
|
||||
res.setHeader("ETag", '\"210\"');
|
||||
res.write('{"version":210,"document":');
|
||||
await new Promise((resolve) => setTimeout(resolve, 15200));
|
||||
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
|
||||
}
|
||||
if (canonical.endsWith("/materials/stream-board/canvas")) {
|
||||
assert.equal(req.headers.authorization, "Bearer test-only-session");
|
||||
res.setHeader("ETag", '\"1\"'); res.setHeader("X-Canvas-Schema-Version", "2");
|
||||
@@ -190,11 +196,14 @@ try {
|
||||
const response = await fetch(v2Base + "/" + path, { method: "POST", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "Idempotency-Key": idem, "If-Match": '\"7\"' }, body: "{}" });
|
||||
assert.equal(response.status, 200, path); const echo = await response.json(); assert.equal(echo.key, idem); assert.equal(echo.version, '\"7\"'); assert.equal(echo.accept, "application/vnd.foundry.canvas+json;version=2");
|
||||
}
|
||||
const preview = await fetch(host + "/api/foundry/workspaces/space/assets/image?variant=preview", { headers: { Cookie: session } });assert.equal(preview.status,200);assert.equal(preview.headers.get("x-asset-variant"),"preview");await preview.arrayBuffer();
|
||||
const directoryResponse = await (await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&sort=modified&include_subgraphs=true&limit=100&offset=100", { headers: { Cookie: session } })).json();
|
||||
const directoryQuery = new URL(directoryResponse.url, "http://mock").searchParams;
|
||||
assert.equal(directoryQuery.get("include_subgraphs"), "true"); assert.equal(directoryQuery.get("offset"), "100");
|
||||
const events = await (await fetch(v2Base + "/ai/runs/run-1?after_seq=12&limit=100&status=active&include_descendants=true&cursor=cursor&secret=drop", { headers: { Cookie: session } })).json();
|
||||
const query = new URL(events.url, "http://mock").searchParams; assert.equal(query.get("after_seq"), "12"); assert.equal(query.get("include_descendants"), "true"); assert.equal(query.get("cursor"), "cursor"); assert.equal(query.get("secret"), null);
|
||||
const slowSnapshot = await fetch(host + "/api/foundry/workspaces/space/materials/slow-body-board/canvas", { headers: { Cookie: session } });
|
||||
assert.ok(slowSnapshot.headers.get("x-canvas-trace-id")); assert.equal((await slowSnapshot.json()).version, 210, "A valid body can finish after the old 15-second total deadline");
|
||||
const asset = await fetch(host + "/api/foundry/workspaces/space/assets/image-1", { headers: { Cookie: session } });
|
||||
assert.equal(asset.status, 200); assert.equal(asset.headers.get("content-type"), "image/png"); assert.equal(asset.headers.get("x-content-type-options"), "nosniff"); assert.ok(asset.headers.get("set-cookie")?.includes("HttpOnly")); assert.equal((await asset.arrayBuffer()).byteLength, 8);
|
||||
assert.equal((await fetch(host + "/api/foundry/workspaces/space/assets/image-1")).status, 401);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { prepareManagedImages, uploadCanvasImage, inlineImageCount, inlineImageBlob } from '../src/lib/canvas-assets.ts';
|
||||
import { emptyCanvas, makeCanvasNode } from '../src/lib/canvas.ts';
|
||||
import { managedImageSource } from '../src/lib/canvas-v2.ts';
|
||||
const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
|
||||
const id='77777777-7777-4777-8777-777777777777';
|
||||
const asset={id,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${id}`,width:1,height:1,bytes:24,mime_type:'image/png'};
|
||||
const fixture=()=>({...emptyCanvas(),schema_version:2,nodes:[makeCanvasNode('image',{x:15,y:20},source),makeCanvasNode('plain-image',{x:800,y:300},source)],edges:[]});
|
||||
test('explicit legacy image migration uploads original bytes once and preserves graph geometry and identity',async()=>{
|
||||
const document=fixture(),original=structuredClone(document);let calls=0;
|
||||
const result=await prepareManagedImages(document,async(path,options)=>{
|
||||
calls++;assert.equal(path,'workspaces/w/assets');assert.match(options.headers['Idempotency-Key'],/^[a-f0-9-]{36}$/);assert.equal(options.method,'POST');assert.ok(options.body instanceof FormData);
|
||||
assert.deepEqual(Buffer.from(await options.body.get('image').arrayBuffer()),Buffer.from(source.split(',')[1],'base64'));return asset;
|
||||
},'w');
|
||||
assert.equal(calls,1);assert.equal(inlineImageCount(result),0);assert.deepEqual(document,original);
|
||||
assert.equal(result.edges,document.edges);for(let i=0;i<2;i++){assert.equal(result.nodes[i].id,document.nodes[i].id);assert.deepEqual(result.nodes[i].position,document.nodes[i].position);assert.equal(result.nodes[i].width,document.nodes[i].width);assert.equal(result.nodes[i].data.src,asset.url);assert.equal(result.nodes[i].data.assetId,id);}
|
||||
assert.equal(inlineImageCount(document),2);
|
||||
});
|
||||
test('failed upload or invalid response leaves the original document untouched',async()=>{
|
||||
const document=fixture(),original=structuredClone(document);
|
||||
await assert.rejects(prepareManagedImages(document,async()=>{throw new Error('forbidden');},'w'),/forbidden/);assert.deepEqual(document,original);
|
||||
await assert.rejects(prepareManagedImages(document,async()=>({...asset,url:'https://invalid.example/a.png'}),'w'),/invalid_asset_response/);assert.deepEqual(document,original);
|
||||
});
|
||||
test('uncertain upload retries the exact bytes with the same idempotency key',async()=>{
|
||||
let calls=0,key;
|
||||
await uploadCanvasImage(async(_path,options)=>{calls++;if(!key)key=options.headers['Idempotency-Key'];assert.equal(options.headers['Idempotency-Key'],key);if(calls===1)throw new TypeError('connection lost');return asset;},'w',new Blob(['test'],{type:'image/png'}),'test.png');assert.equal(calls,2);
|
||||
});
|
||||
test('V1 uses managed URLs without unsupported V2 fields and clean documents cause no upload',async()=>{
|
||||
const document=fixture();document.schema_version=1;let calls=0;
|
||||
const result=await prepareManagedImages(document,async()=>{calls++;return asset;},'w');assert.equal(result.schema_version,1);assert.equal(result.nodes[0].data.assetId,undefined);
|
||||
await prepareManagedImages(result,async()=>{throw new Error('must not upload');},'w');assert.equal(calls,1);
|
||||
});
|
||||
test('preview URLs are renderer-only and do not replace original managed references',()=>{
|
||||
assert.equal(managedImageSource(asset.url),`/api/foundry/workspaces/w/assets/${id}`);assert.equal(managedImageSource(asset.url,true),`/api/foundry/workspaces/w/assets/${id}?variant=preview`);
|
||||
assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png');assert.throws(()=>inlineImageBlob('data:image/svg+xml;base64,PHN2Zz4='),/invalid_asset/);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fetchCanvasResponse } from '../src/lib/canvas-response.ts';
|
||||
import { api, ApiError } from '../src/lib/api.ts';
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
test('snapshot body outlives the header deadline and reports transfer timing and bytes',async()=>{
|
||||
const reports=[]; const bytes=new TextEncoder().encode('{"version":210,"document":{"title":"测试"}}');
|
||||
const upstream=new Response(new ReadableStream({async start(c){ c.enqueue(bytes.slice(0,10));await wait(50);c.enqueue(bytes.slice(10));c.close();}}),{headers:{ETag:'"210"'}});
|
||||
const response=await fetchCanvasResponse('https://example.test',{}, {headersMs:10,bodyMs:500,idleMs:300},async()=>upstream, report=>reports.push(report));
|
||||
assert.equal(response.headers.get('etag'),'"210"');assert.equal((await response.json()).version,210);
|
||||
assert.equal(reports.length,1);assert.equal(reports[0].outcome,'complete');assert.equal(reports[0].bytes,bytes.length);assert.ok(reports[0].transfer_ms>=30);assert.equal(reports[0].chunks,2);
|
||||
});
|
||||
test('snapshot idle timeout aborts unfinished bodies instead of returning a valid empty snapshot',async()=>{
|
||||
const reports=[];let cancelled=false;
|
||||
const upstream=new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));},cancel(){cancelled=true;}}));
|
||||
const response=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:15},async()=>upstream,r=>reports.push(r));
|
||||
await assert.rejects(response.json(),{name:'TimeoutError'});assert.ok(cancelled);assert.equal(reports[0].outcome,'error');assert.equal(reports[0].error,'TimeoutError');
|
||||
});
|
||||
test('header deadline rejects a server that has not responded',async()=>{
|
||||
await assert.rejects(fetchCanvasResponse('https://example.test',{}, {headersMs:10,bodyMs:500,idleMs:100},(_url,options)=>new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>reject(options.signal.reason)))),{name:'TimeoutError'});
|
||||
});
|
||||
test('disconnect and consumer cancellation release the upstream read',async()=>{
|
||||
const abort=new AbortController();let cancelled=0;
|
||||
const make=()=>new Response(new ReadableStream({cancel(){cancelled++;}}));
|
||||
const response=await fetchCanvasResponse('https://example.test',{signal:abort.signal},{headersMs:100,bodyMs:500,idleMs:100},async()=>make());
|
||||
abort.abort();await assert.rejects(response.text(),{name:'AbortError'});
|
||||
const second=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:100},async()=>make());await second.body.cancel();assert.equal(cancelled,2);
|
||||
});
|
||||
test('client distinguishes a failed 200 body transfer, malformed JSON, server errors and 204',async()=>{
|
||||
const original=globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch=async()=>new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));c.error(new TypeError('terminated'));}}));
|
||||
await assert.rejects(api('canvas'),error=>error instanceof ApiError && error.message==='response_interrupted');
|
||||
globalThis.fetch=async()=>new Response('{"version":');await assert.rejects(api('canvas'),/invalid_api_response/);
|
||||
globalThis.fetch=async()=>Response.json({error:'upstream_timeout'},{status:504});await assert.rejects(api('canvas'),/upstream_timeout/);
|
||||
globalThis.fetch=async()=>new Response(null,{status:204});assert.equal(await api('canvas'),null);
|
||||
} finally {globalThis.fetch=original;}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts";
|
||||
import { emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
||||
import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts";
|
||||
import { loadBoardCanvas } from "../src/lib/canvas-loader.ts";
|
||||
function document(text = "") { const doc = emptyCanvas(); const node = makeCanvasNode("text", { x: 0, y: 0 }); node.id = "text"; node.data.text = text; doc.nodes.push(node); return doc; }
|
||||
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
|
||||
@@ -90,3 +90,15 @@ test("board metadata and snapshot begin concurrently without a request waterfall
|
||||
const loading = loadBoardCanvas((path) => { started.push(path); return new Promise((resolve) => releases.push(() => resolve(path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "board", workspace_id: "space", kind: "board" }))); }, "space", "board");
|
||||
assert.equal(started.length, 2); releases.forEach((release) => release()); await loading;
|
||||
});
|
||||
|
||||
|
||||
test("viewport restoration roundoff does not create an autosave", async () => {
|
||||
const doc=document("keep");doc.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
|
||||
const state={past:[],present:doc,future:[]};
|
||||
const next=canvasReducer(state,{type:"viewport",viewport:{x:doc.viewport.x+1e-10,y:doc.viewport.y,zoom:doc.viewport.zoom+1e-12}});
|
||||
assert.equal(next,state);let writes=0;const writer=new CanvasSync(async()=>{writes++;return {version:2};},"canvas",{version:1,document:doc},()=>{});
|
||||
await writer.enqueue(next.present);assert.equal(writes,0);
|
||||
const edited=canvasReducer(state,{type:"viewport",viewport:{...doc.viewport,x:doc.viewport.x+10}});await writer.enqueue(edited.present);assert.equal(writes,0);
|
||||
edited.present.nodes[0]={...edited.present.nodes[0],data:{...edited.present.nodes[0].data,text:"changed"}};
|
||||
await writer.enqueue(edited.present);assert.equal(writes,1);
|
||||
});
|
||||
|
||||
@@ -162,6 +162,7 @@ try {
|
||||
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "隐藏名称");
|
||||
await event(document.querySelector('[aria-label="重做"]'), "click");
|
||||
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "随画布缩放");
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 180)); });
|
||||
await event(document.querySelector('[aria-label="八向拉伸模式"]'), "click");
|
||||
assert.equal(document.querySelectorAll('[data-resize-handle]').length, 8);
|
||||
await event(window, "keydown", { key: "Escape" });
|
||||
|
||||
@@ -5,13 +5,19 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
|
||||
const inside=makeCanvasNode('text',{x:10,y:20});inside.id='child-text';inside.data.title='子节点';inside.data.text='原始输入';
|
||||
const target=makeCanvasNode('text',{x:950,y:100});target.id='outside';target.data.ai={enabled:true,modelId:'test-text',prompt:'继续整理',parameters:{}};
|
||||
const image=makeCanvasNode('image',{x:1100,y:450},'https://example.com/reference.png');image.id='image-target';image.data.title='图片目标';
|
||||
const records=new Map([[parent,{version:10,document:{...emptyCanvas(),schema_version:2,nodes:[frame,target,image],edges:[{id:'boundary',source:frame.id,sourceHandle:'port:out',target:target.id,targetHandle:'left',data:{animation:{mode:'executing',effect:'flow'}}}]}}],[child,{version:3,document:{...emptyCanvas(),schema_version:2,nodes:[inside]}}]]);
|
||||
const legacy=makeCanvasNode('plain-image',{x:1600,y:100},'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB');legacy.id='legacy-image';
|
||||
const records=new Map([[parent,{version:10,document:{...emptyCanvas(),schema_version:2,nodes:[frame,target,image,legacy],edges:[{id:'boundary',source:frame.id,sourceHandle:'port:out',target:target.id,targetHandle:'left',data:{animation:{mode:'executing',effect:'flow'}}}]}}],[child,{version:3,document:{...emptyCanvas(),schema_version:2,nodes:[inside]}}]]);
|
||||
let connectionReceipt=null,connectionKey=null,connectionBody=null,connectionRequests=0;
|
||||
const receipts=new Map();let createCount=0;
|
||||
let assetUploads=0;
|
||||
const saves=[];let job=null,poll=0,seq=0,imageRuns=0;const events=[];
|
||||
const json=(data,status=200)=>new Response(JSON.stringify(data),{status,headers:{'content-type':'application/json'}});
|
||||
const emit=(type,data)=>events.push({seq:++seq,type,...data});
|
||||
mock(async(url,options={})=>{
|
||||
if(new URL(url,'http://localhost').pathname.endsWith('/assets') && options.method==='POST') {
|
||||
assetUploads++;assert.ok(options.headers['Idempotency-Key']);assert.ok(options.body.get('image').size>0);
|
||||
return json({id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777',width:1,height:1,bytes:24,mime_type:'image/png'},201);
|
||||
}
|
||||
assert.match(options.headers?.Accept||'',/version=2/);
|
||||
const parsed=new URL(url,'http://localhost'), parts=parsed.pathname.split('/');const board=parts[6], suffix=parts.slice(8).join('/'), method=options.method||'GET';
|
||||
const snapshot=records.get(board);assert.ok(snapshot,`Unknown canvas ${board} ${url}`);
|
||||
@@ -74,9 +80,22 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
|
||||
}
|
||||
throw new Error(`Unhandled V2 API ${method} ${url}`);
|
||||
});
|
||||
const readOnly=structuredClone(records.get(parent));readOnly.document.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
|
||||
await act(async()=>{root.render(h(GraphCanvas,{key:'read-only',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 read only',version:1},snapshot:readOnly}));await tick();});
|
||||
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
|
||||
assert.equal(saves.length,0,'Non-default viewport restoration, measurement and child loading must not PATCH');
|
||||
await event(document.querySelector('[aria-label="缩小"]'),'click');
|
||||
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
|
||||
assert.equal(saves.length,0,'Changing the view without content edits must not PATCH');
|
||||
const localView=JSON.parse(localStorage.getItem('endoleap_canvas_v1:w:'+parent+':viewport'));assert.ok(localView&&Number.isFinite(localView.zoom));
|
||||
localStorage.removeItem('endoleap_canvas_v1:w:'+parent+':viewport');
|
||||
console.log('PASS read-only: root/child loading, viewport restoration and zoom cause zero PATCH requests');
|
||||
await act(async()=>{root.render(null);await tick();});
|
||||
await act(async()=>{root.render(h(GraphCanvas,{key:'v2',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 test',version:1},snapshot:structuredClone(records.get(parent))}));await tick();});
|
||||
await act(async()=>{await tick();});
|
||||
const node=()=>document.querySelector(`.react-flow__node[data-id="${child}::child-text"]`);
|
||||
await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));});
|
||||
assert.equal(saves.length,0,'Opening a root and its child without edits must not PATCH');
|
||||
assert.ok(node(),'Child is expanded from its own snapshot');assert.equal(document.querySelectorAll(`.react-flow__node[data-id="${child}::child-text"]`).length,1);
|
||||
await event(node(),'click');await event(document.querySelector('[aria-label="AI 模式"]'),'click');await act(async()=>{await tick();});
|
||||
assert.ok(document.querySelector('[aria-label="元素 AI 工具"]'));assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
|
||||
@@ -123,7 +142,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
|
||||
assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试图片模型'),'Sticky AI mode selects models for the newly selected image');
|
||||
assert.ok(!document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
|
||||
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
|
||||
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="image-target"] img')?.getAttribute('src')==='/api/foundry/workspaces/w/assets/generated-image','managed AI image result');
|
||||
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="image-target"] img')?.getAttribute('src')==='/api/foundry/workspaces/w/assets/generated-image?variant=preview','managed AI image result');
|
||||
const generated=records.get(parent).document.nodes.find(node=>node.id==='image-target');assert.equal(generated.width,image.width);assert.equal(generated.height,image.height);assert.equal(generated.data.title,'图片目标');
|
||||
await waitUntil(()=>document.querySelector('[aria-label="执行 AI"]'),'image run terminal UI');
|
||||
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
|
||||
@@ -131,5 +150,13 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
|
||||
await waitUntil(()=>document.querySelector('[aria-label="元素 AI 工具"]')?.textContent.includes('已取消生成'),'cancelled image run');
|
||||
assert.equal(records.get(parent).document.nodes.find(node=>node.id==='image-target').data.src,generated.data.src);
|
||||
console.log('PASS AI images: sticky mode, modality filtering, original-node asset output and cancellation');
|
||||
const beforeMigration=structuredClone(records.get(parent).document);
|
||||
await event([...document.querySelectorAll('button')].find(button=>button.textContent==='优化图片加载'),'click');
|
||||
await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='legacy-image')?.data.src.startsWith('https://api.foundry.entroia.com/'),'explicit image migration');
|
||||
assert.equal(assetUploads,1);const migrated=records.get(parent).document.nodes.find(node=>node.id==='legacy-image');assert.deepEqual(migrated.position,beforeMigration.nodes.find(node=>node.id==='legacy-image').position);
|
||||
assert.deepEqual(records.get(parent).document.edges,beforeMigration.edges);
|
||||
assert.equal([...document.querySelectorAll('button')].some(button=>button.textContent==='优化图片加载'),false);
|
||||
assert.ok(document.querySelector('img[src$="?variant=preview"]'),'Canvas renders a preview URL while the snapshot preserves the original');
|
||||
console.log('PASS images: explicit original-preserving migration, one upload, graph preservation and preview-only render URL');
|
||||
console.log('PASS V2: child ownership and saves, live model catalog, AI propagation/polling, geometry preservation, collapse/expand and transactional child creation');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user