537 lines
25 KiB
TypeScript
537 lines
25 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { EventEmitter } from "node:events";
|
|
import http, { type Server } from "node:http";
|
|
import { test } from "node:test";
|
|
|
|
import { buildCanvasContext } from "../src/canvas-context.js";
|
|
import { CanvasSession } from "../src/canvas-session.js";
|
|
import { createLocalRuntimeApp } from "../src/local-runtime.js";
|
|
import { LocalRuntimeSessionManager } from "../src/local-runtime-session.js";
|
|
import { createCanvasAgentHttpModule } from "../src/modules/canvas-agent-http.js";
|
|
import { toolDescriptions, toolInputSchemas, toolNames } from "../src/schemas.js";
|
|
import type { LocalRuntimeConfig } from "../src/config.js";
|
|
|
|
const authority = "127.0.0.1:41743";
|
|
const endpoint = `http://${authority}`;
|
|
const origin = "http://127.0.0.1:3001";
|
|
const token = "legacy-canvas-token-fixture";
|
|
|
|
test("MCP manifest exposes the semantic canvas read tools with schemas and descriptions", () => {
|
|
const expected = [
|
|
"canvas_get_context",
|
|
"canvas_find_nodes",
|
|
"canvas_get_node",
|
|
"canvas_get_connection",
|
|
"canvas_get_generation_tasks",
|
|
"canvas_get_resources",
|
|
"canvas_validate_ops",
|
|
];
|
|
for (const name of expected) {
|
|
assert.ok(toolNames.includes(name as typeof toolNames[number]), `${name} is missing from toolNames`);
|
|
assert.ok(toolDescriptions[name as keyof typeof toolDescriptions], `${name} is missing a description`);
|
|
assert.ok(toolInputSchemas[name as keyof typeof toolInputSchemas], `${name} is missing an input schema`);
|
|
}
|
|
assert.deepEqual(
|
|
toolNames.filter((name) => name.startsWith("canvas_")).slice(0, 10),
|
|
[
|
|
"canvas_get_state",
|
|
"canvas_get_context",
|
|
"canvas_find_nodes",
|
|
"canvas_get_node",
|
|
"canvas_get_connection",
|
|
"canvas_get_generation_tasks",
|
|
"canvas_get_resources",
|
|
"canvas_validate_ops",
|
|
"canvas_get_selection",
|
|
"canvas_export_snapshot",
|
|
],
|
|
);
|
|
});
|
|
|
|
test("Canvas module declares only Canvas scopes and constructs without CLI side effects", () => {
|
|
const calls: string[] = [];
|
|
const module = createCanvasAgentHttpModule(fixtureConfig(), sessionFixture(calls));
|
|
|
|
assert.deepEqual(module.descriptor, {
|
|
id: "canvas-agent",
|
|
displayName: "Canvas Agent",
|
|
apiVersion: 1,
|
|
scopes: ["canvas:connect"],
|
|
});
|
|
assert.ok(module.routes.some((route) => route.path === "/events" && route.lastEventId));
|
|
assert.ok(module.routes.every((route) => route.scope === "canvas:connect" && route.legacy));
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
|
|
test("Canvas legacy guard strips token before core handlers and rejects the wrong token", async () => {
|
|
const calls: Array<{ name: string; value?: unknown }> = [];
|
|
const session = sessionFixture(calls);
|
|
const module = createCanvasAgentHttpModule(fixtureConfig(), session);
|
|
const manager = new LocalRuntimeSessionManager({
|
|
endpoint,
|
|
trustedOrigins: [origin],
|
|
registrations: [],
|
|
});
|
|
const app = createLocalRuntimeApp({
|
|
authority,
|
|
endpoint,
|
|
version: "0.1.0",
|
|
sessionManager: manager,
|
|
modules: [module],
|
|
legacyMasterToken: token,
|
|
legacyOrigins: [origin],
|
|
});
|
|
const server = app.listen(0, "127.0.0.1");
|
|
await listening(server);
|
|
try {
|
|
const accepted = await request(server, {
|
|
method: "POST",
|
|
path: `/canvas/state?clientId=fixture&token=${token}`,
|
|
headers: jsonHeaders(),
|
|
body: '{"nodes":[]}',
|
|
});
|
|
assert.equal(accepted.status, 200);
|
|
assert.deepEqual(calls.at(-1), { name: "state", value: { nodes: [] } });
|
|
|
|
const event = await request(server, {
|
|
path: `/events?clientId=fixture&token=${token}`,
|
|
headers: { Host: authority, Origin: origin },
|
|
});
|
|
assert.equal(event.status, 204);
|
|
assert.deepEqual(calls.at(-1), {
|
|
name: "events",
|
|
value: { clientId: "fixture", token: null },
|
|
});
|
|
|
|
const before = calls.length;
|
|
const rejected = await request(server, {
|
|
method: "POST",
|
|
path: "/canvas/state?token=wrong",
|
|
headers: jsonHeaders(),
|
|
body: '{"nodes":[]}',
|
|
});
|
|
assert.equal(rejected.status, 401);
|
|
assert.equal(calls.length, before);
|
|
} finally {
|
|
manager.dispose();
|
|
await close(server);
|
|
}
|
|
});
|
|
|
|
test("CanvasSession dispose closes streams and a replaced stream cannot clear the active client", () => {
|
|
const session = new CanvasSession();
|
|
const first = eventResponse();
|
|
const second = eventResponse();
|
|
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=fixture"), first.response as never);
|
|
session.updateState({ nodes: [] }, "fixture");
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=fixture"), second.response as never);
|
|
first.response.emit("close");
|
|
assert.deepEqual(session.health(), { ok: true, hasCanvas: true, clients: 1 });
|
|
|
|
session.dispose();
|
|
assert.equal(second.ended(), 1);
|
|
assert.deepEqual(session.health(), { ok: true, hasCanvas: false, clients: 0 });
|
|
second.response.emit("close");
|
|
});
|
|
|
|
test("CanvasSession exposes precise node and connection reads", async () => {
|
|
const session = new CanvasSession();
|
|
const response = eventResponse();
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=precise-read"), response.response as never);
|
|
session.updateState({
|
|
nodes: [
|
|
{ id: "node-a", type: "text", title: "A", position: { x: 0, y: 0 }, width: 320, height: 240 },
|
|
{ id: "node-b", type: "image", title: "B", position: { x: 400, y: 0 }, width: 320, height: 320, metadata: { status: "success", storageKey: "resource:b" } },
|
|
],
|
|
connections: [{ id: "connection-1", fromNodeId: "node-a", toNodeId: "node-b" }],
|
|
}, "precise-read");
|
|
assert.equal((await session.callTool("canvas_get_node", { id: "node-b" }) as { found: boolean }).found, true);
|
|
assert.equal((await session.callTool("canvas_get_connection", { id: "connection-1" }) as { found: boolean }).found, true);
|
|
assert.equal((await session.callTool("canvas_get_node", { id: "missing" }) as { found: boolean }).found, false);
|
|
session.dispose();
|
|
});
|
|
|
|
test("CanvasSession closes only streams owned by a revoked Runtime session", async () => {
|
|
const session = new CanvasSession();
|
|
const closeRuntimeSession = (session as CanvasSession & {
|
|
closeRuntimeSession?: (sessionId: string) => void;
|
|
}).closeRuntimeSession;
|
|
assert.equal(typeof closeRuntimeSession, "function");
|
|
if (!closeRuntimeSession) return;
|
|
|
|
const first = eventResponse();
|
|
const second = eventResponse();
|
|
const legacy = eventResponse();
|
|
const openEvents = session.openEvents as unknown as (
|
|
url: URL,
|
|
response: EventEmitter,
|
|
runtimeSessionId?: string,
|
|
) => void;
|
|
openEvents.call(session, new URL("http://127.0.0.1/events?clientId=first"), first.response, "session-a");
|
|
openEvents.call(session, new URL("http://127.0.0.1/events?clientId=second"), second.response, "session-b");
|
|
openEvents.call(session, new URL("http://127.0.0.1/events?clientId=legacy"), legacy.response);
|
|
session.updateState({ nodes: [] }, "first");
|
|
const pending = session.callTool("canvas_apply_ops", { ops: [] });
|
|
|
|
closeRuntimeSession.call(session, "session-a");
|
|
|
|
assert.equal(first.ended(), 1);
|
|
assert.equal(second.ended(), 0);
|
|
assert.equal(legacy.ended(), 0);
|
|
assert.deepEqual(session.health(), { ok: true, hasCanvas: false, clients: 2 });
|
|
await assert.rejects(pending, /会话已撤销/);
|
|
session.dispose();
|
|
});
|
|
|
|
test("Canvas generation tool continuation survives a browser stream reconnect until the same request is resolved", async () => {
|
|
const session = new CanvasSession();
|
|
const first = eventResponse();
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=agent-client-before-refresh"), first.response as never);
|
|
session.updateState({
|
|
nodes: [{ id: "existing-image", type: "image", title: "Existing", position: { x: 0, y: 0 }, width: 320, height: 240, metadata: { generationMode: "image", taskId: "dreamina:prior-task-0001" } }],
|
|
}, "agent-client-before-refresh");
|
|
|
|
try {
|
|
const pending = session.callTool("canvas_run_generation", { nodeId: "existing-image", mode: "image", prompt: "Retry image", retry: true });
|
|
const call = latestToolCall(first.writes());
|
|
first.response.emit("close");
|
|
let settled = false;
|
|
void pending.finally(() => { settled = true; }).catch(() => undefined);
|
|
await Promise.resolve();
|
|
assert.equal(settled, false, "generation tool must remain resumable across a browser refresh");
|
|
|
|
const second = eventResponse();
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=agent-client-after-refresh"), second.response as never);
|
|
session.resolveResult({ requestId: call.requestId, result: { accepted: true } });
|
|
assert.deepEqual(await pending, { accepted: true });
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
|
|
test("CanvasSession expands a workflow into semantic nodes, non-overlapping layout, real edges, and selective generation", async () => {
|
|
const session = new CanvasSession();
|
|
const events = eventResponse();
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=agent-workflow"), events.response as never);
|
|
session.updateState({
|
|
nodes: [{ id: "existing-character", type: "image", title: "角色原画", position: { x: 0, y: 0 }, width: 560, height: 380, metadata: { status: "success", storageKey: "resource:character" } }],
|
|
connections: [],
|
|
}, "agent-workflow");
|
|
|
|
try {
|
|
const pending = session.callTool("canvas_create_workflow", {
|
|
title: "搞笑修仙小说流水线",
|
|
nodes: [
|
|
{ ref: "cards", kind: "character_cards", title: "角色拆分图片卡片", referenceNodeIds: ["existing-character"] },
|
|
{ ref: "views", kind: "character_three_view", title: "角色三视图", prompt: "基于角色卡片生成正面、侧面、背面三视图", referenceRefs: ["cards"] },
|
|
{ ref: "storyboard", kind: "storyboard_video", title: "分镜剧情视频", prompt: "基于三视图制作分镜剧情视频", referenceRefs: ["views"], runGeneration: true },
|
|
],
|
|
});
|
|
const call = latestToolCall(events.writes());
|
|
const ops = (call.input as { ops: Array<Record<string, unknown>> }).ops;
|
|
const added = ops.filter((op) => op.type === "add_node");
|
|
const edges = ops.filter((op) => op.type === "connect_nodes");
|
|
const runs = ops.filter((op) => op.type === "run_generation");
|
|
|
|
assert.deepEqual(added.map((op) => op.nodeType), ["image", "image", "video"]);
|
|
assert.match(String((added[0]?.metadata as Record<string, unknown>)?.prompt), /拆分主要角色/);
|
|
assert.equal(edges.length, 3, "two workflow edges plus one existing reference edge");
|
|
assert.equal(runs.length, 1, "runGeneration only affects the explicitly requested node");
|
|
assert.equal(runs[0]?.nodeId, added[2]?.id);
|
|
assert.ok(Number((added[1]?.position as { x: number }).x) > Number((added[0]?.position as { x: number }).x) + Number(added[0]?.width));
|
|
assert.ok(Number((added[2]?.position as { x: number }).x) > Number((added[1]?.position as { x: number }).x) + Number(added[1]?.width));
|
|
assert.ok(edges.some((op) => op.fromNodeId === "existing-character" && op.toNodeId === added[0]?.id));
|
|
|
|
session.resolveResult({ requestId: call.requestId, result: { accepted: true } });
|
|
assert.deepEqual(await pending, { accepted: true });
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
|
|
test("CanvasSession rejects media workflow nodes without real creative content", async () => {
|
|
const session = new CanvasSession();
|
|
const events = eventResponse();
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=agent-workflow-invalid"), events.response as never);
|
|
session.updateState({ nodes: [] }, "agent-workflow-invalid");
|
|
try {
|
|
await assert.rejects(
|
|
session.callTool("canvas_create_workflow", {
|
|
nodes: [{ ref: "empty-image", kind: "image", title: "空图片节点" }],
|
|
}),
|
|
/缺少 prompt\/content/,
|
|
);
|
|
assert.equal(events.writes().some((value) => value.includes("event: tool_call")), false);
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
|
|
test("Canvas Dreamina image generation preserves the shared product model and auto quality before run_generation", async () => {
|
|
const session = new CanvasSession();
|
|
const events = eventResponse();
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=agent-dreamina-product"), events.response as never);
|
|
session.updateState({ nodes: [] }, "agent-dreamina-product");
|
|
|
|
try {
|
|
const generated = session.callTool("canvas_generate_image", {
|
|
prompt: "A cinematic city at night",
|
|
model: "local:dreamina-cli:5.0",
|
|
quality: "auto",
|
|
size: "16:9",
|
|
count: 1,
|
|
});
|
|
const call = latestToolCall(events.writes());
|
|
const ops = (call.input as { ops: Array<Record<string, unknown>> }).ops;
|
|
const target = ops.find((op) => op.type === "add_node" && op.nodeType === "image");
|
|
const metadata = target?.metadata as Record<string, unknown> | undefined;
|
|
const run = ops.find((op) => op.type === "run_generation");
|
|
assert.equal(metadata?.model, "local:dreamina-cli:5.0");
|
|
assert.equal(metadata?.quality, "auto");
|
|
assert.equal(metadata?.size, "16:9");
|
|
assert.deepEqual(run && { type: run.type, nodeId: run.nodeId, mode: run.mode }, {
|
|
type: "run_generation",
|
|
nodeId: target?.id,
|
|
mode: "image",
|
|
});
|
|
session.resolveResult({ requestId: call.requestId, result: { accepted: true } });
|
|
await generated;
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
|
|
test("CanvasSession keeps ordinary tool timeout at 30s and generation continuation at 35min with one settlement", async () => {
|
|
const originalSetTimeout = globalThis.setTimeout;
|
|
const originalClearTimeout = globalThis.clearTimeout;
|
|
const delays: number[] = [];
|
|
const cleared = new Set<unknown>();
|
|
let nextHandle = 0;
|
|
Object.defineProperty(globalThis, "setTimeout", {
|
|
configurable: true,
|
|
value: ((_: (...args: unknown[]) => void, delay?: number) => {
|
|
delays.push(Number(delay));
|
|
return { id: ++nextHandle, unref() {} };
|
|
}) as typeof setTimeout,
|
|
});
|
|
Object.defineProperty(globalThis, "clearTimeout", {
|
|
configurable: true,
|
|
value: ((handle: unknown) => { cleared.add(handle); }) as typeof clearTimeout,
|
|
});
|
|
const session = new CanvasSession();
|
|
const events = eventResponse();
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=timeout-client"), events.response as never);
|
|
session.updateState({ nodes: [] }, "timeout-client");
|
|
try {
|
|
const ordinary = session.callTool("canvas_apply_ops", { ops: [{ type: "select_nodes", ids: [] }] });
|
|
const ordinaryCall = latestToolCall(events.writes());
|
|
assert.equal(delays.at(-1), 30_000);
|
|
session.resolveResult({ requestId: ordinaryCall.requestId, result: { accepted: "ordinary" } });
|
|
assert.deepEqual(await ordinary, { accepted: "ordinary" });
|
|
|
|
const generation = session.callTool("canvas_generate_image", {
|
|
prompt: "A safe fixture",
|
|
model: "local:dreamina-cli:5.0",
|
|
quality: "auto",
|
|
size: "16:9",
|
|
count: 1,
|
|
});
|
|
const generationCall = latestToolCall(events.writes());
|
|
assert.equal(delays.at(-1), 35 * 60 * 1_000);
|
|
let settlements = 0;
|
|
void generation.then(() => { settlements += 1; }, () => { settlements += 1; });
|
|
session.resolveResult({ requestId: generationCall.requestId, result: { accepted: "generation" } });
|
|
assert.deepEqual(await generation, { accepted: "generation" });
|
|
await Promise.resolve();
|
|
assert.equal(settlements, 1);
|
|
session.resolveResult({ requestId: generationCall.requestId, result: { accepted: "duplicate" } });
|
|
await Promise.resolve();
|
|
assert.equal(settlements, 1);
|
|
assert.equal(cleared.size, 2);
|
|
} finally {
|
|
Object.defineProperty(globalThis, "setTimeout", { configurable: true, value: originalSetTimeout });
|
|
Object.defineProperty(globalThis, "clearTimeout", { configurable: true, value: originalClearTimeout });
|
|
session.dispose();
|
|
}
|
|
});
|
|
|
|
test("Canvas generation tools emit generic run operations and preserve product configuration values", async () => {
|
|
const session = new CanvasSession();
|
|
const events = eventResponse();
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=agent-client"), events.response as never);
|
|
session.updateState({
|
|
nodes: [{ id: "existing-image", type: "image", title: "Existing", position: { x: 0, y: 0 }, width: 320, height: 240, metadata: { generationMode: "image" } }],
|
|
}, "agent-client");
|
|
|
|
try {
|
|
const generated = session.callTool("canvas_generate_video", { prompt: "A short test clip", seconds: "4", vquality: "720" });
|
|
const generateCall = latestToolCall(events.writes());
|
|
const generateOps = (generateCall.input as { ops: Array<Record<string, unknown>> }).ops;
|
|
const target = generateOps.find((op) => op.type === "add_node" && op.nodeType === "video");
|
|
const run = generateOps.find((op) => op.type === "run_generation");
|
|
assert.equal((target?.metadata as Record<string, unknown>)?.vquality, "720");
|
|
assert.deepEqual(run && { type: run.type, nodeId: run.nodeId, mode: run.mode }, { type: "run_generation", nodeId: target?.id, mode: "video" });
|
|
session.resolveResult({ requestId: generateCall.requestId, result: { accepted: true } });
|
|
await generated;
|
|
|
|
const rerun = session.callTool("canvas_run_generation", { nodeId: "existing-image", mode: "image", prompt: "Retry image", retry: true });
|
|
const rerunCall = latestToolCall(events.writes());
|
|
assert.deepEqual(rerunCall.input, { ops: [{ type: "run_generation", nodeId: "existing-image", mode: "image", prompt: "Retry image", retry: true }] });
|
|
session.resolveResult({ requestId: rerunCall.requestId, result: { accepted: true } });
|
|
await rerun;
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|
|
|
|
function fixtureConfig(): LocalRuntimeConfig {
|
|
return {
|
|
url: endpoint,
|
|
token,
|
|
ownerId: "owner-canvas-fixture-001",
|
|
origins: [origin],
|
|
trustedWebOrigins: [origin],
|
|
browserRegistrations: [],
|
|
canvases: {},
|
|
};
|
|
}
|
|
|
|
function sessionFixture(calls: Array<string | { name: string; value?: unknown }>) {
|
|
return {
|
|
health: () => ({ ok: true, hasCanvas: false, clients: 0 }),
|
|
openEvents: (url: URL, res: { status(code: number): unknown; end(): void }) => {
|
|
calls.push({
|
|
name: "events",
|
|
value: { clientId: url.searchParams.get("clientId"), token: url.searchParams.get("token") },
|
|
});
|
|
res.status(204);
|
|
res.end();
|
|
},
|
|
updateState: (value: unknown) => { calls.push({ name: "state", value }); },
|
|
resolveResult: (value: unknown) => { calls.push({ name: "result", value }); },
|
|
emitAll: () => undefined,
|
|
callTool: async (name: unknown, value: unknown) => {
|
|
calls.push({ name: String(name), value });
|
|
return { accepted: true };
|
|
},
|
|
closeRuntimeSession: (sessionId: string) => { calls.push({ name: "revoke", value: sessionId }); },
|
|
dispose: () => { calls.push("dispose"); },
|
|
};
|
|
}
|
|
|
|
function jsonHeaders() {
|
|
return { Host: authority, Origin: origin, "Content-Type": "application/json" };
|
|
}
|
|
|
|
function request(
|
|
server: Server,
|
|
options: { method?: string; path: string; headers: Record<string, string>; body?: string },
|
|
) {
|
|
const address = server.address();
|
|
assert(address && typeof address === "object");
|
|
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
|
const req = http.request({
|
|
hostname: "127.0.0.1",
|
|
port: address.port,
|
|
method: options.method ?? "GET",
|
|
path: options.path,
|
|
headers: options.headers,
|
|
}, (res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
res.on("end", () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") }));
|
|
});
|
|
req.once("error", reject);
|
|
if (options.body) req.write(options.body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function eventResponse() {
|
|
const writes: string[] = [];
|
|
const response = new EventEmitter() as EventEmitter & {
|
|
writeHead(): void;
|
|
write(chunk: unknown): void;
|
|
end(): void;
|
|
};
|
|
let ended = 0;
|
|
response.writeHead = () => undefined;
|
|
response.write = (chunk) => { writes.push(String(chunk)); };
|
|
response.end = () => { ended += 1; };
|
|
return { response, ended: () => ended, writes: () => [...writes] };
|
|
}
|
|
|
|
function latestToolCall(writes: string[]) {
|
|
const event = [...writes].reverse().find((value) => value.startsWith("event: tool_call\n"));
|
|
assert.ok(event);
|
|
const data = event.split("\n").find((line) => line.startsWith("data: "));
|
|
assert.ok(data);
|
|
return JSON.parse(data.slice("data: ".length)) as { requestId: string; name: string; input: unknown };
|
|
}
|
|
|
|
function listening(server: Server) {
|
|
if (server.listening) return Promise.resolve();
|
|
return new Promise<void>((resolve, reject) => {
|
|
server.once("listening", resolve);
|
|
server.once("error", reject);
|
|
});
|
|
}
|
|
|
|
function close(server: Server) {
|
|
return new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
|
|
test("CanvasSession rejects stale state revisions and accepts idempotent retries", () => {
|
|
const session = new CanvasSession();
|
|
const first = session.updateState({ projectId: "canvas-1", nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 } }, "fixture");
|
|
assert.equal(first.accepted, true);
|
|
assert.equal(first.revision, 0);
|
|
|
|
const idempotent = session.updateState({ projectId: "canvas-1", nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 0 }, "fixture");
|
|
assert.equal(idempotent.accepted, true);
|
|
assert.equal(idempotent.idempotent, true);
|
|
|
|
const conflict = session.updateState({ projectId: "canvas-1", nodes: [{ id: "n-1", type: "text", position: { x: 0, y: 0 }, width: 100, height: 100 }], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 0 }, "fixture");
|
|
assert.equal(conflict.accepted, false);
|
|
assert.equal(conflict.reason, "revision_conflict");
|
|
|
|
const next = session.updateState({ projectId: "canvas-1", nodes: [{ id: "n-1", type: "text", position: { x: 0, y: 0 }, width: 100, height: 100 }], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 1 }, "fixture");
|
|
assert.equal(next.accepted, true);
|
|
assert.equal(next.revision, 1);
|
|
|
|
const stale = session.updateState({ projectId: "canvas-1", nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 0 }, "fixture");
|
|
assert.equal(stale.accepted, false);
|
|
assert.equal(stale.reason, "stale_revision");
|
|
session.dispose();
|
|
});
|
|
|
|
|
|
test("canvas_apply_ops enforces expected revision and state hash before dispatch", async () => {
|
|
const session = new CanvasSession();
|
|
const events = eventResponse();
|
|
session.openEvents(new URL("http://127.0.0.1/events?clientId=guarded-write"), events.response as never);
|
|
session.updateState({ nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 } }, "guarded-write");
|
|
const context = buildCanvasContext({ nodes: [], connections: [], viewport: { x: 0, y: 0, k: 1 }, revision: 0 });
|
|
try {
|
|
const accepted = session.callTool("canvas_apply_ops", { ops: [], expectedRevision: 0, expectedStateHash: context.stateHash });
|
|
const call = latestToolCall(events.writes());
|
|
session.resolveResult({ requestId: call.requestId, result: { accepted: true } });
|
|
assert.deepEqual(await accepted, { accepted: true });
|
|
|
|
const writeCount = events.writes().length;
|
|
await assert.rejects(
|
|
session.callTool("canvas_apply_ops", { ops: [], expectedRevision: 1 }),
|
|
/revision.*重新读取 canvas_get_context/
|
|
);
|
|
assert.equal(events.writes().length, writeCount);
|
|
await assert.rejects(
|
|
session.callTool("canvas_apply_ops", { ops: [], expectedRevision: 0, expectedStateHash: "bad-hash" }),
|
|
/画布状态已变化.*重新读取 canvas_get_context/
|
|
);
|
|
assert.equal(events.writes().length, writeCount);
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
});
|