Integrate Canvas V2 subgraphs, editable edges and AI runs

This commit is contained in:
2026-11-06 19:59:56 +08:00
parent 3478daeccb
commit 05d96abd0e
35 changed files with 1626 additions and 443 deletions
+27 -2
View File
@@ -16,9 +16,19 @@ const upstream = createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.headers.authorization === "Bearer test-only-session" && !req.url.endsWith("/auth/logout")) res.setHeader("X-Foundry-Session-Expires-In", mockSessionSeconds);
const canonical = req.url.replace("/workspaces/creative-space/", "/workspaces/space/").replace("/materials/my-board", "/materials/foundry-generated-id");
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; }
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.includes("/canvas/")) {
res.end(JSON.stringify({url:req.url,method:req.method,accept:req.headers.accept,key:req.headers["idempotency-key"],version:req.headers["if-match"]})); return;
}
if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id/canvas") {
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("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; }
if (req.method === "GET") { res.setHeader("X-Canvas-Schema-Version", String(canvasSnapshot.document.schema_version)); res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; }
let raw = ""; for await (const chunk of req) raw += chunk;
if (req.headers["if-match"] !== '"' + canvasSnapshot.version + '"') { res.statusCode = 412; res.end(JSON.stringify({ error: "version_conflict" })); return; }
const { document } = JSON.parse(raw);
@@ -153,6 +163,21 @@ try {
assert(cookie.includes("SameSite=Lax"));
assert(cookie.includes("Max-Age=2592000"));
const session = cookie.split(";")[0];
const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas";
const idem = "11111111-1111-4111-8111-111111111111";
for (const path of ["connections", "subgraphs", "subgraphs/migrate", "subgraphs/frame-1/members", "subgraphs/frame-1/dissolve", "transactions/tx-1/undo", "ai/runs", "ai/runs/run-1/cancel", "ai/runs/run-1/apply"]) {
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 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 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);
assert.equal((await fetch(host + "/api/foundry/workspaces/space/assets/bad", { headers: { Cookie: session } })).status, 502);
const form = new FormData(); form.set("image", new Blob([new Uint8Array([137,80,78,71])], { type: "image/png" }), "test.png");
const upload = await (await fetch(host + "/api/foundry/workspaces/space/assets", { method: "POST", headers: { Cookie: session, Origin: host, "Idempotency-Key": idem }, body: form })).json();
assert.match(upload.type, /^multipart\/form-data; boundary=/); assert.equal(upload.key, idem); assert.ok(upload.multipart);
const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" });
assert.equal(signedInHome.status, 307);
assert.equal(signedInHome.headers.get("location"), "/dashboard");
@@ -311,7 +336,7 @@ try {
assert.equal(await logout.text(), "");
assert.match(logout.headers.get("set-cookie"), /Max-Age=0/);
console.log(
"PASS: Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, 97 hashed icon shards with immutable caching, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
"PASS: V2 commands, idempotency and query forwarding, authenticated binary assets and multipart uploads, Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, 97 hashed icon shards with immutable caching, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
);
} finally {
const exited = once(app, "exit");
+39 -26
View File
@@ -38,8 +38,10 @@ globalThis.ResizeObserver = window.ResizeObserver = class {
observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); }
unobserve() {} disconnect() {}
};
let apiMock = null;
const requests = [];
globalThis.fetch = async (url, options) => {
if (apiMock && String(url).startsWith("/api/foundry/")) return apiMock(url, options);
requests.push(String(url));
assert.ok(String(url).startsWith("/canvas-icons/"), `Unexpected network request ${url}`);
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
@@ -64,7 +66,7 @@ try {
const event = async (target, type, init = {}) => { assert.ok(target, `Missing target for ${type}`); await act(async () => { const Type = type.startsWith("pointer") ? PointerEvent : type.startsWith("key") ? window.KeyboardEvent : window.MouseEvent; target.dispatchEvent(new Type(type, { bubbles: true, cancelable: true, view: window, ...init })); await tick(); }); };
const node = makeCanvasNode("text", { x: 100, y: 100 }); node.id = "text-one";
const frameNode = makeCanvasNode("frame", { x: 500, y: 100 }); frameNode.id = "frame-one"; frameNode.data.title = "分区 A";
localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode] }));
localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode, { ...makeCanvasNode("text", { x: 900, y: 100 }), id: "text-two" }], edges: [{ id: "edge-test", source: "text-one", target: "text-two", sourceHandle: "right", targetHandle: "left" }] }));
await act(async () => { root.render(h(GraphCanvas, { board: { id: "b", workspace_id: "w", kind: "board", title: "test", version: 1 } })); await tick(); });
await event(document.querySelector('[data-id="text-one"]'), "click");
const toggle = () => document.querySelector('[aria-label="旋转模式"]');
@@ -96,7 +98,7 @@ try {
await event(window, "mouseup", { clientX: 700, clientY: 200, button: 0 });
assert.ok(document.querySelector('[title="属于:分区 A"]'));
assert.ok(document.querySelector('[role="status"]').textContent.includes("已加入「分区 A」"));
assert.ok(document.querySelector('.react-flow__node[data-id="frame-one"]').textContent.includes("1 项"));
assert.ok(!document.querySelector('.react-flow__node[data-id="frame-one"]').textContent.includes("1 项"));
await event(textNode, "mousedown", { clientX: 700, clientY: 200, button: 0, buttons: 1 });
await event(window, "mousemove", { clientX: 690, clientY: 200, buttons: 1 });
await event(window, "mousemove", { clientX: 200, clientY: 200, buttons: 1 });
@@ -108,34 +110,39 @@ try {
const frameElement = document.querySelector('.react-flow__node[data-id="frame-one"]');
await event(frameElement, "click");
const modeButton = () => document.querySelector('[aria-label="分区名称显示模式"]');
assert.equal(modeButton().tagName, "BUTTON", "Frame options must use a custom control");
await event(modeButton(), "click");
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
assert.equal(document.querySelector('[role="menuitemradio"][aria-checked="true"] strong').textContent, "固定字号");
await event(document.querySelector('[role="menuitemradio"]'), "click");
assert.equal(modeButton().textContent, "隐藏名称");
const group = document.querySelector('[role="radiogroup"][aria-label="分区名称显示模式"]');
assert.ok(group); assert.equal(group.querySelectorAll('[role="radio"]').length, 3);
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "固定字号");
await event(group.querySelector('[aria-label="隐藏名称"]'), "click");
assert.equal(frameElement.querySelector('[title="分区 A"]'), null);
assert.equal(document.querySelector('[role="menu"]'), null);
assert.equal(document.activeElement, modeButton());
await event(modeButton(), "keydown", { key: "ArrowDown" });
await event(document.activeElement, "keydown", { key: "End" });
assert.equal(document.activeElement.querySelector("strong").textContent, "随画布缩放");
await event(document.activeElement, "click");
assert.equal(modeButton().textContent, "随画布缩放");
await event(group.querySelector('[aria-label="隐藏名称"]'), "keydown", { key: "End" });
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "随画布缩放");
assert.ok(frameElement.querySelector('[title="分区 A"]'));
await event(document.querySelector('[aria-label="撤销"]'), "click");
assert.equal(modeButton().textContent, "隐藏名称");
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "隐藏名称");
await event(document.querySelector('[aria-label="重做"]'), "click");
assert.equal(modeButton().textContent, "随画布缩放");
await event(modeButton(), "click");
await event(document.activeElement, "keydown", { key: "Escape" });
assert.equal(document.querySelector('[role="menu"]'), null);
assert.equal(document.activeElement, modeButton());
await event(modeButton(), "click");
await event(document.body, "pointerdown");
assert.equal(document.querySelector('[role="menu"]'), null);
console.log("PASS frame menu: custom options, selection, keyboard focus, Escape, outside dismissal and undo/redo");
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "随画布缩放");
await event(document.querySelector('[aria-label="八向拉伸模式"]'), "click");
assert.equal(document.querySelectorAll('[data-resize-handle]').length, 8);
await event(window, "keydown", { key: "Escape" });
assert.equal(document.querySelectorAll('[data-resize-handle]').length, 0);
console.log("PASS frames: icon segments, no member count, keyboard choice, undo/redo and eight resize handles");
await event(document.querySelector('.react-flow__edge[data-id="edge-test"]'), "click");
assert.ok(document.querySelector('[aria-label="连线编辑"]'));
await event(document.querySelector('[aria-label="添加曲线点"]'), "click");
assert.ok(document.querySelector('[aria-label="曲线点 1"]'));
const arrow = document.querySelector('[aria-label="箭头类型"]');
await act(async () => { arrow.value = "triangle"; arrow.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); });
assert.ok(document.querySelector('.react-flow__edge[data-id="edge-test"] .react-flow__edge-path').getAttribute("marker-end"));
const animation = document.querySelector('[aria-label="动画播放时机"]');
await act(async () => { animation.value = "always"; animation.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); });
assert.ok(document.querySelector("animateMotion"));
await act(async () => { animation.value = "executing"; animation.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); });
assert.equal(document.querySelector("animateMotion"), null, "Execution-only animation remains stopped without a running edge");
await event(document.querySelector('[aria-label="删除选中曲线点"]'), "click");
assert.equal(document.querySelector('[aria-label="曲线点 1"]'), null);
console.log("PASS edges: select, add/remove curve knot, markers and always/execution-only animation");
let choice;
await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); });
@@ -160,6 +167,12 @@ try {
assert.equal(grid.querySelector("button").dataset.index, "0", "Category change resets virtual window");
console.log("PASS picker: bounded DOM, on-demand data, shared Tabler load and selection");
}
if (process.env.ROTATION_BASELINE !== "1") {
let scenario;
await act(async () => { scenario = await import("./canvas-v2-scenario.mjs"); await tick(); });
const { canvasV2Scenario } = scenario;
await canvasV2Scenario({ root, act, h, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
}
await act(async () => { root.unmount(); await tick(); });
} finally {
await rm(folder, { recursive: true, force: true }); window.close(); stop();
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvasNode,emptyCanvas,event,tick,mock}) {
const parent='22222222-2222-4222-8222-222222222222', child='11111111-1111-4111-8111-111111111111';
const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='linked-frame';frame.data={...frame.data,title:'真实子图',subgraph:{boardId:child},ports:[{id:'out',nodeId:'child-text',handle:'right',direction:'output'}],presentation:{collapsed:false,contentScale:1,expandedSize:{width:640,height:400},collapsedSize:{width:240,height:140}}};
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]}}]]);
let connectionReceipt=null,connectionKey=null,connectionBody=null,connectionRequests=0;
const receipts=new Map();let createCount=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={})=>{
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}`);
if(!suffix&&method==='GET')return json(structuredClone(snapshot));
if(!suffix&&method==='PATCH'){
if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412);
const payload=JSON.parse(options.body).document;assert.ok(payload.nodes.every(node=>!node.id.includes('::')),'No projected IDs may be persisted');
if(board===parent)assert.ok(!payload.nodes.some(node=>node.id==='child-text'),'Child content cannot be duplicated into parent');
saves.push({board,document:payload});snapshot.document=structuredClone(payload);snapshot.version++;return json({version:snapshot.version});
}
if(suffix==='capabilities')return json({supported_schema_versions:[1,2],subgraphs:{connections:true}});
if(suffix==='ai/models')return json({models:[{id:'test-text',label:'测试文本模型',available:true,output_modalities:['text'],input_modalities:['text'],parameters_schema:{type:'object',properties:{},additionalProperties:false}},{id:'test-image',label:'测试图片模型',available:true,output_modalities:['image'],input_modalities:['text','image'],parameters_schema:{type:'object',properties:{},additionalProperties:false}}]});
if(suffix==='ai/runs'&&method==='GET')return json({runs:job&&['queued','running'].includes(job.status)?[{run_id:'test-run',board_id:child,status:job.status}]:[],next_cursor:null});
if(suffix==='ai/runs'&&method==='POST'){
const input=JSON.parse(options.body);const isImage=input.node_id==='image-target';assert.equal(board,isImage?parent:child);assert.equal(input.node_id,isImage?'image-target':'child-text');assert.equal(input.model_id,isImage?'test-image':'test-text');assert.equal(input.propagate,!isImage);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);assert.ok(options.headers['Idempotency-Key']);
snapshot.document.nodes.find(node=>node.id===input.node_id).data.ai={enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:{},propagate:true};snapshot.version++;
poll=0;seq=0;events.length=0;job={status:'queued',id:isImage?'image-run-'+(++imageRuns):'test-run'};return json({run_id:job.id,status:'queued',canvas_version:snapshot.version,plan:{steps:[],edges:[]}},202);
}
if(suffix.startsWith('ai/runs/image-run-')&&suffix.endsWith('/cancel')&&method==='POST'){
job.status='cancelled';emit('step.cancelled',{board_id:parent,node_id:'image-target'});return json({run_id:job.id,status:'cancelled'});
}
if(suffix.startsWith('ai/runs/image-run-')&&method==='GET'){
poll++;if(job.status!=='cancelled')job.status=poll<2?'running':'succeeded';
if(poll===2&&job.status==='succeeded'){
const target=records.get(parent).document.nodes.find(node=>node.id==='image-target');target.data.src='https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/generated-image';target.data.assetId='44444444-4444-4444-8444-444444444444';records.get(parent).version++;
emit('step.output',{board_id:parent,node_id:'image-target',output:{output_id:'image-output',src:target.data.src}});emit('step.applied',{board_id:parent,node_id:'image-target',canvas_version:records.get(parent).version,patch:{src:target.data.src,assetId:target.data.assetId}});
}
const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:job.id,status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'image-step',board_id:parent,node_id:'image-target',status:job.status}],outputs:[],applied_versions:{}});
}
if(suffix==='ai/runs/test-run'&&method==='GET'){
poll++;
if(poll===1){job.status='running';emit('step.running',{board_id:child,node_id:'child-text'});}
if(poll===2){records.get(child).document.nodes[0].data.text='生成后的子图内容';records.get(child).version++;emit('step.applied',{board_id:child,node_id:'child-text',canvas_version:records.get(child).version,patch:{text:'生成后的子图内容'}});emit('edge.active',{logical_edge_id:parent+'/boundary'});}
if(poll>=3&&job.status!=='succeeded'){job.status='succeeded';records.get(parent).document.nodes.find(node=>node.id==='outside').data.text='向下传播结果';records.get(parent).version++;emit('step.applied',{board_id:parent,node_id:'outside',canvas_version:records.get(parent).version,patch:{text:'向下传播结果'}});emit('edge.completed',{logical_edge_id:parent+'/boundary'});}
const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:'test-run',status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'one',board_id:child,node_id:'child-text',status:poll>=2?'succeeded':'running'},{step_id:'two',board_id:parent,node_id:'outside',status:poll>=3?'succeeded':poll>=2?'running':'queued'}],outputs:[],applied_versions:{}});
}
if(suffix==='connections'&&method==='POST'){
connectionRequests++;
if(connectionReceipt){assert.equal(options.headers['Idempotency-Key'],connectionKey);assert.equal(options.body,connectionBody);return json(connectionReceipt,201);}
connectionKey=options.headers['Idempotency-Key'];connectionBody=options.body;
const input=JSON.parse(options.body);assert.equal(board,parent);assert.equal(input.source.board_id,child);assert.equal(input.source.node_id,'child-text');assert.equal(input.source.handle,'bottom');assert.equal(input.target.board_id,parent);assert.deepEqual(Object.keys(input.expected_versions).sort(),[parent,child].sort());
for(const id of [parent,child])assert.equal(input.expected_versions[id],records.get(id).version);
const linked=records.get(parent).document.nodes.find(node=>node.id==='linked-frame');linked.data.ports.push({id:'generated-port',direction:'output',nodeId:'child-text',handle:'bottom'});
records.get(parent).document.edges.push({id:input.edge.id,source:'linked-frame',sourceHandle:'port:generated-port',target:'outside',targetHandle:'left'});
records.get(parent).version++;records.get(child).version++;
connectionReceipt={transaction_id:'tx-connect',parent:{board_id:parent,...structuredClone(records.get(parent))},snapshots:[{board_id:parent,...structuredClone(records.get(parent))},{board_id:child,...structuredClone(records.get(child))}],undo_receipt:{expires_at:new Date(Date.now()+86400000).toISOString()}};
return json({error:'upstream_unavailable'},502); // Simulate a lost response after the atomic commit.
}
if(suffix.startsWith('transactions/tx-create-')&&suffix.endsWith('/undo')&&method==='POST'){
const id=suffix.split('/')[1],receipt=receipts.get(id),input=JSON.parse(options.body);assert.ok(receipt);assert.deepEqual(Object.keys(input.expected_versions).sort(),[parent,receipt.child].sort());assert.equal(input.expected_versions[parent],records.get(parent).version);assert.equal(input.expected_versions[receipt.child],records.get(receipt.child).version);
const next={...receipt.before,version:records.get(parent).version+1};records.set(parent,next);records.get(receipt.child).version++;
return json({transaction_id:'undo-'+id,parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],archived_board_ids:[receipt.child],applied_versions:{[parent]:next.version,[receipt.child]:records.get(receipt.child).version}});
}
if(suffix==='subgraphs'&&method==='POST'){
const input=JSON.parse(options.body).frame;const newChild=crypto.randomUUID();const tx='tx-create-'+(++createCount);const beforeCreate=structuredClone(snapshot);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);
const node=makeCanvasNode('frame',input.position);node.id=input.id;node.data.title=input.title;node.data.subgraph={boardId:newChild};snapshot.document.nodes.push(node);snapshot.version++;
const empty={board_id:newChild,version:1,document:{...emptyCanvas(),schema_version:2}};records.set(newChild,empty);
receipts.set(tx,{before:beforeCreate,child:newChild});
return json({transaction_id:tx,parent:{board_id:parent,...structuredClone(snapshot)},child:empty,child_board:{id:newChild,workspace_id:'w'},undo_receipt:{expires_at:new Date(Date.now()+86400000).toISOString()}},201);
}
throw new Error(`Unhandled V2 API ${method} ${url}`);
});
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"]`);
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('测试文本模型'));
await event(document.querySelector('[aria-label="继续向下传播"]'),'click');
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
assert.ok(job);assert.equal(document.querySelector('[aria-label="AI 模式"]').getAttribute('aria-pressed'),'true');
// Move a child while the run executes. Its original owner receives the save.
await event(node(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:240,clientY:215,buttons:1});await event(window,'mouseup',{clientX:240,clientY:215,button:0});
const waitUntil=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,80));});}throw new Error('Timed out: '+label);};
await waitUntil(()=>node()?.querySelector('textarea')?.value==='生成后的子图内容','child AI result');
assert.ok(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),'Actual edge.active event starts execution-only animation');
assert.ok(records.get(child).document.nodes[0].position.x>10,'AI writeback preserves the concurrent child drag');
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="outside"] textarea')?.value==='向下传播结果','downstream AI result');
assert.equal(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),null,'Completed run stops execution-only animation');
assert.ok(saves.some(save=>save.board===child),'Child edits save to child canvas');
const childPosition=structuredClone(records.get(child).document.nodes[0].position);
await event(document.querySelector('.react-flow__node[data-id="linked-frame"]'),'click');
await event(document.querySelector('[aria-label="收起子画布"]'),'click');assert.equal(node(),null);assert.ok(document.querySelector('[aria-label="展开子画布"]'));
await event(document.querySelector('[aria-label="展开子画布"]'),'click');assert.ok(node());assert.deepEqual(records.get(child).document.nodes[0].position,childPosition);
const before=records.get(parent).document.nodes.length;await event(document.querySelector('[aria-label="添加框选区"]'),'click');
assert.equal(records.get(parent).document.nodes.length,before+1);assert.equal(records.size,3);
await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(records.get(parent).document.nodes.length,before);
await event(document.querySelector('[aria-label="重做"]'),'click');assert.equal(records.get(parent).document.nodes.length,before+1);
await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(records.get(parent).document.nodes.length,before,'Undo after recreate uses the new child participant');
await event(document.querySelector('[aria-label="重做"]'),'click');assert.equal(records.get(parent).document.nodes.length,before+1);
console.log('PASS transactions: create undo/redo and fresh child version participants');
await event(node(),'click');
const fromHandle=node().querySelector('[data-handleid="bottom"]');
const toHandle=document.querySelector('.react-flow__node[data-id="outside"] [data-handleid="left"]');
const originalHitTest=document.elementFromPoint;document.elementFromPoint=()=>toHandle;
await event(fromHandle,'mousedown',{clientX:250,clientY:250,button:0,buttons:1});
await event(document,'mousemove',{clientX:500,clientY:250,buttons:1});
await event(document,'mouseup',{clientX:500,clientY:250,button:0});
document.elementFromPoint=originalHitTest;
await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='linked-frame').data.ports.some(port=>port.id==='generated-port'),'cross-canvas connection transaction');
await waitUntil(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='重试同步'),'uncertain transaction recovery');
await event([...document.querySelectorAll('button')].find(button=>button.textContent==='重试同步'),'click');
assert.equal(connectionRequests,2);assert.equal(document.querySelector('.react-flow__edge[data-id="'+connectionReceipt.parent.document.edges.at(-1).id+'"]')!==null,true);
assert.equal(records.get(parent).document.edges.length,2);
console.log('PASS connections: real handle gesture, capability gate, LCA transaction and exact participant versions');
await event(document.querySelector('.react-flow__node[data-id="image-target"]'),'click');
await act(async()=>{await tick();});
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');
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');
await event(document.querySelector('[aria-label="取消生成"]'),'click');
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');
console.log('PASS V2: child ownership and saves, live model catalog, AI propagation/polling, geometry preservation, collapse/expand and transactional child creation');
}
+75
View File
@@ -0,0 +1,75 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { decodeCanvas, emptyCanvas, makeCanvasNode } from '../src/lib/canvas.ts';
import { curveSegments, cubicPoint, insertCurveKnot } from '../src/lib/canvas-curves.ts';
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData } from '../src/lib/canvas-tree.ts';
import { resizeFromPointer } from '../src/lib/canvas-geometry.ts';
import { managedImageSource } from '../src/lib/canvas-v2.ts';
const childId = '11111111-1111-4111-8111-111111111111';
function fixture() {
const frame = makeCanvasNode('frame', {x:100,y:100}); frame.id='frame'; frame.width=5000;
frame.data={...frame.data,subgraph:{boardId:childId},presentation:{collapsed:false,contentScale:.2,expandedSize:{width:5000,height:400},collapsedSize:{width:240,height:140}},ports:[{id:'output',direction:'output',nodeId:'inside',handle:'right'}]};
const text=makeCanvasNode('text',{x:900,y:100});text.id='outside';text.data.ai={enabled:true,modelId:'dynamic-model',prompt:'hello',propagate:false,parameters:{}};
const inside=makeCanvasNode('text',{x:10,y:20});inside.id='inside';
const edge={id:'edge',source:'frame',target:'outside',sourceHandle:'port:output',targetHandle:'left',data:{appearance:{color:'#123456',width:2,line:'dashed',head:{type:'triangle',filled:false,size:12},tail:{type:'circle',size:8}},animation:{mode:'executing',effect:'flow',direction:'forward',durationMs:1200,color:'#ffffff'},path:{type:'bezier-chain',knots:[{id:'k',position:{x:700,y:150},in:{x:-30,y:0},out:{x:30,y:0},mode:'smooth'}]},execution:{enabled:true}}};
return {frame,text,inside,root:{...emptyCanvas(),schema_version:2,nodes:[frame,text],edges:[edge]},child:{...emptyCanvas(),schema_version:2,nodes:[inside]}};
}
test('V2 roundtrip preserves linked frames, all curve/animation/marker data and AI config',()=>{
const {root}=fixture(); const decoded=decodeCanvas(JSON.stringify(root));
assert.deepEqual(decoded.edges[0].data,root.edges[0].data); assert.deepEqual(decoded.nodes[0].data.subgraph,root.nodes[0].data.subgraph); assert.deepEqual(decoded.nodes[1].data.ai,root.nodes[1].data.ai);assert.equal(decoded.nodes[0].width,5000);
assert.deepEqual(decodeCanvas(JSON.stringify(decoded)),decoded);
});
test('V2 rejects bad control data, wrong port directions and oversized ordinary elements',()=>{
for(const mutate of [d=>d.edges[0].data.path.knots[0].out.x=-30,d=>d.edges[0].data.animation.mode='random',d=>d.edges[0].data.appearance.width=100,d=>d.nodes[0].data.ports[0].direction='input',d=>d.nodes[1].width=5000,d=>d.nodes[1].data.ai.parameters=[]]){
const {root}=fixture();mutate(root);assert.throws(()=>decodeCanvas(JSON.stringify(root)));
}
});
test('expanded child nodes are projected once and boundary edges resolve to their actual nodes',()=>{
const {root,child}=fixture(), documents=new Map([['root',root],[childId,child]]);const view=projectCanvasTree('root',documents);
assert.equal(view.nodes.length,3);const projected=view.nodes.find(n=>n.id===childId+'::inside');assert.deepEqual(projected.position,{x:126,y:140});assert.equal(projected.width,52);assert.equal(view.nodeRefs.get(projected.id).boardId,childId);assert.equal(view.edges[0].source,projected.id);assert.equal(root.nodes.length,2);assert.equal(child.nodes.length,1);
root.nodes[0].data.presentation.collapsed=true;const collapsed=projectCanvasTree('root',documents);assert.equal(collapsed.nodes.length,2);assert.equal(collapsed.edges[0].source,'frame');assert.deepEqual(child.nodes[0].position,{x:10,y:20});
});
test('curve coordinates and vectors invert exactly between owner and projected canvases',()=>{
const {root}=fixture();const ref={boardId:'b',id:'e',origin:{x:100,y:-40},scale:.2};assert.deepEqual(transformEdgeData(transformEdgeData(root.edges[0].data,ref),ref,true),root.edges[0].data);
});
test('adding a curve point preserves both halves of the original cubic',()=>{
const from={point:{x:0,y:0},normal:{x:1,y:0}},to={point:{x:500,y:200},normal:{x:-1,y:0}};
const old=curveSegments(from,to)[0], split=insertCurveKnot(from,to,undefined), next=curveSegments(from,to,split.path);
assert.equal(next.length,2);for(let i=0;i<=100;i++){const t=i/100,a=cubicPoint(old,t),b=cubicPoint(next[t<=.5?0:1],t<=.5?t*2:(t-.5)*2);assert.ok(Math.hypot(a.x-b.x,a.y-b.y)<1e-8);}
});
test('AI remote output merges with local geometry but conflicts with locally edited text',()=>{
const base=fixture().child,local=structuredClone(base),remote=structuredClone(base);local.nodes[0].position={x:200,y:300};remote.nodes[0].data.text='generated';
const merged=mergeCanvasDocuments(base,local,remote);assert.deepEqual(merged.nodes[0].position,{x:200,y:300});assert.equal(merged.nodes[0].data.text,'generated');local.nodes[0].data.text='user typing';assert.throws(()=>mergeCanvasDocuments(base,local,remote),/version_conflict/);
});
test('eight-direction resize is free by default in resize mode and Shift locks the aspect',()=>{
for(const kind of ['plain-text','plain-image','text','image','frame']){
const node=makeCanvasNode(kind,{x:10,y:20});const oldRatio=node.width/node.height;
for(const direction of ['top','right','bottom','left','top-left','top-right','bottom-left','bottom-right']){
const patch=resizeFromPointer(node,direction,{x:80,y:40},true);assert.ok(Math.abs(patch.width/patch.height-oldRatio)<1e-9);
}
}
const image=makeCanvasNode('plain-image',{x:0,y:0});assert.equal(resizeFromPointer(image,'right',{x:100,y:0},false).height,image.height);
const frame=makeCanvasNode('frame',{x:0,y:0});assert.ok(resizeFromPointer(frame,'right',{x:10000,y:0},false).width>3000);
});
test('managed assets render through the authenticated same-origin proxy',()=>{
assert.equal(managedImageSource('https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/a'),'/api/foundry/workspaces/w/assets/a');assert.equal(managedImageSource('https://example.com/a.png'),'https://example.com/a.png');
});
test('connection plan uses the lowest common ancestor and exactly both ownership paths',async()=>{
const {connectionTransactionPlan}=await import('../src/lib/canvas-subgraphs.ts');const {root,child}=fixture();
const nested='33333333-3333-4333-8333-333333333333';const frame=makeCanvasNode('frame',{x:0,y:0});frame.data.subgraph={boardId:nested};child.nodes.push(frame);
const docs=new Map([['root',root],[childId,child],[nested,{...emptyCanvas(),schema_version:2,nodes:[]}]]);
const result=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'right'},{boardId:'root',id:'b',handle:'left'});
assert.equal(result.boardId,'root');assert.deepEqual(new Set(result.participants),new Set(['root',childId,nested]));assert.equal(result.source.node_id,'a');
const inner=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'bottom'},{boardId:childId,id:'b',handle:'top'});assert.equal(inner.boardId,childId);assert.deepEqual(new Set(inner.participants),new Set([childId,nested]));
});
test('incompatible drafts cannot overwrite migrated V2 ownership',async()=>{
const {canRestoreCanvasDraft}=await import('../src/lib/canvas-sync.ts');const {root}=fixture();const old={...structuredClone(root),schema_version:1};assert.equal(canRestoreCanvasDraft(old,root),false);
const changed=structuredClone(root);changed.nodes[0].data.subgraph.boardId='55555555-5555-4555-8555-555555555555';assert.equal(canRestoreCanvasDraft(changed,root),false);assert.equal(canRestoreCanvasDraft(structuredClone(root),root),true);
});
test('selecting both parent frame and child applies a shared drag once',async()=>{
const {referenceAfterMoves}=await import('../src/lib/canvas-tree.ts');const {root,child}=fixture();const docs=new Map([['root',root],[childId,child]]),view=projectCanvasTree('root',docs);
const projected=view.nodes.find(node=>node.id===childId+'::inside');const delta={x:50,y:80};const moved={x:projected.position.x+delta.x,y:projected.position.y+delta.y};
const ref=referenceAfterMoves('root',docs,view.boards,childId,new Map([['frame',{x:root.nodes[0].position.x+delta.x,y:root.nodes[0].position.y+delta.y}],[projected.id,moved]]));
assert.deepEqual({x:(moved.x-ref.origin.x)/ref.scale,y:(moved.y-ref.origin.y)/ref.scale},child.nodes[0].position);
});