test: cover canvas agent operation requests
This commit is contained in:
parent
6d8aa557bf
commit
fa9f09e84e
@ -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 }
|
||||
|
||||
167
lib/canvas/agent-operation-request.ts
Normal file
167
lib/canvas/agent-operation-request.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
196
tests/routes/canvas-agent-operations.test.ts
Normal file
196
tests/routes/canvas-agent-operations.test.ts
Normal file
@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
return parseCanvasAgentOperationRequestBody({
|
||||
operationPlan: buildPlan(overrides),
|
||||
});
|
||||
}
|
||||
|
||||
function issuePathsFor(overrides: Record<string, unknown>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user