292 lines
9.6 KiB
TypeScript
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([]);
|
|
});
|
|
});
|