ai-chatbot/tests/routes/canvas-capability-runtime.test.ts

292 lines
9.6 KiB
TypeScript

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([]);
});
});