From fa9f09e84e6d6a086b75a08a0378c8b84708fa15 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 22 Jun 2026 21:58:45 +0800 Subject: [PATCH] test: cover canvas agent operation requests --- app/api/canvas/[id]/agent-operations/route.ts | 163 +-------------- lib/canvas/agent-operation-request.ts | 167 +++++++++++++++ playwright.helpers.config.ts | 5 +- tests/routes/canvas-agent-operations.test.ts | 196 ++++++++++++++++++ 4 files changed, 372 insertions(+), 159 deletions(-) create mode 100644 lib/canvas/agent-operation-request.ts create mode 100644 tests/routes/canvas-agent-operations.test.ts diff --git a/app/api/canvas/[id]/agent-operations/route.ts b/app/api/canvas/[id]/agent-operations/route.ts index 306488588..54f967328 100644 --- a/app/api/canvas/[id]/agent-operations/route.ts +++ b/app/api/canvas/[id]/agent-operations/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; +import { ZodError } from "zod"; import { auth } from "@/app/(auth)/auth"; import { applyCanvasOperationPlan, @@ -9,160 +9,7 @@ import { canApplyCanvasAgentMutations, getCanvasAgentFeatureFlags, } from "@/lib/canvas/agent-feature-flags"; -import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol"; - -const canvasOperationPlanSchema = z - .object({ - version: z.literal(1), - operationId: z.string().min(1).max(160), - operation: z.enum([ - "create_agent_slot", - "fill_slot", - "insert_beside", - "append_revision", - "replace_node_media", - "add_annotation", - "link_nodes", - "move_nodes", - "delete_nodes", - "update_text", - ]), - projectId: z.string().uuid().optional(), - baseCanvasRevision: z.string().max(160).optional(), - baseCanvasUpdatedAt: z.string().max(160).optional(), - sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), - targetNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), - targetSlotId: z.string().min(1).max(160).optional(), - anchorNodeId: z.string().min(1).max(160).optional(), - edgeType: z.enum(["default", "reference", "conversation"]).optional(), - textContent: z.string().min(1).max(4000).optional(), - confirmed: z.boolean().optional(), - layoutStrategy: z.enum(["row", "column", "grid"]).optional(), - spacing: z.number().positive().max(240).optional(), - targetPosition: z - .object({ - x: z.number(), - y: z.number(), - }) - .optional(), - slot: z - .object({ - slotType: z.enum(["image", "video", "ui-screen"]), - label: z.string().max(120).optional(), - aspectRatio: z.string().max(32).optional(), - fillPolicy: z - .enum(["replace-empty", "append-version", "replace-existing"]) - .optional(), - acceptedSources: z - .array(z.enum(["text", "image", "selection", "annotation"])) - .max(4) - .optional(), - }) - .optional(), - annotations: z - .array( - z.object({ - id: z.string().min(1).max(128).optional(), - sourceNodeId: z.string().min(1).max(160).optional(), - type: z.enum(["text-note", "arrow-note", "box", "freehand", "crop-box"]), - text: z.string().max(1000).optional(), - bounds: z - .object({ - x: z.number(), - y: z.number(), - width: z.number(), - height: z.number(), - }) - .optional(), - targetPoint: z - .object({ - x: z.number(), - y: z.number(), - }) - .optional(), - color: z.string().max(64).optional(), - createdAt: z.string().max(80).optional(), - }) - ) - .max(20) - .optional(), - placement: z - .enum(["right", "left", "below", "above", "viewport-center"]) - .optional(), - placementStrategy: z - .enum(["single", "row", "grid", "overlay", "beside_each_source"]) - .optional(), - preserveOriginal: z.boolean().optional(), - media: z - .object({ - type: z.enum(["image", "video"]), - url: z.string().url(), - assetId: z.string().uuid().optional(), - width: z.number().positive().optional(), - height: z.number().positive().optional(), - title: z.string().max(256).optional(), - prompt: z.string().max(2000).optional(), - sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), - metadata: z.record(z.unknown()).optional(), - }) - .optional(), - mediaItems: z - .array( - z.object({ - mediaItemId: z.string().min(1).max(160), - type: z.enum(["image", "video"]), - url: z.string().url(), - assetId: z.string().uuid().optional(), - width: z.number().positive().optional(), - height: z.number().positive().optional(), - title: z.string().max(256).optional(), - prompt: z.string().max(2000).optional(), - sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), - metadata: z.record(z.unknown()).optional(), - }) - ) - .max(20) - .optional(), - metadata: z.record(z.unknown()).optional(), - }) - .refine( - (value) => - value.operation === "create_agent_slot" || - value.operation === "link_nodes" || - value.operation === "update_text" || - value.operation === "add_annotation" || - value.operation === "move_nodes" || - value.operation === "delete_nodes" || - value.media || - (value.mediaItems?.length ?? 0) > 0, - { - message: "缺少媒体结果", - path: ["mediaItems"], - } - ) - .refine( - (value) => - value.operation !== "add_annotation" || - Boolean(String(value.textContent || "").trim()) || - (value.annotations?.length ?? 0) > 0, - { - message: "缺少批注内容", - path: ["annotations"], - } - ) - .refine( - (value) => - value.operation !== "update_text" || - Boolean(String(value.textContent || "").trim()), - { - message: "缺少文本内容", - path: ["textContent"], - } - ); - -const requestSchema = z.object({ - operationPlan: canvasOperationPlanSchema, -}); +import { parseCanvasAgentOperationRequestBody } from "@/lib/canvas/agent-operation-request"; export async function POST( request: NextRequest, @@ -191,11 +38,11 @@ export async function POST( ); } - const body = requestSchema.parse(await request.json()); + const body = parseCanvasAgentOperationRequestBody(await request.json()); const result = await applyCanvasOperationPlan({ userId: session.user.id, projectId: id, - plan: body.operationPlan as CanvasOperationPlan, + plan: body.operationPlan, }); return NextResponse.json({ @@ -213,7 +60,7 @@ export async function POST( { status: error.status } ); } - if (error instanceof z.ZodError) { + if (error instanceof ZodError) { return NextResponse.json( { success: false, error: "请求参数无效", details: error.flatten() }, { status: 400 } diff --git a/lib/canvas/agent-operation-request.ts b/lib/canvas/agent-operation-request.ts new file mode 100644 index 000000000..f8e3eb501 --- /dev/null +++ b/lib/canvas/agent-operation-request.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; +import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol"; + +export const canvasAgentOperationPlanSchema = z + .object({ + version: z.literal(1), + operationId: z.string().min(1).max(160), + operation: z.enum([ + "create_agent_slot", + "fill_slot", + "insert_beside", + "append_revision", + "replace_node_media", + "add_annotation", + "link_nodes", + "move_nodes", + "delete_nodes", + "update_text", + ]), + projectId: z.string().uuid().optional(), + baseCanvasRevision: z.string().max(160).optional(), + baseCanvasUpdatedAt: z.string().max(160).optional(), + sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), + targetNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), + targetSlotId: z.string().min(1).max(160).optional(), + anchorNodeId: z.string().min(1).max(160).optional(), + edgeType: z.enum(["default", "reference", "conversation"]).optional(), + textContent: z.string().min(1).max(4000).optional(), + confirmed: z.boolean().optional(), + layoutStrategy: z.enum(["row", "column", "grid"]).optional(), + spacing: z.number().positive().max(240).optional(), + targetPosition: z + .object({ + x: z.number(), + y: z.number(), + }) + .optional(), + slot: z + .object({ + slotType: z.enum(["image", "video", "ui-screen"]), + label: z.string().max(120).optional(), + aspectRatio: z.string().max(32).optional(), + fillPolicy: z + .enum(["replace-empty", "append-version", "replace-existing"]) + .optional(), + acceptedSources: z + .array(z.enum(["text", "image", "selection", "annotation"])) + .max(4) + .optional(), + }) + .optional(), + annotations: z + .array( + z.object({ + id: z.string().min(1).max(128).optional(), + sourceNodeId: z.string().min(1).max(160).optional(), + type: z.enum(["text-note", "arrow-note", "box", "freehand", "crop-box"]), + text: z.string().max(1000).optional(), + bounds: z + .object({ + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + }) + .optional(), + targetPoint: z + .object({ + x: z.number(), + y: z.number(), + }) + .optional(), + color: z.string().max(64).optional(), + createdAt: z.string().max(80).optional(), + }) + ) + .max(20) + .optional(), + placement: z + .enum(["right", "left", "below", "above", "viewport-center"]) + .optional(), + placementStrategy: z + .enum(["single", "row", "grid", "overlay", "beside_each_source"]) + .optional(), + preserveOriginal: z.boolean().optional(), + media: z + .object({ + type: z.enum(["image", "video"]), + url: z.string().url(), + assetId: z.string().uuid().optional(), + width: z.number().positive().optional(), + height: z.number().positive().optional(), + title: z.string().max(256).optional(), + prompt: z.string().max(2000).optional(), + sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), + metadata: z.record(z.unknown()).optional(), + }) + .optional(), + mediaItems: z + .array( + z.object({ + mediaItemId: z.string().min(1).max(160), + type: z.enum(["image", "video"]), + url: z.string().url(), + assetId: z.string().uuid().optional(), + width: z.number().positive().optional(), + height: z.number().positive().optional(), + title: z.string().max(256).optional(), + prompt: z.string().max(2000).optional(), + sourceNodeIds: z.array(z.string().min(1).max(160)).max(20).optional(), + metadata: z.record(z.unknown()).optional(), + }) + ) + .max(20) + .optional(), + metadata: z.record(z.unknown()).optional(), + }) + .refine( + (value) => + value.operation === "create_agent_slot" || + value.operation === "link_nodes" || + value.operation === "update_text" || + value.operation === "add_annotation" || + value.operation === "move_nodes" || + value.operation === "delete_nodes" || + value.media || + (value.mediaItems?.length ?? 0) > 0, + { + message: "缺少媒体结果", + path: ["mediaItems"], + } + ) + .refine( + (value) => + value.operation !== "add_annotation" || + Boolean(String(value.textContent || "").trim()) || + (value.annotations?.length ?? 0) > 0, + { + message: "缺少批注内容", + path: ["annotations"], + } + ) + .refine( + (value) => + value.operation !== "update_text" || + Boolean(String(value.textContent || "").trim()), + { + message: "缺少文本内容", + path: ["textContent"], + } + ); + +export const canvasAgentOperationRequestSchema = z.object({ + operationPlan: canvasAgentOperationPlanSchema, +}); + +export type CanvasAgentOperationRequestBody = { + operationPlan: CanvasOperationPlan; +}; + +export function parseCanvasAgentOperationRequestBody( + value: unknown +): CanvasAgentOperationRequestBody { + return canvasAgentOperationRequestSchema.parse( + value + ) as CanvasAgentOperationRequestBody; +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index c83a955a5..b6baa692c 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -2,7 +2,10 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", - testMatch: /routes\/canvas-chat-helpers\.test\.ts/, + testMatch: [ + /routes\/canvas-chat-helpers\.test\.ts/, + /routes\/canvas-agent-operations\.test\.ts/, + ], fullyParallel: true, forbidOnly: !!process.env.CI, retries: 0, diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts new file mode 100644 index 000000000..724bb9757 --- /dev/null +++ b/tests/routes/canvas-agent-operations.test.ts @@ -0,0 +1,196 @@ +import { expect, test } from "@playwright/test"; +import { + canvasAgentOperationRequestSchema, + parseCanvasAgentOperationRequestBody, +} from "@/lib/canvas/agent-operation-request"; + +const projectId = "11111111-1111-4111-8111-111111111111"; + +function buildMediaItem(index = 1) { + return { + mediaItemId: `media-${index}`, + type: "image" as const, + url: `https://example.com/generated-${index}.png`, + assetId: "22222222-2222-4222-8222-222222222222", + width: 1024, + height: 768, + title: `generated-${index}`, + prompt: "make the product cleaner", + sourceNodeIds: ["image-1"], + metadata: { + provider: "test", + }, + }; +} + +function buildPlan(overrides: Record) { + return { + version: 1, + operationId: "message-1:canvas-agent:test", + projectId, + baseCanvasRevision: "2026-06-22T00:00:00.000Z", + baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z", + ...overrides, + }; +} + +function parsePlan(overrides: Record) { + return parseCanvasAgentOperationRequestBody({ + operationPlan: buildPlan(overrides), + }); +} + +function issuePathsFor(overrides: Record) { + const result = canvasAgentOperationRequestSchema.safeParse({ + operationPlan: buildPlan(overrides), + }); + + expect(result.success).toBe(false); + return result.success + ? [] + : result.error.issues.map((issue) => issue.path.join(".")); +} + +test.describe("canvas agent operation request schema", () => { + test("accepts slot fill requests with batch media and revision guards", () => { + const body = parsePlan({ + operation: "fill_slot", + targetSlotId: "slot-1", + placement: "viewport-center", + preserveOriginal: true, + mediaItems: [buildMediaItem(1), buildMediaItem(2)], + }); + + expect(body.operationPlan).toMatchObject({ + operation: "fill_slot", + targetSlotId: "slot-1", + baseCanvasRevision: "2026-06-22T00:00:00.000Z", + mediaItems: [ + { mediaItemId: "media-1", url: "https://example.com/generated-1.png" }, + { mediaItemId: "media-2", url: "https://example.com/generated-2.png" }, + ], + }); + }); + + test("accepts append revision requests with legacy media payloads", () => { + const body = parsePlan({ + operation: "append_revision", + sourceNodeIds: ["image-1"], + targetNodeIds: ["image-1"], + media: { + type: "image", + url: "https://example.com/legacy.png", + width: 1200, + height: 900, + title: "legacy-result", + }, + }); + + expect(body.operationPlan).toMatchObject({ + operation: "append_revision", + media: { + url: "https://example.com/legacy.png", + }, + }); + }); + + test("accepts slot-only, annotation-only, and text-only operations", () => { + expect( + parsePlan({ + operation: "create_agent_slot", + targetPosition: { x: 120, y: 160 }, + slot: { + slotType: "image", + label: "主图结果槽", + aspectRatio: "4:3", + fillPolicy: "replace-empty", + acceptedSources: ["text", "image", "annotation"], + }, + }).operationPlan.operation + ).toBe("create_agent_slot"); + + expect( + parsePlan({ + operation: "add_annotation", + targetNodeIds: ["image-1"], + annotations: [ + { + id: "annotation-1", + sourceNodeId: "image-1", + type: "box", + text: "tighten crop", + bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, + color: "#ff3366", + }, + ], + }).operationPlan.operation + ).toBe("add_annotation"); + + expect( + parsePlan({ + operation: "update_text", + targetNodeIds: ["text-1"], + textContent: "New canvas title", + }).operationPlan.operation + ).toBe("update_text"); + }); + + test("caps batch media at twenty items", () => { + const accepted = parsePlan({ + operation: "insert_beside", + sourceNodeIds: ["image-1"], + placementStrategy: "grid", + mediaItems: Array.from({ length: 20 }, (_, index) => buildMediaItem(index + 1)), + }); + + expect(accepted.operationPlan.mediaItems).toHaveLength(20); + expect( + issuePathsFor({ + operation: "insert_beside", + sourceNodeIds: ["image-1"], + mediaItems: Array.from({ length: 21 }, (_, index) => buildMediaItem(index + 1)), + }) + ).toContain("operationPlan.mediaItems"); + }); + + test("rejects media operations without generated media", () => { + expect( + issuePathsFor({ + operation: "insert_beside", + sourceNodeIds: ["image-1"], + placement: "right", + }) + ).toContain("operationPlan.mediaItems"); + }); + + test("rejects annotation and text operations without required content", () => { + expect( + issuePathsFor({ + operation: "add_annotation", + targetNodeIds: ["image-1"], + }) + ).toContain("operationPlan.annotations"); + + expect( + issuePathsFor({ + operation: "update_text", + targetNodeIds: ["text-1"], + }) + ).toContain("operationPlan.textContent"); + }); + + test("rejects invalid media urls", () => { + expect( + issuePathsFor({ + operation: "replace_node_media", + targetNodeIds: ["image-1"], + mediaItems: [ + { + ...buildMediaItem(), + url: "not-a-url", + }, + ], + }) + ).toContain("operationPlan.mediaItems.0.url"); + }); +});