import { expect, test } from "@playwright/test"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { applyCanvasOperationPlanToCanvasData } from "@/lib/canvas/agent-mutations"; import { CANVAS_CAPABILITY_REGISTRY, getCanvasCapability, getCanvasCapabilityManifest, } from "@/lib/canvas/capabilities/registry"; import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import { shouldExecuteCanvasCapabilityHeadlessly } from "@/lib/canvas/capabilities/execution-mode"; import { createCanvasCapabilityMcpServer } from "@/lib/canvas/capabilities/mcp-server"; import { CANVAS_CAPABILITY_IDS } from "@/lib/canvas/capabilities/types"; import { useCanvasStore } from "@/store/canvas"; const SELECTED_IMAGE_TOOLBAR_ACTIONS = [ "pickObject", "enhance", "removeBackground", "aiWashQuick", "textEdit", "boxCutout", "localEdit", "removeWatermark", "resize", "multiAngle", "generate3dModel", "generate3dWorld", "layerSplit", "extend", "crop", "adjust", "flipRotate", "promptRegenerate", "note", ] as const; test.describe("canvas capability runtime contracts", () => { test("registers every selected-image toolbar capability exactly once", () => { const registeredActions = CANVAS_CAPABILITY_REGISTRY.flatMap((capability) => capability.toolbarActionKey ? [capability.toolbarActionKey] : [] ); expect(new Set(registeredActions).size).toBe(registeredActions.length); expect(registeredActions.sort()).toEqual([...SELECTED_IMAGE_TOOLBAR_ACTIONS].sort()); }); test("keeps capability, client action, Agent, and MCP names unique", () => { const ids = CANVAS_CAPABILITY_REGISTRY.map((capability) => capability.id); const clientActions = CANVAS_CAPABILITY_REGISTRY.map( (capability) => capability.clientAction ); const agentTools = CANVAS_CAPABILITY_REGISTRY.flatMap((capability) => capability.agent.exposed && capability.agent.toolName ? [capability.agent.toolName] : [] ); const mcpTools = CANVAS_CAPABILITY_REGISTRY.flatMap((capability) => capability.mcp.exposed ? [capability.mcp.toolName] : [] ); expect(ids).toEqual([...CANVAS_CAPABILITY_IDS]); expect(new Set(ids).size).toBe(ids.length); expect(new Set(clientActions).size).toBe(clientActions.length); expect(new Set(agentTools).size).toBe(agentTools.length); expect(new Set(mcpTools).size).toBe(mcpTools.length); }); test("validates arguments and preserves execution provenance", () => { const command = createCanvasCapabilityClientCommand({ capabilityId: "image.enhance", nodeIds: ["image-a", "image-a", "image-b"], projectId: "canvas-1", requestId: "request-1", args: { scaleFactor: 4 }, source: "agent", createdAt: "2026-07-13T00:00:00.000Z", }); expect(command).toMatchObject({ capabilityId: "image.enhance", nodeIds: ["image-a", "image-b"], projectId: "canvas-1", requestId: "request-1", args: { scaleFactor: 4 }, source: "agent", }); expect(() => createCanvasCapabilityClientCommand({ capabilityId: "image.enhance", nodeIds: ["image-a"], args: { scaleFactor: 12 }, source: "agent", }) ).toThrow(); }); test("requires explicit confirmation for destructive capabilities", () => { expect(() => createCanvasCapabilityClientCommand({ capabilityId: "image.delete", nodeIds: ["image-a"], source: "agent", }) ).toThrow("requires explicit confirmation"); expect( createCanvasCapabilityClientCommand({ capabilityId: "image.delete", nodeIds: ["image-a"], source: "agent", confirmed: true, }).confirmed ).toBe(true); }); test("publishes a serializable manifest without runtime schemas", () => { const manifest = getCanvasCapabilityManifest(); expect(manifest).toHaveLength(CANVAS_CAPABILITY_IDS.length); expect(manifest.every((entry) => !("inputSchema" in entry))).toBe(true); expect(() => JSON.stringify(manifest)).not.toThrow(); }); test("exposes the registry through a real MCP client", async () => { const server = await createCanvasCapabilityMcpServer({ filterDisabled: false, projectId: "canvas-1", requestHeaders: new Headers(), requestUrl: "http://localhost/api/mcp/canvas/canvas-1", userId: "user-1", }); const client = new Client({ name: "capability-contract-test", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); try { const result = await client.listTools(); const expectedNames = CANVAS_CAPABILITY_REGISTRY.flatMap((capability) => capability.mcp.exposed && capability.mcp.toolName ? [capability.mcp.toolName] : [] ); expect(result.tools.map((tool) => tool.name).sort()).toEqual( expectedNames.sort() ); expect( result.tools.every( (tool) => tool.inputSchema.type === "object" && Object.hasOwn(tool.inputSchema.properties || {}, "nodeIds") ) ).toBe(true); } finally { await client.close(); await server.close(); } }); test("runs 3D capabilities headlessly for Agent and MCP", () => { for (const capabilityId of [ "image.generate_3d_model", "image.generate_3d_world", ] as const) { const capability = getCanvasCapability(capabilityId); expect(capability).not.toBeNull(); if (!capability) continue; const agentCommand = createCanvasCapabilityClientCommand({ capabilityId, nodeIds: ["image-a"], source: "agent", }); const toolbarCommand = createCanvasCapabilityClientCommand({ capabilityId, nodeIds: ["image-a"], source: "toolbar", }); expect( shouldExecuteCanvasCapabilityHeadlessly({ capability, command: agentCommand }) ).toBe(true); expect( shouldExecuteCanvasCapabilityHeadlessly({ capability, command: toolbarCommand }) ).toBe(false); } }); test("writes recoverable 3D task nodes through the shared mutation protocol", () => { const sourceNode = { id: "image-a", type: "image", position: { x: 10, y: 20 }, size: { width: 320, height: 240 }, data: { src: "https://example.com/source.png" }, }; const cases = [ { type: "model3d" as const, provider: "tripo-3d" }, { type: "world3d" as const, provider: "worldlabs-3d" }, ]; for (const item of cases) { const result = applyCanvasOperationPlanToCanvasData({ canvasData: { nodes: [sourceNode], edges: [] }, plan: { version: 1, operationId: `operation-${item.type}`, operation: "insert_beside", projectId: "canvas-1", sourceNodeIds: [sourceNode.id], targetNodeIds: [sourceNode.id], placement: "right", placementStrategy: "beside_each_source", mediaItems: [ { mediaItemId: `result-${item.type}`, type: item.type, url: "https://example.com/source.png", title: `3D result ${item.type}`, sourceNodeIds: [sourceNode.id], metadata: { taskId: `task-${item.type}`, provider: item.provider, status: "generating", }, }, ], }, }); const resultNodes = (Array.isArray(result.canvasData.nodes) ? result.canvasData.nodes : []) as Array<{ connections?: string[]; data?: { metadata?: { taskId?: string }; status?: string }; id: string; type?: string; }>; const createdNode = resultNodes.find( (node) => node.id === result.createdNodeIds[0] ); expect(createdNode?.type).toBe(item.type); expect(createdNode?.data?.status).toBe("generating"); expect(createdNode?.data?.metadata?.taskId).toBe(`task-${item.type}`); expect(createdNode?.connections).toContain(sourceNode.id); } }); test("queues client commands once and clears them when switching canvas", () => { useCanvasStore.setState({ projectId: "canvas-1", nodes: [ { id: "image-a", type: "image", position: { x: 0, y: 0 }, size: { width: 320, height: 320 }, data: { src: "https://example.com/source.png", status: "completed", }, connections: [], createdAt: new Date(), updatedAt: new Date(), }, ], imageNodeActionRequest: null, imageNodeActionQueue: [], handledCanvasCapabilityRequestIds: [], }); const command = createCanvasCapabilityClientCommand({ capabilityId: "image.crop", nodeIds: ["image-a"], projectId: "canvas-1", requestId: "interactive-request-1", source: "agent", createdAt: "2026-07-13T00:00:00.000Z", }); useCanvasStore.getState().dispatchCanvasCapabilityCommand(command); useCanvasStore.getState().dispatchCanvasCapabilityCommand(command); expect(useCanvasStore.getState().imageNodeActionRequest?.requestId).toBe( command.requestId ); expect(useCanvasStore.getState().imageNodeActionQueue).toHaveLength(0); useCanvasStore.getState().setProject("canvas-2"); expect(useCanvasStore.getState().imageNodeActionRequest).toBeNull(); expect(useCanvasStore.getState().handledCanvasCapabilityRequestIds).toEqual([]); }); });