chore: commit workspace updates

This commit is contained in:
ddCat
2026-09-14 01:11:56 +08:00
parent 01a2d171f5
commit 9727656b2b
491 changed files with 12826 additions and 72249 deletions

View File

@@ -0,0 +1,194 @@
import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentEvent, CreateAgentRunInput } from "../src/services/api/agent";
// Exercise the production protocol, isolating only transport/storage dependencies.
// Temporary modules avoid mock.module leaking into unrelated Bun test suites.
const dir = mkdtempSync(join(tmpdir(), "agent-reliability-"));
const root = new URL("../src/", import.meta.url);
const requestPath = join(dir, "request.ts");
writeFileSync(requestPath, 'export const apiBaseURL = "https://agent.invalid/api"; export const http = { post: async () => { throw new Error("unexpected POST"); } };');
writeFileSync(join(dir, "agent.ts"), readFileSync(new URL("services/api/agent.ts", root), "utf8")
.replace('"@/services/api/request"', JSON.stringify(requestPath))
.replace('"@/services/api/task-text-stream"', JSON.stringify(new URL("services/api/task-text-stream.ts", root).pathname)));
const api: typeof import("../src/services/api/agent") = await import(join(dir, "agent.ts"));
const transport = await import(requestPath);
const storagePath = join(dir, "storage.ts");
writeFileSync(storagePath, 'export const data = new Map(); export const localForageStorageForScope = () => ({ getItem: async (k) => data.get(k) ?? null, setItem: async (k,v) => {data.set(k,v);}, removeItem: async (k) => {data.delete(k);} });');
writeFileSync(join(dir, "scope.ts"), 'export const getActiveUserScope = () => "test-user";');
writeFileSync(join(dir, "conversations.ts"), readFileSync(new URL("services/cloud-agent-conversations.ts", root), "utf8")
.replace('"@/lib/localforage-storage"', JSON.stringify(storagePath))
.replace('"@/lib/user-scope"', JSON.stringify(join(dir, "scope.ts")))
.replace('"@/lib/markdown-plain-text"', JSON.stringify(new URL("lib/markdown-plain-text.ts", root).pathname)));
const conversations: typeof import("../src/services/cloud-agent-conversations") = await import(join(dir, "conversations.ts"));
const storage = await import(storagePath);
const nativeFetch = globalThis.fetch;
const nativeTimer = globalThis.setTimeout;
let delays: number[] = [];
let stops: Array<() => void> = [];
beforeEach(() => {
delays = [];
storage.data.clear();
// Retain watchdog semantics; accelerate only retry waits, recording real delays.
globalThis.setTimeout = ((fn: TimerHandler, ms?: number, ...args: unknown[]) => {
delays.push(ms ?? 0);
return nativeTimer(fn, ms === 45_000 ? ms : Math.min(ms ?? 0, 1), ...args);
}) as typeof setTimeout;
});
afterEach(() => {
stops.forEach((stop) => stop());
stops = [];
globalThis.fetch = nativeFetch;
globalThis.setTimeout = nativeTimer;
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));
const frame = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
const snapshot = (status: string, extra = {}) => frame("run_snapshot", { id: "run", status, ...extra });
const event = (seq: number) => frame("agent_event", { runId: "run", eventId: `run:${seq}`, seq, type: "tool_completed", payload: { nodeId: "node" } });
const stream = (body: string) => new Response(body, { headers: { "content-type": "text/event-stream" } });
function observe(options: { timeoutMs?: number; after?: number } = {}) {
const events: AgentEvent[] = [];
const statuses: string[] = [];
let finish!: (error?: unknown) => void;
const done = new Promise<unknown>((resolve, reject) => {
const deadline = nativeTimer(() => reject(new Error("subscription did not settle")), 3000);
finish = (error) => { clearTimeout(deadline); resolve(error); };
});
const stop = api.subscribeAgentEvents("run", (e) => {
events.push(e);
if (e.type === "run_status" && !e.payload.cleanupPending && ["completed", "failed", "cancelled"].includes(String(e.payload.status))) finish();
}, { ...options, onError: finish, onConnectionChange: (s) => statuses.push(s) });
stops.push(stop);
return { events, statuses, done, stop, finish };
}
describe("Agent SSE recovery", () => {
it.each([401, 403, 404, 422])("stops immediately on definitive %s", async (status) => {
let requests = 0;
globalThis.fetch = (async () => { requests++; return new Response("", { status }); }) as typeof fetch;
const result = observe();
expect(await result.done).toMatchObject({ status });
expect(requests).toBe(1);
expect(result.statuses.at(-1)).toBe("disconnected");
});
it.each(["", snapshot("running")])("bounds clean EOF retries even with repeated snapshots", async (body) => {
let requests = 0;
globalThis.fetch = (async () => { requests++; return stream(body); }) as typeof fetch;
const result = observe();
expect(await result.done).toBeInstanceOf(Error);
expect(requests).toBe(5);
expect(delays.filter((ms) => ms !== 45_000)).toHaveLength(4);
});
it("resumes the persisted cursor and deduplicates replayed events", async () => {
const cursors: string[] = [];
globalThis.fetch = (async (url, init) => {
const cursor = new URL(String(url)).searchParams.get("after")!;
cursors.push(cursor);
expect((init?.headers as Record<string, string>)["Last-Event-ID"]).toBe(cursor);
if (cursors.length === 2) throw new TypeError("connection reset");
return stream(cursors.length === 1 ? event(1) + snapshot("running") : event(1) + event(2) + snapshot("completed"));
}) as typeof fetch;
const result = observe();
expect(await result.done).toBeUndefined();
expect(cursors).toEqual(["0", "1", "1"]);
expect(result.events.filter((e) => e.seq > 0).map((e) => e.seq)).toEqual([1, 2]);
});
it("waits for durable cleanup after a terminal status", async () => {
let requests = 0;
globalThis.fetch = (async () => stream(snapshot("cancelled", { cleanupPending: ++requests === 1 }))) as typeof fetch;
const result = observe();
expect(await result.done).toBeUndefined();
expect(requests).toBe(2);
expect(result.events.filter((e) => e.type === "run_status").map((e) => e.payload.cleanupPending)).toEqual([true, false]);
});
it("honors Retry-After rather than immediately retrying 429", async () => {
let requests = 0;
globalThis.fetch = (async () => ++requests === 1 ? new Response("", { status: 429, headers: { "retry-after": "60" } }) : stream(snapshot("completed"))) as typeof fetch;
const result = observe();
await result.done;
expect(requests).toBe(2);
expect(delays).toContain(60_000);
});
it("returns long Retry-After waits to the user without retrying early", async () => {
let requests = 0;
globalThis.fetch = (async () => { requests++; return new Response("", { status: 429, headers: { "retry-after": "600" } }); }) as typeof fetch;
const result = observe();
expect(await result.done).toMatchObject({ status: 429 });
expect(requests).toBe(1);
});
it("times out a stalled connection with a bounded retry budget", async () => {
let requests = 0;
globalThis.fetch = (async (_url, init) => {
requests++;
return new Promise<Response>((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(new Error("timeout")), { once: true }));
}) as typeof fetch;
expect(await observe({ timeoutMs: 10 }).done).toBeInstanceOf(Error);
expect(requests).toBe(5);
});
it("unsubscribe aborts the live reader without reporting a run failure", async () => {
let cancelled = false;
globalThis.fetch = (async () => new Response(new ReadableStream({ cancel() { cancelled = true; } }), { headers: { "content-type": "text/event-stream" } })) as typeof fetch;
const result = observe();
await new Promise((resolve) => nativeTimer(resolve, 5));
result.stop();
await new Promise((resolve) => nativeTimer(resolve, 5));
expect(cancelled).toBe(true);
expect(result.statuses).not.toContain("disconnected");
result.finish();
await result.done;
});
});
describe("Agent admission replay and durable pending records", () => {
const input: CreateAgentRunInput = { canvasId: "canvas", prompt: "generate", model: "original", idempotencyKey: "stable-key-123" };
it("retries with a frozen original body/key and a bounded HTTP timeout", async () => {
const bodies: unknown[] = [];
const mutable = { ...input };
transport.http.post = async (_path: string, body: unknown, options: unknown) => {
bodies.push(structuredClone(body));
expect(options).toEqual({ timeout: 15_000 });
mutable.model = "changed";
if (bodies.length < 3) throw { retryable: true };
return { run: { id: "accepted" } };
};
expect(await api.createAgentRun(mutable)).toMatchObject({ run: { id: "accepted" } });
expect(bodies).toEqual([input, input, input]);
});
it("limits transport failures to three attempts, but never retries admission rejection", async () => {
let attempts = 0;
transport.http.post = async () => { attempts++; throw { retryable: true }; };
await expect(api.sendAgentMessage("parent", input)).rejects.toMatchObject({ retryable: true });
expect(attempts).toBe(3);
attempts = 0;
transport.http.post = async () => { attempts++; throw { status: 400, retryable: false }; };
await expect(api.createAgentRun(input)).rejects.toMatchObject({ status: 400 });
expect(attempts).toBe(1);
});
it("restores the full original request and message identity, isolated by conversation", async () => {
const pending = { fingerprint: "fingerprint", key: input.idempotencyKey, request: input, parentRunId: "parent", messageId: "bubble" };
await conversations.saveCloudAgentPendingSubmission("canvas", "conversation", pending);
expect(await conversations.loadCloudAgentPendingSubmission("canvas", "conversation")).toEqual(pending);
expect(await conversations.loadCloudAgentPendingSubmission("canvas", "other")).toBeNull();
await conversations.clearCloudAgentPendingSubmission("canvas", "conversation");
expect(await conversations.loadCloudAgentPendingSubmission("canvas", "conversation")).toBeNull();
});
it.each(["{broken", "null", "{}", JSON.stringify({ fingerprint: "f", key: input.idempotencyKey, request: { ...input, idempotencyKey: "wrong-key" } })])("fails closed on corrupt pending recovery data", async (raw) => {
await conversations.saveCloudAgentPendingSubmission("canvas", "conversation", { fingerprint: "f", key: input.idempotencyKey });
const key = [...storage.data.keys()][0];
storage.data.set(key, raw);
await expect(conversations.loadCloudAgentPendingSubmission("canvas", "conversation")).rejects.toThrow("已暂停本对话发送");
expect(storage.data.get(key)).toBe(raw);
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "bun:test";
import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation";
describe("Agent approval presentation", () => {
const args = JSON.stringify({ snapshotHash: "private-hash", ops: [
{ type: "add_node", title: "开场分镜", content: "private script" },
{ type: "update_node", title: "结尾台词", content: "private dialogue" },
] });
it("summarizes a persisted approval without exposing tool names or content", () => {
const view = agentApprovalPresentation({ call: { function: { name: "canvas_apply_ops", arguments: args } } });
expect(view.title).toBe("确认画布修改");
expect(view.description).toContain("新增 1 个节点,修改 1 个节点");
expect(view.items).toEqual(["开场分镜", "结尾台词"]);
expect(JSON.stringify(view)).not.toContain("private");
expect(JSON.stringify(view)).not.toContain("canvas_apply_ops");
});
it("summarizes an event approval and media request", () => {
expect(agentApprovalPresentation({ toolName: "canvas_apply_ops", arguments: JSON.parse(args) }).items).toEqual(["开场分镜", "结尾台词"]);
expect(agentApprovalPresentation({ toolName: "generate_media", arguments: { mode: "video" } }).title).toBe("确认生成视频");
});
it("uses a safe fallback for malformed arguments", () => {
expect(agentApprovalPresentation({ toolName: "canvas_apply_ops", arguments: "{" }).description).toContain("修改当前画布");
});
it("shows approved media specifications and references without exposing prompts or IDs", () => {
const view = agentApprovalPresentation({ toolName: "generate_media", arguments: {
mode: "video", title: "镜头1", durationSeconds: 12, size: "9:16", quality: "720p",
videoGenerateAudio: false, referenceNodeIds: ["private-cat", "private-hero"], prompt: "private prompt",
} });
expect(view.description).toContain("结果自动回写画布");
expect(view.items).toEqual(["节点:镜头1", "引用 2 个画布资产,并建立连线", "时长:12 秒", "画幅:9:16", "质量:720p", "音频:关闭"]);
expect(JSON.stringify(view)).not.toContain("private");
});
it("summarizes reference connections as canvas edits without generation", () => {
const view = agentApprovalPresentation({ toolName: "canvas_apply_ops", arguments: { ops: [
{ type: "add_node", title: "视频节点" }, { type: "connect_nodes" }, { type: "connect_nodes" },
] } });
expect(view.description).toContain("新增 1 个节点,建立 2 条引用连线");
expect(view.description).not.toContain("提交任务");
});
it("shows the real catalog model and states that a draft is not a submitted task", () => {
const view = agentApprovalPresentation({ modelName: "用户选择的模型", toolName: "generate_media", arguments: { mode: "video", channelModelKey: "internal-key", size: "16:9" } });
expect(view.items).toContain("模型:用户选择的模型");
expect(view.items).toContain("画幅:16:9");
expect(view.description).toContain("尚未提交生成");
expect(view.description).toContain("拒绝则保留草稿");
});
});

View File

@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { agentCanvasActions, agentCanvasActionLabel } from "@/lib/canvas/agent-canvas-actions";
test("persisted node actions retain human-readable titles without a live canvas", () => {
const detail = JSON.parse(JSON.stringify({ actions: [
{ action: "created", nodeId: "video", title: "满月动画", nodeType: "video" },
{ action: "referenced", nodeId: "image", title: "满月照片", nodeType: "image" },
] }));
expect(agentCanvasActions("canvas_apply_ops", detail).map(agentCanvasActionLabel)).toEqual(["创建了视频节点《满月动画》", "引用了图片节点《满月照片》"]);
});
test("legacy generation failures retain node name and never claim creation or reference success", () => {
const detail = { eventType: "tool_failed", arguments: JSON.stringify({ nodeId: "video", title: "满月动画", mode: "video", referenceNodeIds: ["image"] }), result: { phase: "admission", taskSubmitted: false } };
expect(agentCanvasActions("generate_media", detail).map(agentCanvasActionLabel)).toEqual(["生成未完成:视频节点《满月动画》"]);
});
test("malformed historical arguments do not break the conversation", () => {
expect(agentCanvasActions("generate_media", { arguments: "{" })).toEqual([]);
});

View File

@@ -0,0 +1,69 @@
import { expect, test } from "bun:test";
import { applyAgentCanvasPatch, mergeAgentCanvasEditor, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
const node: CanvasNodeData = { id: "video-1", title: "满月动画", type: CanvasNodeType.Video, position: { x: 10, y: 20 }, width: 320, height: 180, metadata: { status: "loading", taskId: "task-1", taskStatus: "running", prompt: "原始提示词" } };
const project: CanvasProject = { id: "canvas", title: "画布", nodes: [node], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 12, y: 24, k: 1 }, createdAt: "2026-09-13", updatedAt: "2026-09-13" };
const failed: CanvasNodeData = { ...node, metadata: { ...node.metadata, status: "error", taskStatus: "failed", errorDetails: "上游拒绝" } };
const patch: AgentCanvasPatch = { canvasId: project.id, updatedAt: "2026-09-13T12:00:00Z", nodes: [{ before: node, after: failed }], connections: [] };
test("failed Agent task patches only its node and unlocks generation", () => {
expect(isCanvasNodeGenerating(node)).toBe(true);
const result = applyAgentCanvasPatch(project, patch);
expect(result.nodes[0]).toEqual(failed);
expect(isCanvasNodeGenerating(result.nodes[0])).toBe(false);
expect(result.viewport).toBe(project.viewport);
expect(result.connections).toBe(project.connections);
expect(project.nodes[0].metadata?.status).toBe("loading");
});
test("unrelated local edits and editor-only dragging survive completion", () => {
const local = { ...node, position: { x: 800, y: 400 }, metadata: { ...node.metadata, prompt: "正在编辑的新提示词" } };
const merged = mergeAgentCanvasEditor(project, applyAgentCanvasPatch(project, patch), [local], []);
expect(merged.nodes[0].position).toEqual(local.position);
expect(merged.nodes[0].metadata?.prompt).toBe("正在编辑的新提示词");
expect(merged.nodes[0].metadata?.status).toBe("error");
});
test("replay is idempotent and cannot roll a completed node back to loading", () => {
const result = applyAgentCanvasPatch(project, patch);
expect(applyAgentCanvasPatch(result, patch)).toBe(result);
const old: AgentCanvasPatch = { ...patch, nodes: [{ before: { ...node, metadata: { status: "idle" } }, after: node }] };
expect(() => applyAgentCanvasPatch(result, old)).toThrow("冲突");
expect(result.nodes[0].metadata?.status).toBe("error");
});
test("new nodes and reference connections are applied atomically", () => {
const draft = { ...node, id: "video-2", metadata: { status: "idle" as const } };
const edge = { id: "edge", fromNodeId: node.id, toNodeId: draft.id };
const result = applyAgentCanvasPatch(project, { ...patch, nodes: [{ before: null, after: draft }], connections: [{ before: null, after: edge }] });
expect(result.nodes[0]).toBe(node);
expect(result.nodes[1]).toEqual(draft);
expect(result.connections).toEqual([edge]);
});
test("deleted nodes and a newer task binding are not overwritten", () => {
expect(() => applyAgentCanvasPatch({ ...project, nodes: [] }, patch)).toThrow("冲突");
// A replacement task can have the same running status; its task identity is a fence.
const replacement = { ...node, metadata: { ...node.metadata, taskId: "task-2" } };
expect(() => applyAgentCanvasPatch({ ...project, nodes: [replacement] }, patch)).toThrow();
});
test("task state covers Agent, manual submissions and historical terminal tasks", () => {
for (const status of ["queued", "running"]) expect(isCanvasNodeGenerating({ ...node, metadata: { status: "idle", taskId: "task", taskStatus: status } })).toBe(true);
for (const status of ["succeeded", "failed", "cancelled"]) expect(isCanvasNodeGenerating({ ...node, metadata: { status: "loading", taskId: "old", taskStatus: status } })).toBe(false);
expect(isCanvasNodeGenerating({ ...node, metadata: { status: "loading" } })).toBe(true);
expect(isCanvasNodeGenerating({ ...node, metadata: { status: "success", taskId: "historical" } })).toBe(false);
expect(isCanvasNodeGenerating({ ...node, metadata: { status: "idle" } }, node.id)).toBe(true);
});
test("task progress ahead of the server checkpoint still accepts terminal state", () => {
const queued = { ...node, metadata: { ...node.metadata, taskStatus: "queued", taskProgress: 0 } };
const running = { ...node, metadata: { ...node.metadata, taskProgress: 67 } };
const result = applyAgentCanvasPatch({ ...project, nodes: [running] }, { ...patch, nodes: [{ before: queued, after: failed }] });
expect(result.nodes[0].metadata?.taskStatus).toBe("failed");
expect(isCanvasNodeGenerating(result.nodes[0])).toBe(false);
});

View File

@@ -0,0 +1,140 @@
import { afterEach, expect, spyOn, test } from "bun:test";
import { http } from "@/services/api/request";
import { applyAgentCanvasPatches, initializeRemoteUserDataSession, refreshCanvasAfterAgent, resetRemoteUserDataSync, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType } from "@/types/canvas";
const initial: CanvasProject = { id: "agent-test", title: "测试", createdAt: "2026-01-01", updatedAt: "2026-01-01", nodes: [], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 } };
const remote: CanvasProject = { ...initial, nodes: [{ id: "text-1", type: CanvasNodeType.Text, title: "剧本", position: { x: 20, y: 20 }, width: 320, height: 240, metadata: { content: "正文" } }] };
let unsubscribe = () => {};
let restore = () => {};
afterEach(() => { unsubscribe(); restore(); resetRemoteUserDataSync(); });
async function setup(project: CanvasProject = remote) {
useCanvasStore.setState({ projects: [initial] });
await initializeRemoteUserDataSession("agent-user");
const mock = spyOn(http, "get").mockResolvedValue({ project });
restore = () => mock.mockRestore();
return mock;
}
test("successful Agent refresh projects nodes into the open editor and store", async () => {
await setup();
let editor = initial;
unsubscribe = subscribeAgentCanvasRefresh((project, previous) => {
expect(previous).toEqual(initial);
editor = project;
});
await refreshCanvasAfterAgent(initial.id);
expect(editor.nodes).toEqual(remote.nodes);
expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
});
test("unsaved editor conflicts preserve store and acknowledged baseline", async () => {
await setup();
unsubscribe = subscribeAgentCanvasRefresh(() => { throw new Error("本地编辑冲突"); });
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地编辑冲突");
expect(useCanvasStore.getState().projects[0]).toEqual(initial);
unsubscribe();
await refreshCanvasAfterAgent(initial.id);
expect(useCanvasStore.getState().projects[0]).toEqual(remote);
});
test("dirty persisted edits are not replaced or delivered to the editor", async () => {
await setup();
useCanvasStore.setState({ projects: [{ ...initial, title: "本地修改" }] });
let notified = false;
unsubscribe = subscribeAgentCanvasRefresh(() => { notified = true; });
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地存在未同步编辑");
expect(notified).toBe(false);
expect(useCanvasStore.getState().projects[0].title).toBe("本地修改");
});
test("automatic focus and panning do not block Agent updates or reset the local viewport", async () => {
await setup();
const viewport = { x: -800, y: -400, k: 0.7 };
useCanvasStore.setState({ projects: [{ ...initial, viewport, updatedAt: "2026-09-13" }] });
let editor = initial;
unsubscribe = subscribeAgentCanvasRefresh((project) => { editor = project; });
await refreshCanvasAfterAgent(initial.id);
expect(editor.nodes).toEqual(remote.nodes);
expect(editor.viewport).toEqual(viewport);
expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport);
});
test("replayed completion events do not reapply unchanged canvas nodes", async () => {
await setup();
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries += 1; });
await refreshCanvasAfterAgent(initial.id);
await refreshCanvasAfterAgent(initial.id);
expect(deliveries).toBe(1);
});
test("media admission and completion refresh the editor with references and result", async () => {
const video = { id: "video-1", type: CanvasNodeType.Video, title: "镜头1", position: { x: 500, y: 20 }, width: 360, height: 640, metadata: { taskId: "task-1", status: "loading" as const, referenceNodeIds: ["image-1"] } };
const project: CanvasProject = { ...remote, nodes: [...remote.nodes, { ...video, id: "image-1", type: CanvasNodeType.Image }, video], connections: [{ id: "edge-1", fromNodeId: "image-1", toNodeId: "video-1" }] };
const mock = await setup(project);
let editor = initial;
unsubscribe = subscribeAgentCanvasRefresh((updated) => { editor = updated; });
await refreshCanvasAfterAgent(initial.id);
expect(editor.connections).toEqual(project.connections);
expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.referenceNodeIds).toEqual(["image-1"]);
const complete: CanvasProject = { ...project, nodes: project.nodes.map((node) => node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node) };
mock.mockResolvedValue({ project: complete });
await refreshCanvasAfterAgent(initial.id);
expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.storageKey).toBe("resource:video-result");
expect(useCanvasStore.getState().projects[0].nodes).toEqual(complete.nodes);
});
test("incremental batches update the editor once without full canvas requests", async () => {
const get = await setup();
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
const patch = { canvasId: initial.id, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
await applyAgentCanvasPatches(initial.id, [patch, patch]);
expect(get).not.toHaveBeenCalled();
expect(deliveries).toBe(1);
expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
});
test("already projected deltas still advance the remote baseline", async () => {
await setup();
useCanvasStore.setState({ projects: [remote] });
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
await applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }]);
expect(deliveries).toBe(0);
await expect(refreshCanvasAfterAgent(initial.id)).resolves.toEqual(remote);
expect(deliveries).toBe(0);
});
test("editor rejection keeps delta projection atomic and retryable", async () => {
await setup();
const patch = { canvasId: initial.id, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
unsubscribe = subscribeAgentCanvasRefresh(() => { throw new Error("编辑冲突"); });
await expect(applyAgentCanvasPatches(initial.id, [patch])).rejects.toThrow("编辑冲突");
expect(useCanvasStore.getState().projects[0]).toEqual(initial);
unsubscribe();
await applyAgentCanvasPatches(initial.id, [patch]);
expect(useCanvasStore.getState().projects[0].nodes).toEqual(remote.nodes);
});
test("50 distinct media completions project once and keep the local viewport", async () => {
const get = await setup();
const nodes = Array.from({ length: 50 }, (_, index) => ({ id: `video-${index}`, title: `动画${index}`, type: CanvasNodeType.Video, position: { x: index * 400, y: 100 }, width: 320, height: 180, metadata: { status: "loading" as const, taskId: `task-${index}`, taskStatus: "running" } }));
const pending = { ...initial, nodes };
useCanvasStore.setState({ projects: [pending] });
await initializeRemoteUserDataSession("agent-user");
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
await applyAgentCanvasPatches(initial.id, nodes.map((before, index) => ({ canvasId: initial.id, updatedAt: initial.updatedAt, nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? "error" as const : "success" as const, taskStatus: index % 2 ? "failed" : "succeeded" } } }], connections: [] })));
const result = useCanvasStore.getState().projects[0];
expect(result.nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(25);
expect(result.nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(25);
expect(result.viewport).toEqual(initial.viewport);
expect(deliveries).toBe(1);
expect(get).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,73 @@
import { expect, mock, test } from "bun:test";
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import type { AgentEvent } from "@/services/api/agent";
const sleep = (ms = 15) => new Promise((resolve) => setTimeout(resolve, ms));
const event = (type: string, payload: Record<string, unknown> = {}): AgentEvent => ({ eventId: type, runId: "run", seq: 1, type, payload, createdAt: "2026-09-13" });
const delta = event("canvas_updated", { canvasId: "canvas", canvasPatch: { canvasId: "canvas", nodes: [], connections: [], updatedAt: "2026-09-13" } });
test("50 concurrent node completions are one local batch with zero full-canvas GETs", async () => {
const applyPatches = mock(async () => {}), refresh = mock(async () => {}), onError = mock(() => {});
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches, refresh, onError, batchMs: 0 });
for (let i = 0; i < 50; i++) { sync.receive(delta); sync.receive(event("tool_failed", { toolName: "generate_media" })); sync.receive(event("run_failed")); }
await sleep(); sync.dispose();
expect(applyPatches).toHaveBeenCalledTimes(1);
expect((applyPatches.mock.calls[0] as unknown as [unknown[]])[0]).toHaveLength(50);
expect(refresh).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
test("legacy success/failure bursts coalesce into one snapshot", async () => {
const refresh = mock(async () => {});
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => {}, refresh, onError: () => {}, batchMs: 0 });
for (let i = 0; i < 50; i++) { sync.receive(event("tool_failed")); sync.receive(event("run_failed")); sync.receive(event("tool_completed", { toolName: "generate_media" })); }
await sleep(); sync.dispose();
expect(refresh).toHaveBeenCalledTimes(1);
});
test("in-flight refreshes never overlap; later invalidations produce one trailing refresh", async () => {
let release!: () => void;
let active = 0, peak = 0;
const refresh = mock(async () => { peak = Math.max(peak, ++active); await new Promise<void>((resolve) => { release = resolve; }); active--; });
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => {}, refresh, onError: () => {}, batchMs: 0, refreshIntervalMs: 0 });
sync.reconcile(); await sleep();
for (let i = 0; i < 50; i++) sync.reconcile();
expect(refresh).toHaveBeenCalledTimes(1);
release(); await sleep();
expect(refresh).toHaveBeenCalledTimes(2);
release(); await sleep(); sync.dispose();
expect(peak).toBe(1);
});
test("conflicting/out-of-order patches reconcile once and surface unresolved local conflicts", async () => {
const error = new Error("本地存在未同步编辑");
const refresh = mock(async () => { throw error; }), onError = mock(() => {});
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => { throw new Error("delta gap"); }, refresh, onError, batchMs: 0 });
sync.receive(delta); await sleep(); sync.dispose();
expect(refresh).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(error);
});
test("cleanup and other-canvas events cannot update the new canvas", async () => {
const applyPatches = mock(async () => {}), refresh = mock(async () => {});
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches, refresh, onError: () => {}, batchMs: 0 });
sync.receive(event("canvas_updated", { ...delta.payload, canvasId: "other" }));
sync.receive(delta); sync.dispose();
await sleep();
expect(applyPatches).not.toHaveBeenCalled(); expect(refresh).not.toHaveBeenCalled();
});
test("fallback snapshots obey the configured minimum refresh interval", async () => {
const times: number[] = [];
const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => {}, refresh: async () => { times.push(Date.now()); }, onError: () => {}, batchMs: 0, refreshIntervalMs: 100 });
sync.reconcile();
await sleep(15);
for (let index = 0; index < 50; index++) sync.reconcile();
await sleep(15);
expect(times).toHaveLength(1);
await sleep(120);
sync.dispose();
expect(times).toHaveLength(2);
expect(times[1] - times[0]).toBeGreaterThanOrEqual(100);
});

View File

@@ -1,48 +0,0 @@
import { afterEach, expect, test } from "bun:test";
import { registerPlugin, unregisterPlugin } from "../src/lib/plugins/plugin-registry";
import { usePluginStore } from "../src/stores/use-plugin-store";
import { listAgentCapabilities, buildAgentPluginOperations } from "../src/services/agent-capabilities";
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
import { normalizeCreativeProposal, creativeProposalOps } from "../src/lib/creation/creative-agent-state";
import { defaultConfig } from "../src/stores/use-config-store";
import { buildCanvasWorkflowOps } from "../src/lib/canvas/canvas-agent-workflow";
const snapshot: CanvasAgentSnapshot = { projectId: "test", title: "test", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
const pluginId = "agent-extension-test";
afterEach(() => { unregisterPlugin(pluginId); usePluginStore.setState({ runtimeStatuses: {} }); });
test("方案与直接工作流均拒绝只有正文的分镜", () => {
const nodes = [{ ref: "shots", kind: "script" as const, title: "10秒分镜", content: "0–2秒近景,2–10秒特写" }];
expect(() => normalizeCreativeProposal({ title: "短剧", summary: "分镜", markdown: "方案", workflow: { nodes, edges: [] }, generationItems: [] }, "p", 1, defaultConfig)).toThrow("script.shots");
expect(() => buildCanvasWorkflowOps({ nodes, edges: [] }, snapshot, defaultConfig)).toThrow("shots");
});
test("专业方案创建可编辑分镜与上下文连线,不伪造画风已选择或收费任务", () => {
const proposal = normalizeCreativeProposal({ title: "短剧", summary: "搭建", markdown: "完整方案", workflow: { nodes: [
{ ref: "style", kind: "styleboard", title: "项目画风" },
{ ref: "story", kind: "story_input", title: "故事", content: "两个人重逢" },
{ ref: "shots", kind: "script", title: "分镜", shots: [{ durationSeconds: 5, videoMotionPrompt: "推近人物", dialogue: "好久不见" }] },
], edges: [{ from: "style", to: "shots" }, { from: "story", to: "shots" }] }, generationItems: [] }, "p", 1, defaultConfig);
const result = applyCanvasAgentOps(snapshot, creativeProposalOps("run", proposal, snapshot, defaultConfig));
expect(result.nodes.find((node) => node.metadata?.workflowKind === "styleboard")?.metadata?.content).toBe("");
const script = result.nodes.find((node) => node.type === "script")!;
expect(script.metadata?.storyboard?.rows[0]?.dialogue).toBe("好久不见");
expect(result.connections.every((edge) => edge.toHandleId === "storyboard:context")).toBe(true);
expect(proposal.generationItems).toHaveLength(0);
});
test("插件注册后可发现和创建真实类型,停用后立即拒绝新调用", () => {
registerPlugin({ manifest: { id: pluginId, name: "测试扩展", version: "1", apiVersion: "yingce.plugin/v1", description: "测试", surfaces: ["node"], permissions: ["canvas.write"], runtime: { web: "declarative" }, contributes: { canvasNodes: [{ id: "test-special-node", label: "专用节点", defaultTitle: "测试", defaultSize: { width: 400, height: 300 }, schema: {}, renderer: "declarative" }] } }, agentActions: [{ id: "create", description: "创建", inputSchema: {}, buildOperations: () => [{ type: "add_node", nodeType: "test-special-node" }] }] });
usePluginStore.setState({ runtimeStatuses: { [pluginId]: "enabled" } });
expect(listAgentCapabilities("专用节点").length).toBeGreaterThan(0);
const ops = buildAgentPluginOperations(pluginId, "create", {}, snapshot);
expect(applyCanvasAgentOps(snapshot, ops).nodes[0]?.type).toBe("test-special-node");
usePluginStore.setState({ runtimeStatuses: { [pluginId]: "disabled" } });
expect(listAgentCapabilities("专用节点")).toHaveLength(0);
expect(() => buildAgentPluginOperations(pluginId, "create", {}, snapshot)).toThrow();
expect(() => applyCanvasAgentOps(snapshot, ops)).toThrow();
});
test("未知节点不能静默降级成文本", () => {
expect(() => applyCanvasAgentOps(snapshot, [{ type: "add_node", nodeType: "missing-plugin-node" }])).toThrow();
});

View File

@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { AgentToolCard } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { canvasThemes } from "@/lib/canvas-theme";
test("completed tools keep an accessible status without a duplicate visual badge", () => {
const html = renderToStaticMarkup(<AgentToolCard title="model_list" text="工具执行成功" detail={{ eventType: "tool_completed" }} theme={canvasThemes.dark} />);
expect(html).toContain("已获取可用模型");
expect(html).toContain('class="sr-only">已完成');
expect(html).not.toContain("agent-tool-label");
});
test("failed tools retain a visible failure label", () => {
const html = renderToStaticMarkup(<AgentToolCard title="model_list" text="无法获取" detail={{ eventType: "tool_failed" }} theme={canvasThemes.light} />);
expect(html).toContain("agent-tool-label");
expect(html).toContain("执行失败");
});
test("long skill references wrap rather than truncate", () => {
const html = renderToStaticMarkup(<AgentToolCard title="skill_read_file" text="工具执行成功" detail={{ eventType: "tool_completed", skillName: "novel-storyboard", path: "references/storyboard-pass.md" }} theme={canvasThemes.dark} />);
expect(html).toContain("references/storyboard-pass.md");
expect(html).toContain("break-words");
expect(html).not.toContain("truncate");
});
test("failed media shows its error and task identifier without hovering", () => {
const html = renderToStaticMarkup(<AgentToolCard title="generate_media" text="上游拒绝该生成规格" detail={{ eventType: "tool_failed", result: { taskId: "task-123" } }} theme={canvasThemes.light} />);
expect(html).toContain(">上游拒绝该生成规格</span>");
expect(html).toContain("任务 ID:task-123");
});
test("canvas action trace is accessible and links to persisted node identities", () => {
const html = renderToStaticMarkup(<AgentToolCard title="canvas_apply_ops" text="画布操作已完成" detail={{ eventType: "canvas_updated", actions: [{ action: "created", nodeId: "video-1", title: "满月动画", nodeType: "video" }, { action: "referenced", nodeId: "image-1", title: "满月照片", nodeType: "image" }] }} theme={canvasThemes.light} onFocusNode={() => {}} />);
expect(html).toContain('data-agent-node-id="video-1"');
expect(html).toContain('aria-label="在画布中定位满月动画"');
expect(html).toContain("创建了视频节点《满月动画》");
expect(html).toContain("引用了图片节点《满月照片》");
expect(html).not.toContain('disabled=""');
});
test("failed media trace retains node title and collapsible upstream details", () => {
const text = "上游拒绝规格:" + "错误详情".repeat(80);
const html = renderToStaticMarkup(<AgentToolCard title="generate_media" text={text} detail={{ eventType: "tool_failed", arguments: JSON.stringify({ nodeId: "video-1", title: "满月动画", mode: "video" }), result: { taskId: "task-failed", taskSubmitted: true } }} theme={canvasThemes.light} onFocusNode={() => {}} />);
expect(html).toContain("生成未完成:视频节点《满月动画》");
expect(html).toContain("查看完整错误详情");
expect(html).toContain("任务 ID:task-failed");
expect(html).toContain("<details");
});

View File

@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export";
test("debug export preserves diagnostics and redacts nested credentials and signed media", () => {
const text = buildAgentDebugExport({
messages: [{ content: "生成失败:长度超限", taskId: "task-1" }],
arguments: JSON.stringify({ prompt: "镜头1", apiKey: "private-key", references: ["https://user:pass@example.com/video?signature=private-signature#secret"] }),
headers: { Authorization: "private-header" },
token: "private-token",
error: "Bearer private-bearer api_key=private-plain data:image/png;base64,private-media",
});
const exported = JSON.parse(text);
expect(exported.format).toBe("canvas-agent-debug");
expect(exported.messages[0].taskId).toBe("task-1");
expect(JSON.parse(exported.arguments).prompt).toBe("镜头1");
expect(text).toContain("https://example.com/video");
expect(text).not.toContain("private-");
expect(text).not.toContain("user:pass");
});

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "bun:test";
import { changeAgentPanelLayout, clampAgentPanelLayout, restoreAgentPanelLayout } from "@/lib/canvas/agent-panel-layout";
import { agentErrorPresentation } from "@/lib/canvas/agent-error-presentation";
import { ApiError } from "@/services/api/request";
const viewport = { width: 1280, height: 800 };
const start = { left: 816, top: 68, width: 448, height: 720 };
describe("Agent window layout", () => {
it("restores the saved size and position", () => {
expect(restoreAgentPanelLayout(JSON.stringify(start), viewport)).toEqual(start);
});
it("starts within the visible viewport instead of an invisible 840px height", () => {
const layout = restoreAgentPanelLayout(null, { width: 1024, height: 650 });
expect(layout.height).toBe(626);
expect(layout.top).toBe(12);
});
it("keeps the bottom/right anchor when resizing from top/left", () => {
const layout = changeAgentPanelLayout(start, "northwest", -120, 60, viewport);
expect(layout).toEqual({ left: 696, top: 128, width: 568, height: 660 });
expect(layout.left + layout.width).toBe(start.left + start.width);
expect(layout.top + layout.height).toBe(start.top + start.height);
});
it("resizes one axis at a time on an edge", () => {
expect(changeAgentPanelLayout(start, "west", -60, 150, viewport)).toEqual({ ...start, left: 756, width: 508 });
expect(changeAgentPanelLayout(start, "north", -60, 150, viewport)).toEqual({ ...start, top: 218, height: 570 });
});
it("limits resize to viewport and minimum readable size", () => {
expect(changeAgentPanelLayout(start, "northwest", -10000, -10000, viewport)).toEqual({ left: 12, top: 12, width: 1252, height: 776 });
const small = changeAgentPanelLayout(start, "northwest", 10000, 10000, viewport);
expect(small.width).toBe(360);
expect(small.height).toBe(420);
});
it("keeps the title bar reachable when dragging beyond the screen", () => {
expect(changeAgentPanelLayout(start, "move", -10000, -10000, viewport)).toEqual({ ...start, left: 12, top: 12 });
expect(changeAgentPanelLayout(start, "move", 10000, 10000, viewport)).toEqual({ ...start, left: 820, top: 68 });
});
it("clamps stale preferences after changing display size", () => {
const layout = clampAgentPanelLayout(start, { width: 320, height: 300 });
expect(layout).toEqual({ width: 296, height: 276, left: 12, top: 12 });
});
it("discards malformed, partial and nonnumeric stored preferences", () => {
for (const raw of ["invalid json", "null", "{}", '{"width":"448"}', '{"left":12,"top":12,"width":1e999,"height":720}']) {
expect(restoreAgentPanelLayout(raw, viewport)).toEqual(restoreAgentPanelLayout(null, viewport));
}
});
});
describe("Agent error semantics", () => {
it("uses HTTP status rather than matching error wording", () => {
expect(agentErrorPresentation(new ApiError("Not found", { status: 404 })).title).toBe("Agent 接口或资源不存在");
expect(agentErrorPresentation(new Error("HTTP 404"))).toEqual({ title: "Agent 执行失败", text: "HTTP 404" });
});
it("does not falsely claim that a disabled model channel is still enabled", () => {
const result = agentErrorPresentation(new ApiError("系统渠道不存在或已停用", { status: 404 }));
expect(result.text).toBe("系统渠道不存在或已停用");
expect(JSON.stringify(result)).not.toContain("没有被停用");
});
it("preserves authentication, quota, and model errors", () => {
for (const status of [401, 403, 429, 500]) {
expect(agentErrorPresentation(new ApiError("真实业务错误", { status }), "请求失败")).toEqual({ title: "请求失败", text: "真实业务错误" });
}
});
it("reports unimplemented endpoints without retry promises", () => {
expect(agentErrorPresentation(new ApiError("Not implemented", { status: 501 })).title).toBe("当前 Agent 能力尚未开放");
});
});

View File

@@ -1,23 +1,23 @@
import { describe, expect, test } from "bun:test";
import { canvasAgentPromptCacheKey, clampOpenAIPromptCacheKey, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH, withOpenAIPromptCacheKey } from "../src/lib/openai-prompt-cache";
import { agentPromptCacheKey, clampOpenAIPromptCacheKey, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH, withOpenAIPromptCacheKey } from "../src/lib/openai-prompt-cache";
describe("Agent prompt cache", () => {
test("同一会话生成稳定且隔离的缓存键", () => {
expect(canvasAgentPromptCacheKey("session-a")).toBe("canvas-agent:session-a");
expect(canvasAgentPromptCacheKey("session-a")).toBe(canvasAgentPromptCacheKey("session-a"));
expect(canvasAgentPromptCacheKey("session-a")).not.toBe(canvasAgentPromptCacheKey("session-b"));
expect(canvasAgentPromptCacheKey(" ")).toBeUndefined();
expect(agentPromptCacheKey("session-a")).toBe("cloud-agent:session-a");
expect(agentPromptCacheKey("session-a")).toBe(agentPromptCacheKey("session-a"));
expect(agentPromptCacheKey("session-a")).not.toBe(agentPromptCacheKey("session-b"));
expect(agentPromptCacheKey(" ")).toBeUndefined();
});
test("缓存键按 OpenAI 上限截断", () => {
const key = clampOpenAIPromptCacheKey(`canvas-agent:${"x".repeat(100)}`);
const key = clampOpenAIPromptCacheKey(`cloud-agent:${"x".repeat(100)}`);
expect(Array.from(key || "")).toHaveLength(OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH);
});
test("Responses 请求体携带缓存键", () => {
const body = withOpenAIPromptCacheKey({ model: "test-model", input: [] }, " canvas-agent:session-a ");
expect(body.prompt_cache_key).toBe("canvas-agent:session-a");
const body = withOpenAIPromptCacheKey({ model: "test-model", input: [] }, " cloud-agent:session-a ");
expect(body.prompt_cache_key).toBe("cloud-agent:session-a");
});
test("空缓存键不会写入 Responses 请求体", () => {

View File

@@ -1,89 +0,0 @@
import { expect, test } from "bun:test";
import { buildAgentStoryboardDraft, buildAgentStoryboardOperations, validateAgentStoryboardCreation } from "../src/lib/canvas/canvas-agent-storyboard";
import { inspectStoryboardReadiness } from "../src/lib/canvas/canvas-storyboard-context";
import { buildCanvasAgentContext } from "../src/lib/canvas/canvas-agent-context";
import { createShortDramaPipeline } from "../src/lib/canvas/canvas-short-drama";
import { createStoryboardRow } from "../src/lib/canvas/canvas-project-domain";
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
import { prepareAnalysisNodeAction } from "../src/lib/plugins/analysis-node-action";
function fixture() {
const pipeline = createShortDramaPipeline({ x: 0, y: 0 });
const script = pipeline.nodes.find((node) => node.type === "script")!;
script.metadata!.storyboard!.rows = [createStoryboardRow(1, { id: "row-1", videoNodeId: "video-1", imageNodeId: "image-1", dialogue: "旧对白" }), createStoryboardRow(2, { id: "row-2" })];
const snapshot: CanvasAgentSnapshot = { projectId: "test", title: "test", nodes: pipeline.nodes, connections: pipeline.connections, selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
return { snapshot, script };
}
test("待拆镜入口保存原始剧本但不伪造已完成的镜头", () => {
const { snapshot } = fixture();
const result = applyCanvasAgentOps(snapshot, buildAgentStoryboardDraft({ title: "旧机来电", prompt: "手机突然响起", x: 500, y: 100 }));
const node = result.nodes.find((item) => item.title === "旧机来电")!;
expect(node.type).toBe("script");
expect(node.metadata?.composerContent).toBe("手机突然响起");
expect(node.metadata?.storyboard?.rows).toEqual([]);
expect(node.metadata?.workflowDescription).toContain("尚未生成");
});
test("Agent 上下文提前指出未选择画风及分镜空行", () => {
const { snapshot, script } = fixture();
const readiness = buildCanvasAgentContext(snapshot).storyboardReadiness;
expect(readiness.canGenerateStoryboard).toBe(false);
expect(readiness.style.ready).toBe(false);
expect(readiness.blockingReason).toContain("画风");
expect(readiness.storyboards.find((item) => item.nodeId === script.id)?.incompleteRowIds).toEqual(["row-1", "row-2"]);
expect(readiness.nextActions.join(" ")).toContain("canvas_apply_style");
});
test("已应用画风与真实分镜写入后,返回实际总时长", () => {
const { snapshot, script } = fixture();
const style = snapshot.nodes.find((node) => node.metadata?.workflowKind === "styleboard")!;
style.metadata = { ...style.metadata, stylePresetId: "preset", content: "电影写实" };
script.metadata!.storyboard!.rows = [2, 2, 3, 2, 1].map((durationSeconds, i) => createStoryboardRow(i + 1, { durationSeconds, videoMotionPrompt: `镜头${i + 1}` }));
const readiness = inspectStoryboardReadiness(snapshot.nodes);
expect(readiness.canGenerateStoryboard).toBe(true);
expect(readiness.storyboards[0]).toMatchObject({ rowCount: 5, populated: true, totalDurationSeconds: 10 });
expect(readiness.nextActions).toEqual([]);
});
test("通用节点创建不能用正文代替真实分镜行", () => {
expect(() => validateAgentStoryboardCreation([{ type: "add_node", nodeType: "script", metadata: { content: "镜头1:0–2秒近景;镜头2:2–4秒特写" } }])).toThrow("真实镜头行");
const { script } = fixture();
expect(() => validateAgentStoryboardCreation([{ type: "add_node", nodeType: "script", metadata: script.metadata }])).toThrow();
});
test("按行修改保留其他镜头及已有生成素材关联", () => {
const { snapshot, script } = fixture();
const next = applyCanvasAgentOps(snapshot, buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "update", rowId: "row-1", patch: { dialogue: "新对白", durationSeconds: 8 } }));
const rows = next.nodes.find((node) => node.id === script.id)!.metadata!.storyboard!.rows;
expect(rows[0]).toMatchObject({ id: "row-1", dialogue: "新对白", durationSeconds: 8, imageNodeId: "image-1", videoNodeId: "video-1" });
expect(rows[1]).toEqual(script.metadata!.storyboard!.rows[1]);
});
test("移除分镜只更新表格,保留行 ID 并重排序号", () => {
const { snapshot, script } = fixture();
const ops = buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "remove", rowId: "row-1" });
expect(ops).toHaveLength(1);
expect(ops[0].type).toBe("update_node");
if (ops[0].type === "update_node") expect(ops[0].metadata?.storyboard?.rows[0]).toMatchObject({ id: "row-2", shotNumber: 1 });
});
test("分镜编辑拒绝伪造结果、未知行和非法时长", () => {
const { snapshot, script } = fixture();
for (const patch of [{ status: "success" }, { videoNodeId: "fake" }, { durationSeconds: -1 }]) {
expect(() => buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "update", rowId: "row-1", patch })).toThrow();
}
expect(() => buildAgentStoryboardOperations(snapshot, { nodeId: script.id, action: "remove", rowId: "missing" })).toThrow();
});
test("分析节点准备复用相同输入且不启动模型", () => {
const { snapshot, script } = fixture();
snapshot.nodes.push({ ...script, id: "source", type: "image", metadata: { storageKey: "resource:image" } });
const action = prepareAnalysisNodeAction("test-analysis", "分析", () => ({}));
const ops = action.buildOperations({ sourceNodeId: "source" }, snapshot);
expect(ops.some((op) => op.type === "run_generation")).toBe(false);
expect(ops.some((op) => op.type === "connect_nodes")).toBe(true);
snapshot.nodes.push({ ...script, id: "analysis", type: "test-analysis" });
snapshot.connections.push({ id: "edge", fromNodeId: "source", toNodeId: "analysis" });
expect(action.buildOperations({ sourceNodeId: "source" }, snapshot)).toEqual([{ type: "select_nodes", ids: ["analysis"] }]);
});

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "bun:test";
import { agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation";
describe("Agent tool presentation", () => {
it("distinguishes media submission from a completed canvas result", () => {
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "generation_task_created" })).toBe("媒体节点已创建,生成任务已提交");
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_completed" })).toBe("生成结果已回写画布节点");
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_failed" })).toBe("媒体生成未完成");
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_failed", result: { phase: "admission", taskSubmitted: false } })).toBe("生成请求未提交");
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_failed", result: { phase: "completion", status: "succeeded" } })).toBe("生成成功,画布回写未完成");
});
it("uses the failure event even without error keywords", () => {
const text = "技能未在本轮启用,或参考文件未包含在固定快照中";
const detail = { eventType: "tool_failed", skillName: "剧本撰写", path: "references/workflow.md" };
expect(agentToolStatus("skill_read_file", text, detail)).toBe("failed");
expect(friendlyAgentToolSummary("skill_read_file", text, detail)).toBe("读取参考资料失败 · 剧本撰写 · references/workflow.md");
});
it("does not interpret successful content containing error words as failure", () => {
const detail = { eventType: "tool_completed", skillName: "剧本撰写", path: "error-handling.md" };
expect(agentToolStatus("skill_read_file", "错误处理资料", detail)).toBe("completed");
expect(friendlyAgentToolSummary("skill_read_file", "错误处理资料", detail)).toBe("已读取参考资料 · 剧本撰写 · error-handling.md");
});
it("distinguishes empty directories, populated directories and file reads", () => {
for (const path of ["", undefined]) {
const detail = { eventType: "tool_completed", skillName: "剧本撰写", path, result: { files: [] as string[] } };
expect(friendlyAgentToolSummary("skill_read_file", "工具执行成功", detail)).toContain("无可读参考文件");
detail.result.files = ["a.md"];
expect(friendlyAgentToolSummary("skill_read_file", "工具执行成功", detail)).toContain("可读参考文件 1 个");
}
});
it("preserves other tools, approval summaries and legacy status fallback", () => {
expect(friendlyAgentToolSummary("canvas_apply_ops", "", undefined, true)).toBe("准备更新画布内容");
expect(friendlyAgentToolSummary("skills_load", "已从用户技能库固定加载 3 个技能")).toBe("已加载 3 个 Skills 技能");
expect(agentToolStatus("canvas_get_state", "失败")).toBe("failed");
expect(friendlyAgentToolSummary("canvas_get_state", "无法访问", { eventType: "tool_failed" })).toBe("获取画布内容失败");
});
});

View File

@@ -31,6 +31,18 @@ describe("backend API request error semantics", () => {
expect(thrown.cause).toBe(axiosError);
});
test("replaces a bare proxy 502 with a user-facing retry message", async () => {
const axiosError = {
isAxiosError: true,
message: "Request failed with status code 502",
response: { status: 502, data: "Bad Gateway", headers: {} },
};
const thrown = await request(Promise.reject(axiosError)).catch((error) => error);
expect(thrown).toBeInstanceOf(ApiError);
expect(thrown).toMatchObject({ status: 502, message: "后端服务暂时不可用,请稍后重试", retryable: true });
});
test("http.get unwraps the same backend envelope", async () => {
const original = apiClient.request;
apiClient.request = (async () => ({

View File

@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { unobscuredCanvasArea, viewportForAgentNodes } from "@/lib/canvas/canvas-viewport";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
const node = (id: string, x: number, y: number): CanvasNodeData => ({ id, type: CanvasNodeType.Text, title: id, position: { x, y }, width: 300, height: 200 });
describe("Agent created node focus", () => {
test("pans to distant nodes without unnecessarily changing scale", () => {
const target = viewportForAgentNodes([node("new", 8000, -4000)], { x: 0, y: 0, k: 1 }, unobscuredCanvasArea({ width: 1200, height: 800 }))!;
expect(target.k).toBe(1);
expect(8150 * target.k + target.x).toBe(600);
expect(-3900 * target.k + target.y).toBe(400);
});
test("fits the whole new batch to the left of a right-side panel", () => {
const area = unobscuredCanvasArea({ width: 1200, height: 800 }, { left: 780, top: 40, right: 1180, bottom: 780 });
expect(area).toEqual({ left: 0, top: 0, right: 780, bottom: 800 });
const nodes = [node("a", 3000, 2000), node("b", 4400, 2700)];
const target = viewportForAgentNodes(nodes, { x: 0, y: 0, k: 1 }, area)!;
expect(target.k).toBeLessThan(1);
for (const item of nodes) {
expect(item.position.x * target.k + target.x).toBeGreaterThanOrEqual(area.left + 55);
expect((item.position.x + item.width) * target.k + target.x).toBeLessThanOrEqual(area.right - 55);
expect(item.position.y * target.k + target.y).toBeGreaterThanOrEqual(area.top + 55);
expect((item.position.y + item.height) * target.k + target.y).toBeLessThanOrEqual(area.bottom - 55);
}
});
test("adapts to panels moved to the left or bottom", () => {
expect(unobscuredCanvasArea({ width: 1200, height: 800 }, { left: 0, top: 0, right: 400, bottom: 800 }).left).toBe(400);
expect(unobscuredCanvasArea({ width: 1200, height: 800 }, { left: 0, top: 500, right: 1200, bottom: 800 }).bottom).toBe(500);
});
test("ignores offscreen overlays and handles fully covered narrow screens", () => {
const size = { width: 375, height: 600 };
const full = { left: 0, top: 0, right: 375, bottom: 600 };
expect(unobscuredCanvasArea(size, { left: 500, top: 0, right: 900, bottom: 600 })).toEqual(full);
expect(unobscuredCanvasArea(size, full)).toEqual(full);
expect(viewportForAgentNodes([], { x: 0, y: 0, k: 1 }, full)).toBeNull();
});
});

View File

@@ -1,37 +0,0 @@
import { describe, expect, test } from "bun:test";
import { CANVAS_ONLINE_AGENT_TOOLS } from "@/lib/canvas/canvas-agent-tools";
import { buildToolAgentMessages } from "@/components/canvas/canvas-assistant-online-tools";
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasAssistantMessage } from "@/types/canvas";
const snapshot: CanvasAgentSnapshot = {
projectId: "canvas",
title: "测试画布",
nodes: [],
connections: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, k: 1 },
};
describe("画布在线创作交互", () => {
test("在线工具清单包含 creative_respond", () => {
expect(CANVAS_ONLINE_AGENT_TOOLS.some((tool) => tool.function.name === "creative_respond")).toBe(true);
});
test("创作请求携带创作指导和结构化上下文", async () => {
const history: CanvasAssistantMessage[] = [{ id: "user-1", role: "user", text: "我想做一个短片" }];
const messages = await buildToolAgentMessages(snapshot, history, { id: "user-2", role: "user", text: "先问我关键需求" }, [], {
creative: {
scene: "short-film",
brief: {},
executionResults: [],
references: [{ id: "asset-1", title: "参考图", kind: "image" }],
},
});
const system = messages.find((message) => message.role === "system");
const user = messages.findLast((message) => message.role === "user");
expect(system?.content).toContain("多步骤创作使用 creative_respond");
expect(user?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "text", text: expect.stringContaining("创作上下文") })]));
});
});

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from "bun:test";
import { getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { canvasAgentPostconditionMessage, type CanvasAgentSnapshot, verifyCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops";
import { getCanvasConnection, getCanvasGenerationTasks, getCanvasNode, validateCanvasOperations } from "@/lib/canvas/canvas-context-discovery";
import { canvasOperationPostconditionMessage, type CanvasSnapshot, verifyCanvasOperations } from "@/lib/canvas/canvas-operation-contract";
import { CanvasNodeType } from "@/types/canvas";
const snapshot: CanvasAgentSnapshot = {
const snapshot: CanvasSnapshot = {
projectId: "canvas-1",
domainProjectId: "project-1",
title: "测试画布",
@@ -17,9 +17,9 @@ const snapshot: CanvasAgentSnapshot = {
viewport: { x: 0, y: 0, k: 1 },
};
describe("validateCanvasAgentOps", () => {
describe("validateCanvasOperations", () => {
it("allows deleting and recreating a connection in one batch", () => {
const result = validateCanvasAgentOps(snapshot, [
const result = validateCanvasOperations(snapshot, [
{ type: "delete_connections", id: "connection-1" },
{ type: "connect_nodes", id: "connection-2", fromNodeId: "prompt-1", toNodeId: "ref-1" },
]);
@@ -27,7 +27,7 @@ describe("validateCanvasAgentOps", () => {
});
it("rejects direct media status forgery", () => {
const result = validateCanvasAgentOps(snapshot, [
const result = validateCanvasOperations(snapshot, [
{ type: "update_node", id: "ref-1", patch: { metadata: { status: "success" } } },
]);
expect(result.ok).toBe(false);
@@ -37,7 +37,7 @@ describe("validateCanvasAgentOps", () => {
describe("precise canvas reads", () => {
it("reads one node with resource and connections", () => {
const result = getCanvasAgentNode(snapshot, { id: "ref-1" });
const result = getCanvasNode(snapshot, { id: "ref-1" });
expect(result.found).toBe(true);
expect(result.node?.id).toBe("ref-1");
expect(result.resource?.ready).toBe(true);
@@ -45,15 +45,15 @@ describe("precise canvas reads", () => {
});
it("reads one connection and returns explicit misses", () => {
const result = getCanvasAgentConnection(snapshot, { id: "connection-1" });
const result = getCanvasConnection(snapshot, { id: "connection-1" });
expect(result.found).toBe(true);
expect(result.connection?.fromTitle).toBe("提示词");
expect(result.toNode?.id).toBe("ref-1");
expect(getCanvasAgentConnection(snapshot, { id: "missing" }).found).toBe(false);
expect(getCanvasConnection(snapshot, { id: "missing" }).found).toBe(false);
});
it("projects generation task observation from node metadata", () => {
const result = getCanvasAgentGenerationTasks(snapshot, {});
const result = getCanvasGenerationTasks(snapshot, {});
expect(result.total).toBe(1);
expect(result.tasks[0]?.taskId).toBe("task-1");
expect(result.tasks[0]?.status).toBe("succeeded");
@@ -63,12 +63,12 @@ describe("precise canvas reads", () => {
describe("canvas write postconditions", () => {
it("confirms created nodes and connections exist after the batch", () => {
const after: CanvasAgentSnapshot = {
const after: CanvasSnapshot = {
...snapshot,
nodes: [...snapshot.nodes, { id: "text-2", type: CanvasNodeType.Text, title: "输出", position: { x: 0, y: 420 }, width: 320, height: 240 }],
connections: [...snapshot.connections, { id: "connection-2", fromNodeId: "ref-1", toNodeId: "text-2" }],
};
const result = verifyCanvasAgentOps(snapshot, after, [
const result = verifyCanvasOperations(snapshot, after, [
{ type: "add_node", id: "text-2", nodeType: CanvasNodeType.Text },
{ type: "connect_nodes", id: "connection-2", fromNodeId: "ref-1", toNodeId: "text-2" },
]);
@@ -78,38 +78,38 @@ describe("canvas write postconditions", () => {
});
it("reports submitted generation without claiming completion", () => {
const before: CanvasAgentSnapshot = {
const before: CanvasSnapshot = {
...snapshot,
nodes: [{ ...snapshot.nodes[0], id: "image-1", type: CanvasNodeType.Image, title: "待生成", metadata: { status: "idle", generationMode: "image" } }],
connections: [],
};
const after: CanvasAgentSnapshot = {
const after: CanvasSnapshot = {
...before,
nodes: [{ ...before.nodes[0], metadata: { ...before.nodes[0].metadata, taskId: "task-2", taskStatus: "running", taskOfficialStatus: "processing" } }],
};
const result = verifyCanvasAgentOps(before, after, [{ type: "run_generation", nodeId: "image-1", mode: "image" }]);
const result = verifyCanvasOperations(before, after, [{ type: "run_generation", nodeId: "image-1", mode: "image" }]);
expect(result.ok).toBe(true);
expect(result.generation[0]?.outcome).toBe("running");
expect(canvasAgentPostconditionMessage(result)).toContain("尚未完成");
expect(canvasOperationPostconditionMessage(result)).toContain("尚未完成");
});
it("fails the tool result when generation failed or was never submitted", () => {
const before: CanvasAgentSnapshot = {
const before: CanvasSnapshot = {
...snapshot,
nodes: [{ ...snapshot.nodes[0], id: "image-2", type: CanvasNodeType.Image, title: "失败生成", metadata: { status: "idle" } }],
connections: [],
};
const failed: CanvasAgentSnapshot = {
const failed: CanvasSnapshot = {
...before,
nodes: [{ ...before.nodes[0], metadata: { status: "error", taskId: "task-3", taskStatus: "failed" } }],
};
const result = verifyCanvasAgentOps(before, failed, [{ type: "run_generation", nodeId: "image-2", mode: "image" }]);
const result = verifyCanvasOperations(before, failed, [{ type: "run_generation", nodeId: "image-2", mode: "image" }]);
expect(result.ok).toBe(false);
expect(canvasAgentPostconditionMessage(result)).toContain("失败");
expect(canvasOperationPostconditionMessage(result)).toContain("失败");
});
it("does not accept a silent update no-op as a successful write", () => {
const result = verifyCanvasAgentOps(snapshot, snapshot, [{ type: "update_node", id: "prompt-1", patch: { title: "新标题" } }]);
const result = verifyCanvasOperations(snapshot, snapshot, [{ type: "update_node", id: "prompt-1", patch: { title: "新标题" } }]);
expect(result.ok).toBe(false);
expect(result.warnings.join(" ")).toContain("未达到预期");
});

View File

@@ -1,346 +0,0 @@
import { expect, test } from "bun:test";
test("Canvas consumes signed fetch SSE without bearer data in the URL or headers", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection").catch(() => ({}));
const consume = (
module as {
consumeLocalRuntimeEventStream?: (client: RuntimeTransport, path: string, options: { lastEventId?: string; onEvent(event: RuntimeEvent): void }) => Promise<void>;
}
).consumeLocalRuntimeEventStream;
expect(typeof consume).toBe("function");
if (!consume) return;
const calls: Array<{ path: string; headers: Headers; method: string }> = [];
const events: RuntimeEvent[] = [];
await consume(
{
async request(path, init = {}) {
calls.push({ path, headers: new Headers(init.headers), method: String(init.method || "GET") });
return sseResponse(['id: 7\nevent: hello\ndata: {"ok":true}\n\n', "id: 8\nevent: agent_log\ndata: first\ndata: second\n\n"]);
},
},
"/events?clientId=client-1",
{
lastEventId: "6",
onEvent: (event) => events.push(event),
},
);
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({ path: "/events?clientId=client-1", method: "GET" });
expect(calls[0].headers.get("last-event-id")).toBe("6");
expect(calls[0].headers.has("authorization")).toBe(false);
expect(calls[0].headers.has("x-canvas-agent-token")).toBe(false);
expect(calls[0].path).not.toContain("token");
expect(events).toEqual([
{ type: "hello", data: '{"ok":true}', id: "7" },
{ type: "agent_log", data: "first\nsecond", id: "8" },
]);
});
test("Canvas SSE rejects oversized events and pre-cancelled requests", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection");
let calls = 0;
const cancelled = new AbortController();
cancelled.abort();
await expect(
module.consumeLocalRuntimeEventStream(
{
request: async () => {
calls++;
return sseResponse([]);
},
},
"/events?clientId=client-1",
{ signal: cancelled.signal, onEvent: () => undefined },
),
).rejects.toMatchObject({ name: "AbortError" });
expect(calls).toBe(0);
await expect(
module.consumeLocalRuntimeEventStream(
{
request: async () => sseResponse([`event: agent_log\ndata: ${"x".repeat(300 * 1024)}\n\n`]),
},
"/events?clientId=client-1",
{ onEvent: () => undefined },
),
).rejects.toMatchObject({ code: "canvas_event_too_large" });
});
test("Canvas SSE accepts one large transport chunk containing many bounded events", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection");
const chunk = Array.from({ length: 3_000 }, (_, index) => `id: ${index}\nevent: agent_log\ndata: ${"x".repeat(80)}\n\n`).join("");
let events = 0;
await module.consumeLocalRuntimeEventStream(
{
request: async () => sseResponse([chunk]),
},
"/events?clientId=client-1",
{
onEvent: () => {
events++;
},
},
);
expect(events).toBe(3_000);
});
test("Canvas Agent UI store contains no Runtime endpoint, bearer, or session fields", async () => {
const { useCanvasAgentStore } = await import("../src/stores/canvas/use-canvas-agent-store");
const state = useCanvasAgentStore.getState();
expect("url" in state).toBe(false);
expect("token" in state).toBe(false);
expect("endpoint" in state).toBe(false);
expect("session" in state).toBe(false);
});
test("Canvas Agent reconnect intent persists while transient disconnects preserve the active conversation", async () => {
const module = await import("../src/stores/canvas/use-canvas-agent-store").catch(() => ({}));
const connectionStartingPatch = (
module as {
canvasAgentConnectionStartingPatch?: () => Record<string, unknown>;
}
).canvasAgentConnectionStartingPatch;
const transientDisconnectPatch = (
module as {
canvasAgentTransientDisconnectPatch?: (activity: string, connectError: string) => Record<string, unknown>;
}
).canvasAgentTransientDisconnectPatch;
const writeEnabled = (
module as {
writeCanvasAgentEnabledPreference?: (enabled: boolean, storage: { setItem(key: string, value: string): void }) => void;
}
).writeCanvasAgentEnabledPreference;
const readEnabled = (
module as {
readCanvasAgentEnabledPreference?: (storage: { getItem(key: string): string | null }) => boolean;
}
).readCanvasAgentEnabledPreference;
expect(typeof connectionStartingPatch).toBe("function");
expect(typeof transientDisconnectPatch).toBe("function");
expect(typeof writeEnabled).toBe("function");
expect(typeof readEnabled).toBe("function");
if (!connectionStartingPatch || !transientDisconnectPatch || !writeEnabled || !readEnabled) return;
const durable = {
messages: [{ id: "message-1", role: "assistant", text: "kept" }],
threads: [{ id: "thread-1", preview: "kept" }],
activeThreadId: "thread-1",
workspacePath: "D:/workspace",
pendingTool: { requestId: "tool-1", name: "canvas_get_state" },
};
const starting = { ...durable, ...connectionStartingPatch() };
expect(starting).toMatchObject({ enabled: true, connected: false, activity: "连接中", ...durable });
const reconnecting = { ...starting, ...transientDisconnectPatch("正在重连", "连接已断开") };
expect(reconnecting).toMatchObject({ enabled: true, connected: false, activity: "正在重连", connectError: "连接已断开", ...durable });
const values = new Map<string, string>();
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
};
writeEnabled(true, storage);
expect(readEnabled(storage)).toBe(true);
writeEnabled(false, storage);
expect(readEnabled(storage)).toBe(false);
});
test("Canvas Agent status shows reconnecting instead of a stale connection failure", async () => {
const module = await import("../src/stores/canvas/use-canvas-agent-store").catch(() => ({}));
const statusText = (
module as {
canvasAgentConnectionStatusText?: (state: { enabled: boolean; connected: boolean; activity: string; connectError: string }) => string;
}
).canvasAgentConnectionStatusText;
expect(typeof statusText).toBe("function");
if (!statusText) return;
expect(statusText({ enabled: true, connected: false, activity: "正在重连", connectError: "连接已断开" })).toBe("正在重连");
expect(statusText({ enabled: true, connected: false, activity: "连接失败", connectError: "首次连接失败" })).toBe("连接失败");
});
test("Canvas connection reuses the shared Runtime store and requires the canvas module", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection");
const prepare = (
module as {
prepareCanvasRuntimeConnection?: (store: RuntimeStore, signal?: AbortSignal) => Promise<void>;
}
).prepareCanvasRuntimeConnection;
expect(typeof prepare).toBe("function");
if (!prepare) return;
let connectCalls = 0;
const state = {
connection: "idle",
modules: [] as Array<{ id: string }>,
async connect() {
connectCalls++;
state.connection = "connected";
state.modules = [{ id: "canvas-agent" }, { id: "dreamina" }];
},
};
await prepare({ getState: () => state });
expect(connectCalls).toBe(1);
expect(state.connection).toBe("connected");
await prepare({ getState: () => state });
expect(connectCalls).toBe(2);
const missingState = {
connection: "connected",
modules: [{ id: "dreamina" }],
error: "",
async connect() {
return;
},
};
await expect(prepare({ getState: () => missingState })).rejects.toMatchObject({ code: "canvas_module_unavailable" });
const failedRefreshState = {
connection: "connected",
modules: [{ id: "canvas-agent" }],
error: "",
async connect() {
failedRefreshState.error = "本机运行时连接超时";
},
};
await expect(prepare({ getState: () => failedRefreshState })).rejects.toMatchObject({ code: "canvas_runtime_unavailable" });
});
test("Canvas launch mode auto-connects after rejected legacy deep-link secrets are removed", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection");
const shouldAutoConnect = (
module as {
shouldAutoConnectCanvasRuntime?: (params: URLSearchParams) => boolean;
}
).shouldAutoConnectCanvasRuntime;
expect(typeof shouldAutoConnect).toBe("function");
if (!shouldAutoConnect) return;
expect(shouldAutoConnect(new URLSearchParams("mode=new"))).toBe(true);
expect(shouldAutoConnect(new URLSearchParams("mode=recent"))).toBe(true);
expect(shouldAutoConnect(new URLSearchParams("mode=choose"))).toBe(true);
expect(shouldAutoConnect(new URLSearchParams("agentUrl=http://127.0.0.1:17371"))).toBe(false);
});
test("Canvas local Agent setup generates exact-origin commands for Unix and Windows", async () => {
const module = await import("../src/lib/canvas/local-agent-setup");
const unix = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "unix");
const windows = module.buildLocalAgentSetupCommands("https://ddcat.pronhubcn.com", "windows");
expect(unix.install).toContain("git clone https://github.com/ddcat-ai/open-ai-canvas.git");
expect(unix.start).toContain("FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'");
expect(unix.start).toContain("node dist/index.js");
expect(windows.start).toContain("$env:FRAMEFIELD_TRUSTED_WEB_ORIGINS='https://ddcat.pronhubcn.com'");
expect(windows.start).toContain("node .\\dist\\index.js");
expect(module.detectLocalAgentSetupPlatform("Windows NT 10.0")).toBe("windows");
expect(module.detectLocalAgentSetupPlatform("Macintosh Intel Mac OS X")).toBe("unix");
});
test("Canvas local Agent tutorial replaces the conversation only while disconnected", async () => {
const source = await Bun.file(new URL("../src/components/canvas/canvas-local-agent-panel.tsx", import.meta.url)).text();
expect(source).toContain('!connected ? (\n <AgentConnectView');
expect(source).toContain("连接成功后,这里会自动恢复原对话");
expect(source).toContain("当前支持范围");
});
test("Canvas reconnect wait resolves quietly when component cleanup aborts", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection");
const waitForReconnect = (
module as {
waitForCanvasRuntimeReconnect?: (signal: AbortSignal, delayMs?: number) => Promise<void>;
}
).waitForCanvasRuntimeReconnect;
expect(typeof waitForReconnect).toBe("function");
if (!waitForReconnect) return;
const controller = new AbortController();
const waiting = waitForReconnect(controller.signal, 60_000);
controller.abort();
await expect(waiting).resolves.toBeUndefined();
});
test("Canvas state sync reports a stable error without exposing Runtime response text", async () => {
const module = await import("../src/lib/canvas/local-runtime-connection");
const postState = (
module as {
postCanvasRuntimeState?: (client: RuntimeTransport, clientId: string, snapshot: unknown) => Promise<void>;
}
).postCanvasRuntimeState;
expect(typeof postState).toBe("function");
if (!postState) return;
const secretResponse = "runtime-secret-response-body";
const calls: Array<{ path: string; headers: Headers; body: string }> = [];
let thrown: unknown;
try {
await postState(
{
async request(path, init = {}) {
calls.push({
path,
headers: new Headers(init.headers),
body: String(init.body || ""),
});
return new Response(secretResponse, { status: 500 });
},
},
"client/1",
{ nodes: [], connections: [] },
);
} catch (error) {
thrown = error;
}
expect(calls).toHaveLength(1);
expect(calls[0].path).toBe("/canvas/state?clientId=client%2F1");
expect(calls[0].headers.get("content-type")).toBe("application/json");
expect(calls[0].headers.has("authorization")).toBe(false);
expect(calls[0].headers.has("x-canvas-agent-token")).toBe(false);
expect(calls[0].body).toBe('{"nodes":[],"connections":[]}');
expect(thrown).toMatchObject({
code: "canvas_state_sync_failed",
message: "画布状态同步失败",
});
expect(JSON.stringify(thrown)).not.toContain(secretResponse);
});
type RuntimeTransport = {
request(path: string, init?: RequestInit): Promise<Response>;
};
type RuntimeEvent = {
type: string;
data: string;
id?: string;
};
function sseResponse(chunks: string[]) {
const encoder = new TextEncoder();
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
controller.close();
},
}),
{ headers: { "content-type": "text/event-stream", "cache-control": "no-store" } },
);
}
type RuntimeStore = {
getState(): {
connection: string;
modules: Array<{ id: string }>;
error?: string;
connect(signal?: AbortSignal): Promise<void>;
};
};

View File

@@ -4,8 +4,23 @@ import { resolve } from "node:path";
import { describe, expect, test } from "bun:test";
const canvasStylesSource = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
const nodeSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node.tsx"), "utf8");
const liveViewportSource = readFileSync(resolve(import.meta.dir, "../src/lib/canvas/canvas-live-viewport.ts"), "utf8");
describe("canvas node title interaction", () => {
test("disables iframe hit testing only during node dragging", () => {
expect(canvasStylesSource).toMatch(/\[data-canvas-node-dragging="true"\] \.node-element iframe\s*\{\s*pointer-events: none;/);
expect(liveViewportSource).toContain('if (preview) container.dataset.canvasNodeDragging = "true"');
expect(liveViewportSource).toContain('else delete container.dataset.canvasNodeDragging');
});
test("exposes a drag handle without bypassing read-only or locked nodes", () => {
expect(nodeSource).toContain('onDragStart={readOnly ? undefined : (event) => onMouseDown(event, data.id)}');
expect(nodeSource).toContain('disabled={!onDragStart || Boolean(node.metadata?.locked)}');
expect(nodeSource).toContain('onDragStart?.(event)');
expect(nodeSource).toContain('event.currentTarget.setPointerCapture(event.pointerId)');
expect(nodeSource).toContain('拖动此处移动节点;点击名称可重命名');
expect(nodeSource).toContain('onClick={onEdit}');
});
test("keeps the toolbar hover bridge from intercepting the external title", () => {
const hoverBridge = canvasStylesSource.match(/\.canvas-node-toolbar::after\s*\{([\s\S]*?)\}/)?.[1] || "";

View File

@@ -22,7 +22,7 @@ describe("canvas resource mention editor", () => {
test("uses stable component classes for inline media references", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
expect(component).toContain('chip.className = "canvas-resource-inline-mention"');
expect(component).toContain("chip.className = `canvas-resource-inline-mention");
expect(component).toContain("canvas-resource-inline-preview is-${reference.kind}");
expect(component).not.toContain("size-[1.18em]");
});
@@ -106,7 +106,7 @@ describe("canvas resource mention editor", () => {
});
test("agent composer attachments stay large, previewable, and mentionable", () => {
const component = source("../src/components/canvas/canvas-agent-chat-ui.tsx");
const component = source("../src/components/canvas/canvas-cloud-agent-chat-ui.tsx");
expect(component).toContain("w-20 shrink-0");
expect(component).toContain("insertAttachmentMention");
expect(component).toContain("@[attachment:");
@@ -114,4 +114,28 @@ describe("canvas resource mention editor", () => {
expect(component).toContain("composerReferences");
expect(component).toContain("点击放大预览");
});
test("agent composer renders slash skill references as stable skill chips and consumes the typed slash query", () => {
const component = source("../src/components/canvas/canvas-cloud-agent-chat-ui.tsx");
expect(component).toContain("const token = `@[skill:${skill.skillId}] `");
expect(component).toContain("slash.start + 1 + slash.query.length");
expect(component).toContain("buildSkillMentionReferences(availableSlashSkills)");
});
test("skill chips use one colored icon instead of exposing the serialized token", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
const chat = source("../src/components/canvas/canvas-cloud-agent-chat-ui.tsx");
const css = source("../src/components/canvas/canvas-cloud-agent.css");
expect(component).toContain('chip.className = `canvas-resource-inline-mention ${reference.kind === "skill" ? "is-skill" : ""}`');
expect(component).toContain('prefix.textContent = reference.kind === "skill" ? "✦" : "@"');
expect(component).toContain('if (reference.kind !== "skill") chip.appendChild(createInlinePreview(reference));');
expect(component).toContain('chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference))');
expect(chat).toContain("sendOnEnter={false}");
expect(chat).toContain("agent-composer-resize-handle");
expect(chat).toContain("Enter 换行 · ⌘/Ctrl+Enter 发送");
expect(css).toContain(".agent-composer-prompt-scroll");
expect(css).not.toContain(".agent-tool-row:hover");
});
});

View File

@@ -55,5 +55,6 @@ describe("canvas skill mentions", () => {
const active = skill();
const inactive = skill({ skillId: "skill-2", skillName: "未加入", isAdded: false });
expect(resolveSkillMentions("请使用 @镜头拆解,但不要使用 @未加入。", [active, inactive]).map((item) => item.skillId)).toEqual(["skill-1"]);
expect(resolveSkillMentions("请使用 /镜头拆解 完成任务。", [active, inactive]).map((item) => item.skillId)).toEqual(["skill-1"]);
});
});

View File

@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, test } from "bun:test";
const source = (name: string) => readFileSync(resolve(import.meta.dir, `../src/pages/canvas/${name}`), "utf8");
describe("storyboard prompt panel boundaries", () => {
test("creation and duplication clear the previous node panel", () => {
const operations = source("use-canvas-node-operations.ts");
expect(operations).toContain("if (type === CanvasNodeType.Script) setDialogNodeId(null)");
expect(operations).toContain("if (source.type === CanvasNodeType.Script) setDialogNodeId(null)");
expect(operations).toContain("primaryNode.type !== CanvasNodeType.Script ? primaryNode.id : null");
});
test("single and batch connection creation clear the previous panel", () => {
const connections = source("use-canvas-connection-controller.ts");
expect(connections.match(/if \(nodeType === CanvasNodeType.Script\) setDialogNodeId\(null\)/g)).toHaveLength(2);
});
test("focusing a storyboard does not open a panel", () => {
expect(source("use-canvas-viewport-controller.ts")).toContain("node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Script ? null : node.id");
});
test("click and overlay guards remain while the storyboard editor stays available", () => {
const project = source("project.tsx");
expect(project).toMatch(/else if \(node.type === CanvasNodeType.Script\) \{\s*setDialogNodeId\(null\)/);
expect(project).toContain("dialogNode.type !== CanvasNodeType.Script");
expect(project).toContain("onOpen={() => setScriptEditorNodeId(contentNode.id)}");
expect(project).toContain("onGenerateScript={(prompt) => void generateScriptRows(contentNode.id, prompt)}");
});
});

View File

@@ -0,0 +1,44 @@
import { expect, test } from "bun:test";
import { connectCanvasTextMention } from "@/lib/canvas/canvas-text-mention";
import { buildNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
const nodes: CanvasNodeData[] = [
{ id: "text", type: CanvasNodeType.Text, title: "镜头正文", position: { x: 0, y: 0 }, width: 300, height: 200, metadata: { content: "清晨,人物推开木门" } },
{ id: "video", type: CanvasNodeType.Video, title: "镜头1", position: { x: 500, y: 0 }, width: 300, height: 200 },
];
test("selecting an unconnected text mention connects and expands its content for video generation", () => {
const linked = connectCanvasTextMention(nodes, [], "video", "text", "edge");
expect(linked.connections).toEqual([{ id: "edge", fromNodeId: "text", toNodeId: "video" }]);
expect(linked.reference.active).toBe(true);
const prompt = `${canvasResourceMentionToken(linked.reference)} 缓慢运镜`;
const context = buildNodeGenerationContext("video", linked.nodes, linked.connections, prompt, []);
expect(context.prompt).toContain("清晨,人物推开木门");
expect(context.prompt).toContain("缓慢运镜");
expect(connectCanvasTextMention(linked.nodes, linked.connections, "video", "text", "duplicate").connections).toHaveLength(1);
});
test("text mentions follow a connected Config and reject cycles, self references and locked targets", () => {
const config: CanvasNodeData = { ...nodes[1], id: "config", type: CanvasNodeType.Config };
const other: CanvasNodeData = { ...nodes[0], id: "other" };
const linked = connectCanvasTextMention([...nodes, config, other], [{ id: "config-edge", fromNodeId: "video", toNodeId: "config" }, { id: "existing", fromNodeId: "other", toNodeId: "config" }], "video", "text", "edge");
expect(linked.connections[2].toNodeId).toBe("config");
expect(linked.reference.label).toBe("文本2");
expect(linked.reference.nodeId).toBe("text");
expect(() => connectCanvasTextMention(nodes, [{ id: "reverse", fromNodeId: "video", toNodeId: "text" }], "video", "text", "edge")).toThrow("循环");
expect(() => connectCanvasTextMention(nodes, [], "text", "text", "edge")).toThrow();
expect(() => connectCanvasTextMention(nodes.map((node) => node.id === "video" ? { ...node, metadata: { locked: true } } : node), [], "video", "text", "edge")).toThrow();
});
test("appending a text reference preserves existing mention numbering with an empty Config", () => {
const other: CanvasNodeData = { ...nodes[0], id: "other", metadata: { content: "原有文本" } };
const config: CanvasNodeData = { ...nodes[1], id: "config", type: CanvasNodeType.Config };
const linked = connectCanvasTextMention([...nodes, other, config], [{ id: "old", fromNodeId: "other", toNodeId: "video" }, { id: "config", fromNodeId: "video", toNodeId: "config" }], "video", "text", "new");
expect(linked.connections[2].toNodeId).toBe("video");
expect(linked.reference.label).toBe("文本2");
const context = buildNodeGenerationContext("video", linked.nodes, linked.connections, "@文本1 @文本2", []);
expect(context.prompt).toContain("原有文本");
expect(context.prompt).toContain("清晨,人物推开木门");
});

View File

@@ -1,14 +1,14 @@
import { describe, expect, it } from "bun:test";
import { buildCanvasWorkflowOps, looksLikeWorkflowRequest } from "@/lib/canvas/canvas-agent-workflow";
import { applyCanvasAgentOps, canvasAgentPostconditionMessage, verifyCanvasAgentOps, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { extractCanvasAgentQuickActions } from "@/components/canvas/canvas-agent-chat-ui";
import { buildCanvasWorkflowOps, looksLikeWorkflowRequest } from "@/lib/canvas/canvas-workflow-builder";
import { applyCanvasOperations, canvasOperationPostconditionMessage, verifyCanvasOperations, type CanvasSnapshot } from "@/lib/canvas/canvas-operation-contract";
import { extractCloudAgentQuickActions } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { CanvasNodeType } from "@/types/canvas";
const config = { imageModel: "image-model", videoModel: "video-model", audioModel: "audio-model" } as never;
const snapshot: CanvasAgentSnapshot = { projectId: "p", title: "空画布", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
const snapshot: CanvasSnapshot = { projectId: "p", title: "空画布", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
describe("canvas agent workflow builder", () => {
describe("cloud canvas agent workflow builder", () => {
it("creates semantic media nodes, non-overlapping layout and real edges", () => {
const ops = buildCanvasWorkflowOps({
title: "搞笑修仙小说流水线",
@@ -18,27 +18,27 @@ describe("canvas agent workflow builder", () => {
{ ref: "video", kind: "storyboard_video", title: "分镜剧情视频", prompt: "基于角色三视图制作分镜剧情视频", referenceRefs: ["views"] },
],
}, snapshot, config);
const after = applyCanvasAgentOps(snapshot, ops);
const after = applyCanvasOperations(snapshot, ops);
const added = after.nodes.filter((node) => node.id.startsWith("agent-workflow-"));
expect(added.map((node) => node.type)).toEqual([CanvasNodeType.Image, CanvasNodeType.Image, CanvasNodeType.Video]);
expect(after.connections).toHaveLength(2);
expect(added[1].position.x).toBeGreaterThan(added[0].position.x + added[0].width);
expect(added[2].position.x).toBeGreaterThan(added[1].position.x + added[1].width);
const result = verifyCanvasAgentOps(snapshot, after, ops);
const result = verifyCanvasOperations(snapshot, after, ops);
expect(result.ok).toBe(true);
expect(result.connectionCount).toBe(2);
expect(result.expectedConnectionCount).toBe(2);
expect(result.overlapWarnings).toEqual([]);
expect(canvasAgentPostconditionMessage(result)).toContain("实际新增 2 条");
expect(canvasOperationPostconditionMessage(result)).toContain("实际新增 2 条");
});
it("turns numbered assistant choices into clickable prompts", () => {
expect(extractCanvasAgentQuickActions("请选择下一步:\n1. 继续细化角色\n2、增加故事元素\n3) 调整布局")).toEqual([
expect(extractCloudAgentQuickActions("请选择下一步:\n1. 继续细化角色\n2、增加故事元素\n3) 调整布局")).toEqual([
{ label: "继续细化角色", prompt: "继续细化角色" },
{ label: "增加故事元素", prompt: "增加故事元素" },
{ label: "调整布局", prompt: "调整布局" },
]);
expect(extractCanvasAgentQuickActions("```json\n1. not an action\n```")).toEqual([]);
expect(extractCloudAgentQuickActions("```json\n1. not an action\n```")).toEqual([]);
});
it("rejects workflow-shaped text batches", () => {
@@ -68,14 +68,14 @@ describe("canvas agent workflow builder", () => {
});
it("can connect a new workflow node to an existing canvas resource", () => {
const existing: CanvasAgentSnapshot = {
const existing: CanvasSnapshot = {
...snapshot,
nodes: [{ id: "asset-1", type: CanvasNodeType.Image, title: "角色原画", position: { x: 0, y: 0 }, width: 560, height: 380, metadata: { status: "success", storageKey: "resource:character-1" } }],
};
const ops = buildCanvasWorkflowOps({
nodes: [{ ref: "views", kind: "character_three_view", title: "角色三视图", prompt: "基于已有角色原画生成正面、侧面、背面三视图", referenceNodeIds: ["asset-1"] }],
}, existing, config);
const after = applyCanvasAgentOps(existing, ops);
const after = applyCanvasOperations(existing, ops);
expect(after.connections).toHaveLength(1);
expect(after.connections[0]).toMatchObject({ fromNodeId: "asset-1", toNodeId: ops[0].id });
expect(after.nodes[1]?.metadata?.referenceNodeIds).toEqual(["asset-1"]);

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "bun:test";
import { buildCanvasAgentMentionReferences } from "@/lib/canvas/canvas-resource-references";
import { cloudAgentConversationTitle } from "@/services/cloud-agent-conversations";
import { CanvasNodeType } from "@/types/canvas";
describe("cloud Agent conversations", () => {
it("exposes every canvas node through stable @ mention tokens", () => {
const references = buildCanvasAgentMentionReferences([
{ id: "frame-1", type: CanvasNodeType.Frame, title: "第一幕", position: { x: 0, y: 0 }, width: 640, height: 360 },
{ id: "config-1", type: CanvasNodeType.Config, title: "生成配置", position: { x: 700, y: 0 }, width: 320, height: 240, metadata: { prompt: "电影感" } },
]);
expect(references).toHaveLength(2);
expect(references[0]).toMatchObject({ nodeId: "frame-1", label: "第一幕", active: true, mentionToken: "@[node:frame-1]" });
expect(references[1]).toMatchObject({ nodeId: "config-1", text: "电影感", mentionToken: "@[node:config-1]" });
});
it("uses the first user prompt as a compact history title", () => {
expect(cloudAgentConversationTitle([{ id: "1", role: "assistant", text: "你好" }])).toBe("新对话");
expect(cloudAgentConversationTitle([{ id: "2", role: "user", text: " 帮我整理分镜 " }])).toBe("帮我整理分镜");
expect(cloudAgentConversationTitle([{ id: "3", role: "user", text: "一".repeat(40) }])).toBe(`${"一".repeat(28)}...`);
expect(cloudAgentConversationTitle([{ id: "4", role: "user", text: "规划短剧 @[skill:internal-skill-id]" }])).toBe("规划短剧 技能包");
});
});

View File

@@ -28,11 +28,11 @@ test("Create keeps optimistic messages when the first local task binds immediate
}));
const bound = updateCreationConversationSnapshot(optimistic, "conversation-0001", (conversation) => ({
...conversation,
messages: conversation.messages.map((message) => (message.id === "assistant-0001" ? { ...message, taskIds: ["local:dreamina-cli:task-0001"] } : message)),
messages: conversation.messages.map((message) => (message.id === "assistant-0001" ? { ...message, taskIds: ["backend-task-0001"] } : message)),
}));
expect(bound[0].messages).toHaveLength(2);
expect(bound[0].messages[1]).toMatchObject({ id: "assistant-0001", taskIds: ["local:dreamina-cli:task-0001"] });
expect(bound[0].messages[1]).toMatchObject({ id: "assistant-0001", taskIds: ["backend-task-0001"] });
});
test("Create history deletion removes only the selected conversation snapshot", () => {

View File

@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
import { CreativeAgentController, type CreativeControllerView } from "../src/services/creative-agent-controller";
import { initialCreativeState, type CreativeAgentState } from "../src/lib/creation/creative-agent-state";
import { creationRuns, type CreationRun, type CreationSubmission } from "../src/services/api/creation-runs";
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
import { applyCanvasOperations, type CanvasSnapshot } from "../src/lib/canvas/canvas-operation-contract";
import { defaultConfig } from "../src/stores/use-config-store";
import type { GenerationTask } from "../src/services/api/task-center";
import { CanvasNodeType } from "../src/types/canvas";
function harness(state: CreativeAgentState, status: CreationRun["status"] = "paused", submissions: CreationSubmission[] = [], waitTask?: ConstructorParameters<typeof CreativeAgentController>[0]["waitTask"]) {
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status, state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: state.proposal?.version, approvedProposalHash: state.operations ? "approved-proposal-hash" : undefined, createdAt: "", updatedAt: "" };
let snapshot: CanvasAgentSnapshot = { projectId: "canvas", title: "canvas", nodes: state.media.map((media) => ({ id: media.nodeId, type: CanvasNodeType.Image, title: media.ref, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: {} })), connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
let snapshot: CanvasSnapshot = { projectId: "canvas", title: "canvas", nodes: state.media.map((media) => ({ id: media.nodeId, type: CanvasNodeType.Image, title: media.ref, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: {} })), connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
let view: CreativeControllerView | undefined;
let commits = 0, prepares = 0, executions = 0;
const api = { ...creationRuns,
@@ -23,7 +23,7 @@ function harness(state: CreativeAgentState, status: CreationRun["status"] = "pau
canvasSnapshot: async () => ({ document: { id: "canvas", title: "canvas", nodes: snapshot.nodes, connections: snapshot.connections, chatSessions: [], activeChatId: null, viewport: snapshot.viewport, createdAt: "", updatedAt: "", directorScenes: [] }, snapshotHash: "hash" }),
commitCanvas: async () => { commits++; return { snapshotHash: "saved" }; },
} as typeof creationRuns;
const controller = new CreativeAgentController({ config: () => defaultConfig, canvas: () => ({ canvasId: "canvas", read: () => snapshot, apply: async (ops) => snapshot = applyCanvasAgentOps(snapshot, ops) }), onChange: (next) => { view = next; }, onOpenCanvas: () => undefined, api, waitTask, ensureAsset: async () => ({ assetId: "asset", created: false, linkedToProject: false }) });
const controller = new CreativeAgentController({ config: () => defaultConfig, canvas: () => ({ canvasId: "canvas", read: () => snapshot, apply: async (ops) => snapshot = applyCanvasOperations(snapshot, ops) }), onChange: (next) => { view = next; }, onOpenCanvas: () => undefined, api, waitTask, ensureAsset: async () => ({ assetId: "asset", created: false, linkedToProject: false }) });
return { controller, api, view: () => view!, counters: () => ({ commits, prepares, executions }), snapshot: () => snapshot };
}
const proposal = { id: "p", version: 1, title: "方案", summary: "摘要", markdown: "内容", deliverables: [], workflow: { nodes: [], edges: [], autoRun: false as const }, generationItems: [] };

View File

@@ -4,7 +4,7 @@ import { assertCreativeBriefSpecifications, initialCreativeState, type CreativeA
import type { CreativeProposal } from "../src/lib/creation/creative-agent-contract";
import { creationRuns, type CreationRun, type CreationSubmission } from "../src/services/api/creation-runs";
import { createModelChannel, defaultConfig } from "../src/stores/use-config-store";
import type { CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
import type { CanvasSnapshot } from "../src/lib/canvas/canvas-operation-contract";
import { CanvasNodeType } from "../src/types/canvas";
import type { GenerationTask } from "../src/services/api/task-center";
@@ -24,7 +24,7 @@ describe("创作规格与局部重做", () => {
for (const approvedBeforeDisconnect of [false, true]) test(`重做批准${approvedBeforeDisconnect ? "已落盘但回执丢失" : "尚未落盘"},重新装载后仅准备原目标并保留参考节点`, async () => {
const state: CreativeAgentState = { ...initialCreativeState(), proposal: structuredClone(proposal), canvasApplied: true, media: ["a", "b"].map((ref) => ({ ref, nodeId: `original-${ref}`, attempt: 1, status: "ready", taskId: `old-${ref}`, storageKey: `resource:${ref}` })) };
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status: "completed", state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: 1, approvedProposalHash: "old", createdAt: "", updatedAt: "" };
const snapshot: CanvasAgentSnapshot = { projectId: "canvas", title: "画布", nodes: ["a", "b"].map((ref) => ({ id: `original-${ref}`, type: CanvasNodeType.Image, title: ref, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: { prompt: ref === "a" ? "参考" : "结果", model, size: "1024x1024", status: "success", storageKey: `resource:${ref}`, ...(ref === "b" ? { referenceNodeIds: ["original-a"] } : {}) } })), connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
const snapshot: CanvasSnapshot = { projectId: "canvas", title: "画布", nodes: ["a", "b"].map((ref) => ({ id: `original-${ref}`, type: CanvasNodeType.Image, title: ref, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: { prompt: ref === "a" ? "参考" : "结果", model, size: "1024x1024", status: "success", storageKey: `resource:${ref}`, ...(ref === "b" ? { referenceNodeIds: ["original-a"] } : {}) } })), connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
const submissions: CreationSubmission[] = [];
let disconnect = true, approvals = 0, executions = 0;
const prepared: Parameters<typeof creationRuns.prepare>[1][] = [];

View File

@@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test";
import { defaultConfig, type AiConfig } from "../src/stores/use-config-store";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { assertCreativeBriefSpecifications, creativeProposalOps, creativeVideoSpecificationError, mergeCreativeBrief, normalizeCreativeProposal } from "../src/lib/creation/creative-agent-state";
import { applyCanvasAgentOps, type CanvasAgentSnapshot } from "../src/lib/canvas/canvas-agent-ops";
import { applyCanvasOperations, type CanvasSnapshot } from "../src/lib/canvas/canvas-operation-contract";
const image = defaultModelCapabilityConfig("openai-image", "image-model");
const video = defaultModelCapabilityConfig("volcengine-ark-video", "video-model");
const config: AiConfig = { ...defaultConfig, model: "managed::image-model", imageModel: "managed::image-model", videoModel: "managed::video-model", models: ["managed::image-model", "managed::video-model"], imageModels: ["managed::image-model"], videoModels: ["managed::video-model"], channels: [{ ...defaultConfig.channels[0]!, id: "managed", models: ["image-model", "video-model"], modelCosts: [{ model: "image-model", capability: "image", capabilityConfig: image }, { model: "video-model", capability: "video", capabilityConfig: video }] }] };
const empty: CanvasAgentSnapshot = { projectId: "canvas", title: "test", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
const empty: CanvasSnapshot = { projectId: "canvas", title: "test", nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
function shortFilm() {
return { title: "悬疑短片", summary: "一个电梯故事", markdown: "完整故事大纲", deliverables: ["15 秒竖屏短片"], workflow: { nodes: [{ ref: "outline", kind: "text", title: "大纲", content: "叙事节奏" }, ...["character", "scene", "prop"].map((ref) => ({ ref, kind: "image", title: ref, prompt: ref })), { ref: "film", kind: "video", title: "短片", prompt: "电梯内发生异常", referenceRefs: ["character", "scene", "prop"] }], edges: ["character", "scene", "prop"].map((ref) => ({ from: "outline", to: ref })) }, generationItems: [...["character", "scene", "prop"].map((ref) => ({ ref, mode: "image", model: "managed::image-model" })), { ref: "film", mode: "video", model: "managed::video-model", seconds: 15, size: "9:16", referenceRefs: ["character", "scene", "prop"] }] };
}
@@ -21,7 +21,7 @@ describe("创作方案执行合同", () => {
test("单段短片为5节点6真实连线,参考图并排分层,ID跨恢复稳定", () => {
const proposal = normalizeCreativeProposal(shortFilm(), "p", 1, config);
const ops = creativeProposalOps("run", proposal, empty, config);
const next = applyCanvasAgentOps(empty, ops);
const next = applyCanvasOperations(empty, ops);
expect(next.nodes).toHaveLength(5); expect(next.connections).toHaveLength(6);
expect(ops.some((op) => op.type === "run_generation")).toBe(false);
expect(next.nodes.filter((node) => node.type === "video")).toHaveLength(1);
@@ -40,7 +40,7 @@ describe("创作方案执行合同", () => {
test("已有商品图进入正式引用节点但不进入收费生成项", () => {
const proposal = normalizeCreativeProposal({ title: "海报", summary: "产品海报", markdown: "两版卖点设计", deliverables: ["营销图"], workflow: { nodes: [{ ref: "product", kind: "image", title: "商品", assetId: "asset" }, { ref: "poster", kind: "image", title: "海报", prompt: "保持商品实物", referenceRefs: ["product"] }], edges: [] }, generationItems: [{ ref: "poster", mode: "image", model: "managed::image-model", referenceRefs: ["product"] }] }, "p", 1, config, [{ id: "asset", assetId: "asset", title: "商品实物", kind: "image", storageKey: "resource:product" }]);
expect(proposal.generationItems).toHaveLength(1);
const nodes = applyCanvasAgentOps(empty, creativeProposalOps("run", proposal, empty, config)).nodes;
const nodes = applyCanvasOperations(empty, creativeProposalOps("run", proposal, empty, config)).nodes;
expect(nodes[0]!.metadata?.assetId).toBe("asset"); expect(nodes[0]!.metadata?.status).toBe("success");
expect(nodes[1]!.metadata?.referenceNodeIds).toEqual([nodes[0]!.id]);
});

View File

@@ -0,0 +1,29 @@
import React, { useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { CanvasNode } from '@/components/canvas/canvas-node';
import { useCanvasSelectionController } from '@/pages/canvas/use-canvas-selection-controller';
import { CanvasNodeType, type CanvasNodeData } from '@/types/canvas';
const noop = () => {};
// 仅挂载内存中的测试节点,不登录、不读写用户画布或调用模型。
export function mountNodeDragHarness(host: HTMLElement, options: { locked?: boolean; readOnly?: boolean } = {}) {
function Harness() {
const [nodes, setNodes] = useState<CanvasNodeData[]>([{ id: 'drag-test', type: CanvasNodeType.Html, title: '分镜表测试', position: { x: 100, y: 100 }, width: 800, height: 500, metadata: { content: '<html><body style="background:#222;color:white;height:1600px"><h2>分镜表</h2><table><tr><th>切</th><th>画面</th></tr><tr><td>f1</td><td>测试画面</td></tr></table></body></html>' } }]);
const nodesRef = useRef(nodes);
nodesRef.current = nodes;
const containerRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef({ x: 0, y: 0, k: 1 });
const [selected, setSelected] = useState(new Set<string>());
const selectedNodeIdsRef = useRef(selected);
selectedNodeIdsRef.current = selected;
const historyPausedRef = useRef(false);
const ctrl = useCanvasSelectionController({ containerRef, nodesRef, viewportRef, selectedNodeIdsRef, historyPausedRef, screenToCanvas: (x, y) => ({ x, y }), setNodes, setSelectedNodeIds: setSelected, setSelectedConnectionId: noop, cancelPendingConnectionCreate: noop, onCanvasSelectionStart: noop, onNodeInteractionStart: noop, onNodeClick: noop, onDeselect: noop });
return <div ref={containerRef} style={{ position: 'absolute', inset: 0 }}>
<output id="drag-position">{JSON.stringify(nodes[0].position)}</output>
<CanvasNode data={{ ...nodes[0], metadata: { ...nodes[0].metadata, locked: options.locked } }} readOnly={options.readOnly} scale={1} isSelected={selected.has(nodes[0].id)} isRelated={false} isFocusRelated={false} isConnectionTarget={false} dragOffset={ctrl.dragPreview ?? undefined} onMouseDown={ctrl.handleNodeMouseDown} onHoverStart={noop} onHoverEnd={noop} onConnectStart={noop} onResize={noop} onTitleChange={noop} onContentChange={noop} onContextMenu={noop} />
</div>;
}
const root = createRoot(host);
root.render(<Harness />);
return () => root.unmount();
}

View File

@@ -6,7 +6,7 @@ import { applyGenerationConsumerEffect } from "../src/services/generation-consum
import { createGenerationTaskMaterializer, type GenerationTaskEffectResult, type GenerationTaskEffectStore } from "../src/services/generation-task-materializer";
import { buildBackendToolRequests } from "../src/services/api/image";
test("managed canvas agent task keeps tool definitions and tool results", () => {
test("managed cloud agent task keeps tool definitions and tool results", () => {
const requests = buildBackendToolRequests(
[
{ role: "user", content: "读取画布" },

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test";
import { desktopLocalChannelUiVisible } from "../src/lib/desktop-local-channel";
import { fetchChannelModels } from "../src/services/api/image";
import { channelRequest } from "../src/services/api/custom-channel-relay";
import { backendProviderConfig } from "../src/services/api/generation-task";
import { apiClient } from "../src/services/api/request";
import { channelConnectionSignature, createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, type ModelChannel } from "../src/stores/use-config-store";
import { defaultFeatureAvailability, useUserStore } from "../src/stores/use-user-store";
const originalApiRequest = apiClient.request;
const originalWindow = globalThis.window;
afterEach(() => {
apiClient.request = originalApiRequest;
useUserStore.setState({ features: defaultFeatureAvailability });
if (originalWindow === undefined) delete (globalThis as { window?: Window }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
});
function setLocalChannelRuntime(desktopLocalChannelsEnabled: boolean, hostname: string) {
useUserStore.setState({ features: { ...defaultFeatureAvailability, desktopLocalChannelsEnabled } });
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { hostname } } });
}
function forgedLocalGenerationConfig() {
const channel = createModelChannel({
id: "local",
name: "Local",
baseUrl: "http://127.0.0.1:8000",
apiKey: "test-key",
apiFormat: "openai",
models: ["local-model"],
allowLocalChannel: true,
});
return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: [channel], model: "local::local-model", textModel: "local::local-model" } }).config;
}
describe("允许本机渠道前端请求合同", () => {
test("默认关闭,旧持久化记录回退 false,新记录 true 可回显并进入请求配置", () => {
const legacy = createModelChannel({ id: "legacy", name: "Legacy", baseUrl: "https://example.com", apiKey: "key", models: ["model"] });
expect(legacy.allowLocalChannel).toBe(false);
const local = createModelChannel({ id: "local", name: "Local", baseUrl: "http://127.0.0.1:8000", apiKey: "key", models: ["model"], allowLocalChannel: true });
const normalized = normalizeConfigSnapshot({ config: { ...defaultConfig, channels: [local], model: "local::model", textModel: "local::model" } }).config;
expect(normalized.channels[0]?.allowLocalChannel).toBe(true);
setLocalChannelRuntime(true, "127.0.0.1");
expect(resolveModelRequestConfig(normalized, "local::model").allowLocalChannel).toBe(true);
setLocalChannelRuntime(false, "127.0.0.1");
expect(resolveModelRequestConfig(normalized, "local::model").allowLocalChannel).toBe(false);
});
test("UI 只在 Backend capability=true 且页面文本 host 精确 loopback 时显示", () => {
expect(desktopLocalChannelUiVisible(true, "127.0.0.1")).toBe(true);
expect(desktopLocalChannelUiVisible(true, "localhost")).toBe(true);
for (const hostname of ["localhost.", "x.localhost", "127.0.0.2", "::1", "0.0.0.0", "192.168.1.5", "canvas.example.com"]) {
expect(desktopLocalChannelUiVisible(true, hostname)).toBe(false);
}
expect(desktopLocalChannelUiVisible(false, "127.0.0.1")).toBe(false);
expect(desktopLocalChannelUiVisible(false, "localhost")).toBe(false);
});
test("channelConnectionSignature 包含 allowLocalChannel,切换后旧模型拉取结果会失效", () => {
const channel = createModelChannel({ id: "local", name: "Local", baseUrl: "http://127.0.0.1:8000", apiKey: "key", models: ["model"] });
expect(channelConnectionSignature(channel)).not.toBe(channelConnectionSignature({ ...channel, allowLocalChannel: true }));
});
test("实际生成配置与 relay 在运行时重新投影 forged persisted local flag", () => {
const config = forgedLocalGenerationConfig();
setLocalChannelRuntime(false, "127.0.0.1");
const capabilityClosed = backendProviderConfig(config);
expect(capabilityClosed.allowLocalChannel).toBe(false);
expect(channelRequest(capabilityClosed, "http://127.0.0.1:8000/v1/responses").headers["x-canvas-allow-local-channel"]).toBeUndefined();
setLocalChannelRuntime(true, "canvas.example.com");
const remotePage = backendProviderConfig(config);
expect(remotePage.allowLocalChannel).toBe(false);
expect(channelRequest(remotePage, "http://127.0.0.1:8000/v1/responses").headers["x-canvas-allow-local-channel"]).toBeUndefined();
setLocalChannelRuntime(true, "localhost");
const desktop = backendProviderConfig(config);
expect(desktop.allowLocalChannel).toBe(true);
const request = channelRequest(desktop, "http://127.0.0.1:8000/v1/responses", { "Content-Type": "application/json" });
expect(request.headers["x-canvas-allow-local-channel"]).toBe("1");
expect(request.headers["x-canvas-upstream-base-url"]).toBe("http://127.0.0.1:8000/");
});
test("登录态模型拉取同样消费运行时投影而不是 forged persisted flag", async () => {
const bodies: Record<string, unknown>[] = [];
apiClient.request = (async (request) => {
bodies.push(request.data as Record<string, unknown>);
return { data: { code: 0, data: { models: [{ id: "local-model" }] }, msg: "ok" } };
}) as typeof apiClient.request;
const channel: ModelChannel = forgedLocalGenerationConfig().channels[0];
setLocalChannelRuntime(false, "127.0.0.1");
await fetchChannelModels(channel, true);
expect(bodies.at(-1)?.allowLocalChannel).toBe(false);
setLocalChannelRuntime(true, "canvas.example.com");
await fetchChannelModels(channel, true);
expect(bodies.at(-1)?.allowLocalChannel).toBe(false);
setLocalChannelRuntime(true, "127.0.0.1");
await fetchChannelModels(channel, true);
expect(bodies.at(-1)?.allowLocalChannel).toBe(true);
});
test("custom relay 最终边界也会压掉直接伪造的 local flag", () => {
const config = {
baseUrl: "http://127.0.0.1:8000",
apiKey: "test-key",
apiFormat: "openai" as const,
allowLocalChannel: true,
};
setLocalChannelRuntime(false, "127.0.0.1");
expect(channelRequest(config, "http://127.0.0.1:8000/v1/responses").headers["x-canvas-allow-local-channel"]).toBeUndefined();
setLocalChannelRuntime(true, "localhost");
expect(channelRequest(config, "http://127.0.0.1:8000/v1/responses").headers["x-canvas-allow-local-channel"]).toBe("1");
});
test("非法自定义渠道 Base URL 返回可读错误,而不是原生 URL 异常", () => {
expect(() => channelRequest({ baseUrl: "api.example.com/v1", apiKey: "key", apiFormat: "openai" }, "api.example.com/v1/images/generations")).toThrow("当前模型渠道 Base URL 无效");
});
test("非法上游请求地址返回可读错误", () => {
expect(() => channelRequest({ baseUrl: "https://api.example.com/v1", apiKey: "key", apiFormat: "openai" }, "api.example.com/v1/images/generations")).toThrow("当前模型请求地址 无效");
});
});

View File

@@ -1,122 +0,0 @@
import assert from "node:assert/strict";
import { after, afterEach, test } from "node:test";
import os from "node:os";
import path from "node:path";
import { createServer } from "vite";
const webRoot = process.cwd();
const originalWindow = globalThis.window;
const viteRuntime = createServer({
root: webRoot,
configFile: false,
cacheDir: path.join(os.tmpdir(), `framefield-local-channel-runtime-${process.pid}`),
resolve: { alias: { "@": path.join(webRoot, "src") } },
define: { __APP_VERSION__: JSON.stringify("test"), __APP_CHANGELOG__: JSON.stringify("") },
server: { middlewareMode: true },
appType: "custom",
logLevel: "silent",
});
const runtime = await viteRuntime;
const configStore = await runtime.ssrLoadModule("/src/stores/use-config-store.ts");
const userStore = await runtime.ssrLoadModule("/src/stores/use-user-store.ts");
const generationTask = await runtime.ssrLoadModule("/src/services/api/generation-task.ts");
const relay = await runtime.ssrLoadModule("/src/services/api/custom-channel-relay.ts");
const imageApi = await runtime.ssrLoadModule("/src/services/api/image.ts");
const requestModule = await runtime.ssrLoadModule("/src/services/api/request.ts");
const originalApiRequest = requestModule.apiClient.request;
after(async () => {
await runtime.close();
});
afterEach(() => {
requestModule.apiClient.request = originalApiRequest;
userStore.useUserStore.setState({ features: userStore.defaultFeatureAvailability });
if (originalWindow === undefined) delete globalThis.window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
});
function setRuntime(desktopLocalChannelsEnabled, hostname) {
userStore.useUserStore.setState({ features: { ...userStore.defaultFeatureAvailability, desktopLocalChannelsEnabled } });
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { hostname } } });
}
function forgedConfig() {
const channel = configStore.createModelChannel({
id: "local",
name: "Local",
baseUrl: "http://127.0.0.1:8000",
apiKey: "test-key",
apiFormat: "openai",
models: ["local-model"],
allowLocalChannel: true,
});
return configStore.normalizeConfigSnapshot({
config: {
...configStore.defaultConfig,
channels: [channel],
model: "local::local-model",
textModel: "local::local-model",
},
}).config;
}
function requestFlag(config) {
const request = relay.channelRequest(config, "http://127.0.0.1:8000/v1/responses", { "Content-Type": "application/json" });
return request.headers["x-canvas-allow-local-channel"];
}
test("actual generation request config reprojects forged persisted local permission at runtime", () => {
const config = forgedConfig();
assert.equal(config.channels[0]?.allowLocalChannel, true, "persisted channel intentionally remains true before runtime projection");
setRuntime(false, "127.0.0.1");
const capabilityClosed = generationTask.backendProviderConfig(config);
assert.equal(capabilityClosed.allowLocalChannel, false);
assert.equal(requestFlag(capabilityClosed), undefined);
setRuntime(true, "canvas.example.com");
const remotePage = generationTask.backendProviderConfig(config);
assert.equal(remotePage.allowLocalChannel, false);
assert.equal(requestFlag(remotePage), undefined);
setRuntime(true, "localhost");
const desktop = generationTask.backendProviderConfig(config);
assert.equal(desktop.allowLocalChannel, true);
assert.equal(requestFlag(desktop), "1");
});
test("authenticated model fetch reprojects forged persisted local permission before backend request", async () => {
const bodies = [];
requestModule.apiClient.request = async (request) => {
bodies.push(request.data);
return { data: { code: 0, data: { models: [{ id: "local-model" }] }, msg: "ok" } };
};
const channel = forgedConfig().channels[0];
setRuntime(false, "127.0.0.1");
await imageApi.fetchChannelModels(channel, true);
assert.equal(bodies.at(-1)?.allowLocalChannel, false);
setRuntime(true, "canvas.example.com");
await imageApi.fetchChannelModels(channel, true);
assert.equal(bodies.at(-1)?.allowLocalChannel, false);
setRuntime(true, "localhost");
await imageApi.fetchChannelModels(channel, true);
assert.equal(bodies.at(-1)?.allowLocalChannel, true);
});
test("custom relay final boundary independently reprojects a directly forged local permission", () => {
const forged = { baseUrl: "http://127.0.0.1:8000", apiKey: "test-key", apiFormat: "openai", allowLocalChannel: true };
setRuntime(false, "127.0.0.1");
assert.equal(requestFlag(forged), undefined);
setRuntime(true, "canvas.example.com");
assert.equal(requestFlag(forged), undefined);
setRuntime(true, "127.0.0.1");
assert.equal(requestFlag(forged), "1");
});

View File

@@ -1,73 +0,0 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
function ambiguousChannels(store) {
const shared = {
name: "Shared",
baseUrl: "http://127.0.0.1:8000",
apiKey: "same-key",
apiFormat: "openai",
models: ["same-model"],
};
return [store.createModelChannel({ ...shared, id: "local-enabled", allowLocalChannel: true }), store.createModelChannel({ ...shared, id: "local-disabled", allowLocalChannel: false })];
}
test("task-center forwards explicit task/session provider config without inferring a channel from Zustand", async () => {
const taskCenter = await import("../src/services/api/task-center");
const generationTask = await import("../src/services/api/generation-task");
const requestModule = await import("../src/services/api/request");
const store = await import("../src/stores/use-config-store");
const userStore = await import("../src/stores/use-user-store");
const previousWindow = globalThis.window;
const previousConfig = store.useConfigStore.getState().config;
userStore.useUserStore.setState({ features: { ...userStore.defaultFeatureAvailability, desktopLocalChannelsEnabled: true } });
const channels = ambiguousChannels(store);
const explicitConfig = {
...store.defaultConfig,
channels,
models: ["local-enabled::same-model", "local-disabled::same-model"],
model: "local-disabled::same-model",
textModel: "local-disabled::same-model",
};
store.useConfigStore.setState({ config: explicitConfig });
globalThis.window = { location: { hostname: "127.0.0.1" }, dispatchEvent() {} };
assert.equal(generationTask.backendProviderConfig(explicitConfig).allowLocalChannel, false);
assert.equal(generationTask.backendProviderConfig({ ...explicitConfig, model: "local-enabled::same-model" }).allowLocalChannel, true);
const bodies = [];
const originalRequest = requestModule.apiClient.request;
requestModule.apiClient.request = async (config) => {
const url = typeof config === "string" ? config : config.url;
const body = typeof config === "object" ? config.data : undefined;
bodies.push({ url, body });
if (url === "/sessions") {
return { data: { code: 0, data: { session: { id: "session", status: "active", prompt: "p", createdAt: "now", updatedAt: "now" }, messages: [], tasks: [], results: [] }, msg: "ok" }, status: 200, headers: {} };
}
return { data: { code: 0, data: { id: "task", type: "canvas_text", status: "queued", prompt: "p", attempts: 1, createdAt: "now", updatedAt: "now" }, msg: "ok" }, status: 200, headers: {} };
};
try {
const providerConfig = {
baseUrl: "http://127.0.0.1:8000",
apiKey: "same-key",
apiFormat: "openai",
model: "same-model",
allowLocalChannel: false,
};
await taskCenter.createAgentSession({ prompt: "p", config: providerConfig });
await taskCenter.createGenerationTask({
prompt: "p",
model: "same-model",
input: { mode: "text", config: providerConfig },
});
} finally {
requestModule.apiClient.request = originalRequest;
userStore.useUserStore.setState({ features: userStore.defaultFeatureAvailability });
globalThis.window = previousWindow;
store.useConfigStore.setState({ config: previousConfig });
}
assert.equal(bodies[0]?.url, "/sessions");
assert.equal(bodies[0]?.body?.config?.allowLocalChannel, false);
assert.equal(bodies[1]?.url, "/tasks");
assert.equal(bodies[1]?.body?.input?.config?.allowLocalChannel, false);
});

View File

@@ -1,117 +0,0 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
async function loadPageWiring(modulePath, ownerExport, fieldsExport, switchExport) {
const previousVersion = globalThis.__APP_VERSION__;
const previousChangelog = globalThis.__APP_CHANGELOG__;
globalThis.__APP_VERSION__ = "test";
globalThis.__APP_CHANGELOG__ = "";
try {
const module = await import(modulePath);
const owner = module[ownerExport];
const FieldsComponent = module[fieldsExport];
const SwitchComponent = module[switchExport];
assert.equal(typeof owner, "function", `${ownerExport} must be exported by ${modulePath}`);
assert.equal(typeof FieldsComponent, "function", `${fieldsExport} must be the hook-free fields view actually rendered by ${modulePath}`);
assert.equal(typeof SwitchComponent, "function", `${switchExport} must be the real page Switch component`);
return { module, owner, FieldsComponent, SwitchComponent };
} finally {
if (previousVersion === undefined) delete globalThis.__APP_VERSION__;
else globalThis.__APP_VERSION__ = previousVersion;
if (previousChangelog === undefined) delete globalThis.__APP_CHANGELOG__;
else globalThis.__APP_CHANGELOG__ = previousChangelog;
}
}
function findAntdSwitchElement(node, SwitchComponent) {
if (!node || typeof node !== "object") return null;
if (node.type && typeof node.type === "object" && node.type.__ANT_SWITCH === true) return node;
if (node.type === SwitchComponent) return findAntdSwitchElement(SwitchComponent(node.props), SwitchComponent);
const children = node.props?.children;
if (Array.isArray(children)) {
for (const child of children) {
const match = findAntdSwitchElement(child, SwitchComponent);
if (match) return match;
}
return null;
}
return findAntdSwitchElement(children, SwitchComponent);
}
function adminValues(allowLocalChannel) {
return { name: "Local", baseUrl: "http://127.0.0.1:8000", allowLocalChannel, apiKey: "key" };
}
test("administrator default editor fields render the real Switch and feed the real save payload", { timeout: 30_000 }, async () => {
const page = await loadPageWiring("../src/pages/admin/channels/channels-page.tsx", "adminLocalChannelFormOwner", "AdminLocalChannelFields", "AdminLocalChannelSwitch");
assert.equal(typeof page.module.adminChannelSavePayload, "function");
const values = adminValues(false);
const form = {
setFieldValue(name, value) {
assert.equal(name, "allowLocalChannel");
values.allowLocalChannel = value;
},
};
const hiddenOwner = page.owner(false, "127.0.0.1", true);
const hiddenTree = page.FieldsComponent({ visible: hiddenOwner.visible, checked: hiddenOwner.checked, form });
assert.equal(findAntdSwitchElement(hiddenTree, page.SwitchComponent), null);
assert.equal(page.module.adminChannelSavePayload(adminValues(true), false, "127.0.0.1").allowLocalChannel, false);
const initialOwner = page.owner(true, "127.0.0.1", undefined);
const initialTree = page.FieldsComponent({ visible: initialOwner.visible, checked: initialOwner.checked, form });
const initialSwitch = findAntdSwitchElement(initialTree, page.SwitchComponent);
assert.ok(initialSwitch, "default admin editor fields must contain the real AntD Switch");
assert.equal(initialSwitch.props.checked, false);
initialSwitch.props.onChange(true);
assert.equal(values.allowLocalChannel, true, "real admin Switch must write the Form field");
assert.equal(page.module.adminChannelSavePayload(values, true, "127.0.0.1").allowLocalChannel, true);
const persistedOwner = page.owner(true, "localhost", true);
const persistedTree = page.FieldsComponent({ visible: persistedOwner.visible, checked: persistedOwner.checked, form });
const persistedSwitch = findAntdSwitchElement(persistedTree, page.SwitchComponent);
assert.ok(persistedSwitch);
assert.equal(persistedSwitch.props.checked, true);
persistedSwitch.props.onChange(false);
assert.equal(values.allowLocalChannel, false, "real admin Switch must clear the Form field");
assert.equal(page.module.adminChannelSavePayload(values, true, "localhost").allowLocalChannel, false);
const nonLoopbackOwner = page.owner(true, "canvas.example.com", true);
assert.equal(findAntdSwitchElement(page.FieldsComponent({ visible: nonLoopbackOwner.visible, checked: nonLoopbackOwner.checked, form }), page.SwitchComponent), null);
assert.equal(page.module.adminChannelSavePayload(adminValues(true), true, "canvas.example.com").allowLocalChannel, false);
});
test("user default channel fields render the real Switch and feed the real update patch", { timeout: 30_000 }, async () => {
const page = await loadPageWiring("../src/pages/settings/index.tsx", "userLocalChannelFormOwner", "UserLocalChannelFields", "UserLocalChannelSwitch");
assert.equal(typeof page.module.userLocalChannelChangePatch, "function");
const channel = { id: "local", name: "Local", baseUrl: "http://127.0.0.1:8000", apiKey: "key", apiFormat: "openai", models: ["model"], allowLocalChannel: true };
let update;
const updateChannel = (id, patch) => {
update = { id, patch };
};
const hiddenOwner = page.owner(false, "127.0.0.1", true);
const hiddenTree = page.FieldsComponent({ channel, visible: hiddenOwner.visible, checked: hiddenOwner.checked, desktopLocalChannelsEnabled: false, hostname: "127.0.0.1", updateChannel });
assert.equal(findAntdSwitchElement(hiddenTree, page.SwitchComponent), null);
assert.equal(page.module.userLocalChannelChangePatch(false, "127.0.0.1", true).allowLocalChannel, false);
const initialOwner = page.owner(true, "127.0.0.1", false);
const initialTree = page.FieldsComponent({ channel: { ...channel, allowLocalChannel: false }, visible: initialOwner.visible, checked: initialOwner.checked, desktopLocalChannelsEnabled: true, hostname: "127.0.0.1", updateChannel });
const initialSwitch = findAntdSwitchElement(initialTree, page.SwitchComponent);
assert.ok(initialSwitch, "default user channel fields must contain the real AntD Switch");
assert.equal(initialSwitch.props.checked, false);
initialSwitch.props.onChange(true);
assert.deepEqual(update, { id: "local", patch: { allowLocalChannel: true } }, "real user Switch must call updateChannel with the projected patch");
const persistedOwner = page.owner(true, "localhost", true);
const persistedTree = page.FieldsComponent({ channel, visible: persistedOwner.visible, checked: persistedOwner.checked, desktopLocalChannelsEnabled: true, hostname: "localhost", updateChannel });
const persistedSwitch = findAntdSwitchElement(persistedTree, page.SwitchComponent);
assert.ok(persistedSwitch);
assert.equal(persistedSwitch.props.checked, true);
persistedSwitch.props.onChange(false);
assert.deepEqual(update, { id: "local", patch: { allowLocalChannel: false } });
const nonLoopbackOwner = page.owner(true, "canvas.example.com", true);
assert.equal(findAntdSwitchElement(page.FieldsComponent({ channel, visible: nonLoopbackOwner.visible, checked: nonLoopbackOwner.checked, desktopLocalChannelsEnabled: true, hostname: "canvas.example.com", updateChannel }), page.SwitchComponent), null);
assert.deepEqual(page.module.userLocalChannelChangePatch(true, "canvas.example.com", true), { allowLocalChannel: false });
});

View File

@@ -1,247 +0,0 @@
import { expect, test } from "bun:test";
test("Local CLI settings automatically connects without any browser-confirmation flow or connection secrets", async () => {
const module = await import("../src/pages/settings/local-cli-settings").catch(() => ({}));
const present = (
module as {
localCliSettingsPresentation?: typeof presentationContract;
}
).localCliSettingsPresentation;
const copy = (
module as {
LOCAL_CLI_SETTINGS_COPY?: typeof compactCopyContract;
}
).LOCAL_CLI_SETTINGS_COPY;
expect(typeof present).toBe("function");
expect(copy).toEqual(compactCopyContract);
expect("openLocalRuntimePairing" in module).toBe(false);
if (!present) return;
const obsoleteConnectionState = present({
connection: "obsolete_browser_confirmation",
moduleAvailable: false,
dreamina: undefined,
});
expect(obsoleteConnectionState).toMatchObject({
runtime: { label: "尚未检测", action: "refresh" },
dreamina: { label: "连接本机服务后自动检测", action: null },
});
const reconnect = present({
connection: "origin_not_trusted",
moduleAvailable: false,
dreamina: undefined,
});
expect(reconnect).toMatchObject({
runtime: { label: "需要重新连接", action: "refresh", actionLabel: "重新连接" },
dreamina: { label: "连接本机服务后自动检测", action: null },
});
const installed = present({
connection: "connected",
moduleAvailable: true,
dreamina: {
provider: "dreamina-cli",
state: "installed",
installed: true,
authenticated: false,
code: "dreamina_login_required",
message: "Dreamina CLI 已安装,需要登录",
version: "1.2.3",
},
});
expect(installed).toMatchObject({
runtime: { label: "已连接", action: "refresh" },
dreamina: { label: "未登录", action: "login" },
});
const authenticated = present({
connection: "connected",
moduleAvailable: true,
dreamina: {
provider: "dreamina-cli",
state: "authenticated",
installed: true,
authenticated: true,
message: "Dreamina CLI 已登录",
totalCredit: 24_940,
},
});
expect(authenticated.dreamina).toMatchObject({
label: "已登录",
action: "logout",
});
expect(authenticated.dreamina.creditLabel).toBeUndefined();
const serialized = JSON.stringify([obsoleteConnectionState, installed, authenticated]);
expect(serialized).not.toContain("endpoint");
expect(serialized).not.toContain("token");
expect(serialized).not.toContain("session");
});
test("Dreamina model and credit cache scope follows accountBinding plus sessionEpoch and credits expose observedAt", async () => {
const modelStore = await import("../src/stores/use-local-dreamina-model-store").catch(() => ({}));
const scopeKey = (
modelStore as {
dreaminaModelCacheScopeKey?: (scope: { accountBinding: string; sessionEpoch: number }) => string;
}
).dreaminaModelCacheScopeKey;
expect(typeof scopeKey).toBe("function");
if (scopeKey) {
expect(scopeKey({ accountBinding: "account-binding-a", sessionEpoch: 7 })).toBe("account-binding-a:7");
expect(scopeKey({ accountBinding: "account-binding-a", sessionEpoch: 8 })).not.toBe(scopeKey({ accountBinding: "account-binding-a", sessionEpoch: 7 }));
expect(scopeKey({ accountBinding: "account-binding-b", sessionEpoch: 7 })).not.toBe(scopeKey({ accountBinding: "account-binding-a", sessionEpoch: 7 }));
}
const settings = await import("../src/pages/settings/local-cli-settings").catch(() => ({}));
const present = (
settings as {
localCliSettingsPresentation?: (input: {
connection: string;
moduleAvailable: boolean;
timeZone?: string;
dreamina?: {
provider: "dreamina-cli";
state: "authenticated";
installed: true;
authenticated: true;
message: string;
totalCredit: number;
creditObservedAt: string;
accountBinding: string;
sessionEpoch: number;
};
}) => { dreamina: { creditLabel?: string; creditObservedAtLabel?: string } };
}
).localCliSettingsPresentation;
expect(typeof present).toBe("function");
if (!present) return;
const authenticated = present({
connection: "connected",
moduleAvailable: true,
timeZone: "Asia/Shanghai",
dreamina: {
provider: "dreamina-cli",
state: "authenticated",
installed: true,
authenticated: true,
message: "Dreamina CLI 已登录",
totalCredit: 24_940,
creditObservedAt: "2026-08-13T12:34:56.000Z",
accountBinding: "account-binding-a",
sessionEpoch: 7,
},
});
expect(authenticated.dreamina.creditLabel).toBe("即梦积分 24,940");
expect(authenticated.dreamina.creditObservedAtLabel).toBe("上次刷新积分 20:34");
const invalidObservedAt = present({
connection: "connected",
moduleAvailable: true,
timeZone: "Asia/Shanghai",
dreamina: {
provider: "dreamina-cli",
state: "authenticated",
installed: true,
authenticated: true,
message: "Dreamina CLI 已登录",
totalCredit: 24_940,
creditObservedAt: "not-an-iso-time",
accountBinding: "account-binding-a",
sessionEpoch: 7,
},
});
expect(invalidObservedAt.dreamina.creditLabel).toBeUndefined();
expect(invalidObservedAt.dreamina.creditObservedAtLabel).toBeUndefined();
});
test("Local CLI settings keeps the Runtime compact and uses the official Dreamina CLI copy", async () => {
const module = await import("../src/pages/settings/local-cli-settings").catch(() => ({}));
const copy = (
module as {
LOCAL_CLI_SETTINGS_COPY?: typeof compactCopyContract;
}
).LOCAL_CLI_SETTINGS_COPY;
expect(copy).toEqual(compactCopyContract);
const source = await Bun.file(new URL("../src/pages/settings/local-cli-settings.tsx", import.meta.url)).text();
expect(source.match(/void connect\(controller\.signal\)/g)).toHaveLength(1);
expect(source).not.toContain("runtimeController");
expect(source).toContain('void runDreamina("refresh")');
expect(source.match(/官方 CLI 登录资料保存在本机;本页面不读取或上传 Cookie、浏览器 Profile 或登录令牌。/g)).toHaveLength(1);
expect(source.match(/LOCAL_CLI_SETTINGS_COPY\.dreaminaAccountSwitch/g)).toHaveLength(1);
expect(source).not.toContain("dreaminaSafety");
expect(source).not.toContain("Framefield 不读取或上传 Cookie、浏览器 Profile 或登录令牌。");
expect(source).not.toContain("无需重复授权");
expect(source).not.toContain("runtime?.version");
expect(source).not.toContain("自动发现本机 Framefield Runtime");
expect(source).not.toContain("Canvas 与 OAuth CLI 共用同一个进程");
expect(source).not.toContain("当前浏览器密钥自动重连");
expect(source).not.toContain("页面未授权,以页面发起/完成授权为准");
const architecture = await Bun.file(new URL("../../canvas-agent/README.md", import.meta.url)).text();
expect(architecture).toContain("外部程序直接切换 Dreamina CLI 账号无法被本应用实时观测");
expect(architecture).toContain("官方 CLI 的 argv 可能被同一 OS 用户通过进程列表看到");
expect(architecture).toContain("prompt、receipt 或本地路径");
});
test("settings route recognizes local-cli as a first-class section", async () => {
const module = await import("../src/pages/settings");
const isConfigSection = (module as { isConfigSection?: (value: string | null) => boolean }).isConfigSection;
expect(typeof isConfigSection).toBe("function");
if (!isConfigSection) return;
expect(isConfigSection("local-cli")).toBe(true);
expect(isConfigSection("local-runtime-token")).toBe(false);
});
test("model settings and Create subscribe to the same effective Runtime catalog after first render", async () => {
const settingsSource = await Bun.file(new URL("../src/pages/settings/index.tsx", import.meta.url)).text();
const createSource = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text();
expect(settingsSource).toContain("useEffectiveConfig");
expect(settingsSource).toContain("<ModelDefaultGrid config={effectiveConfig}");
expect(createSource).toContain("const config = useEffectiveConfig()");
});
type PresentationInput = {
connection: string;
moduleAvailable: boolean;
dreamina?: {
provider: "dreamina-cli";
state: "missing" | "installed" | "login_pending" | "authenticated" | "error";
installed: boolean;
authenticated: boolean;
code?: string;
message: string;
version?: string;
totalCredit?: number;
accountBinding?: string;
sessionEpoch?: number;
creditObservedAt?: string;
};
};
type PresentationResult = {
runtime: { label: string; action: string | null; actionLabel?: string };
dreamina: { label: string; action: string | null; creditLabel?: string; creditObservedAtLabel?: string };
};
declare function presentationContract(input: PresentationInput): PresentationResult;
const compactCopyContract = {
runtimeTitle: "本机连接",
runtimeConnected: "本机服务已连接,CLI 状态会自动同步。",
runtimeDetecting: "正在检测本机服务;请确认已启动当前版本。",
runtimeReconnect: "重新连接",
runtimeSafety: "官方 CLI 登录资料保存在本机;本页面不读取或上传 Cookie、浏览器 Profile 或登录令牌。",
runtimeRefresh: "刷新状态",
dreaminaDescription: "直接读取当前 Windows 用户的官方即梦 CLI 登录状态。",
dreaminaDisconnected: "连接本机服务后自动检测",
dreaminaDisconnectedMessage: "重新连接本机服务后,将自动读取官方 CLI 状态。",
dreaminaMembership: "账号生成权限:未知。当前页面只确认本机适配器支持与登录状态;具体账号是否可生成,以官方最终结果为准。",
dreaminaConsistency: "任务状态通过后台轮询最终同步,不是实时推送;关闭页面不会停止已经提交的官方任务。",
dreaminaCancel: "官方 Dreamina CLI 当前不提供取消命令;官方已接受的任务只能转入后台继续同步,不能伪装成已取消。",
dreaminaAccountSwitch: "本机任务运行期间,请不要在其他程序中切换 Dreamina CLI 账号;外部换号无法被本页面实时感知。",
dreaminaRefresh: "刷新状态",
} as const;

View File

@@ -1,255 +0,0 @@
import { describe, expect, test } from "bun:test";
const privateCanary = "dreamina-private-canary";
const fixedNow = Date.parse("2026-08-10T00:00:00.000Z");
test("Dreamina browser deadlines cover the complete official CLI lifecycle", async () => {
const module = await import("../src/services/local-dreamina-cli");
expect(module.DREAMINA_REQUEST_TIMEOUTS).toEqual({
status: 30_000,
login: 35_000,
logout: 20_000,
});
for (const [action, expected] of Object.entries(module.DREAMINA_REQUEST_TIMEOUTS)) {
expect(module.dreaminaRequestTimeout(action as "status" | "login" | "logout")).toBe(expected);
expect(module.dreaminaRequestTimeout(action as "status" | "login" | "logout", 1_234)).toBe(1_234);
}
});
describe("signed Dreamina CLI lifecycle client", () => {
test("status login and logout use only fixed signed Runtime requests", async () => {
const module = await import("../src/services/local-dreamina-cli").catch(() => ({}));
expect(typeof (module as Record<string, unknown>).getDreaminaStatus).toBe("function");
if (!("getDreaminaStatus" in module)) return;
const calls: Array<{ path: string; method: string; headers: Headers; body: string }> = [];
const client = {
async request(path: string, init: RequestInit = {}) {
calls.push({
path,
method: String(init.method || "GET"),
headers: new Headers(init.headers),
body: String(init.body || ""),
});
return jsonResponse({ ok: true, status: installedStatus() });
},
};
await module.getDreaminaStatus(client, { now: () => fixedNow });
await module.loginDreamina(client, { now: () => fixedNow });
await module.logoutDreamina(client, { now: () => fixedNow });
expect(calls.map((call) => [call.method, call.path, call.body])).toEqual([
["GET", "/dreamina/status", ""],
["POST", "/dreamina/login", "{}"],
["POST", "/dreamina/logout", "{}"],
]);
expect(calls.every((call) => !call.headers.has("authorization"))).toBe(true);
expect(calls.every((call) => !call.headers.has("x-canvas-agent-token"))).toBe(true);
expect(JSON.stringify(calls)).not.toContain("ownerId");
});
test("projects bounded lifecycle status without preserving injected messages", async () => {
const module = await import("../src/services/local-dreamina-cli");
const result = await module.getDreaminaStatus(
{
request: async () =>
jsonResponse({
ok: true,
status: { ...installedStatus(), message: `${privateCanary} C:\\Users\\owner\\Profile` },
}),
},
{ now: () => fixedNow },
);
expect(result).toEqual({
provider: "dreamina-cli",
state: "installed",
installed: true,
authenticated: false,
version: "1.2.3",
code: "dreamina_login_required",
message: "Dreamina CLI 已安装,需要登录",
});
expect(JSON.stringify(result)).not.toContain(privateCanary);
expect(JSON.stringify(result)).not.toContain("Profile");
});
test("projects only a bounded numeric Dreamina credit balance for authenticated status", async () => {
const module = await import("../src/services/local-dreamina-cli");
const result = await module.getDreaminaStatus(
{
request: async () =>
jsonResponse({
ok: true,
status: { ...authenticatedStatus(), totalCredit: 24_940 },
}),
},
{ now: () => fixedNow },
);
expect(result.totalCredit).toBe(24_940);
for (const totalCredit of [-1, 1.5, "24940", null, Number.MAX_SAFE_INTEGER]) {
await expect(
module.getDreaminaStatus(
{
request: async () =>
jsonResponse({
ok: true,
status: { ...authenticatedStatus(), totalCredit },
}),
},
{ now: () => fixedNow },
),
).rejects.toMatchObject({ code: "dreamina_response_invalid" });
}
await expect(
module.getDreaminaStatus(
{
request: async () =>
jsonResponse({
ok: true,
status: { ...installedStatus(), totalCredit: 24_940 },
}),
},
{ now: () => fixedNow },
),
).rejects.toMatchObject({ code: "dreamina_response_invalid" });
});
test("keeps official pending verification fields and rejects state contradictions", async () => {
const module = await import("../src/services/local-dreamina-cli");
const expiresAt = new Date(fixedNow + 60_000).toISOString();
const pending = await module.loginDreamina(
{
request: async () =>
jsonResponse({
ok: true,
status: {
provider: "dreamina-cli",
state: "login_pending",
installed: true,
authenticated: false,
code: "dreamina_login_pending",
message: privateCanary,
verificationUri: "https://jimeng.jianying.com/ai-tool/cli-auth?verification_uri=https%3A%2F%2Fjimeng.jianying.com%2Fpassport%2Fopen",
userCode: "ABCD-EFGH",
expiresAt,
},
}),
},
{ now: () => fixedNow },
);
expect(pending).toMatchObject({
state: "login_pending",
message: "请在官方页面确认 Dreamina 登录",
userCode: "ABCD-EFGH",
expiresAt,
});
expect(JSON.stringify(pending)).not.toContain(privateCanary);
await expect(
module.getDreaminaStatus(
{
request: async () =>
jsonResponse({
ok: true,
status: { ...installedStatus(), state: "authenticated", authenticated: true },
}),
},
{ now: () => fixedNow },
),
).rejects.toMatchObject({ code: "dreamina_response_invalid" });
});
test("maps unknown Runtime errors and oversized bodies to stable public failures", async () => {
const module = await import("../src/services/local-dreamina-cli");
const privateError = module.getDreaminaStatus(
{
request: async () => jsonResponse({ ok: false, code: "private_error", message: privateCanary }, 500),
},
{ now: () => fixedNow },
);
await expect(privateError).rejects.toMatchObject({
code: "dreamina_internal_error",
message: "Dreamina CLI 请求失败",
});
await expect(privateError).rejects.not.toThrow(privateCanary);
await expect(
module.getDreaminaStatus(
{
request: async () =>
new Response(JSON.stringify({ ok: true, status: installedStatus(), privateCanary }), {
headers: { "content-length": String(70 * 1024) },
}),
},
{ now: () => fixedNow },
),
).rejects.toMatchObject({ code: "dreamina_response_invalid" });
});
test("cancellation and deadlines abort the in-flight signed request", async () => {
const module = await import("../src/services/local-dreamina-cli");
const cancelled = new AbortController();
cancelled.abort();
let calls = 0;
await expect(
module.getDreaminaStatus(
{
request: async () => {
calls++;
return jsonResponse({ ok: true, status: installedStatus() });
},
},
{ signal: cancelled.signal, now: () => fixedNow },
),
).rejects.toMatchObject({ code: "dreamina_cancelled" });
expect(calls).toBe(0);
await expect(
module.getDreaminaStatus(
{
request: async (_path, init) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
}),
},
{ timeoutMs: 5, now: () => fixedNow },
),
).rejects.toMatchObject({ code: "dreamina_timeout" });
});
});
function installedStatus() {
return {
provider: "dreamina-cli",
state: "installed",
installed: true,
authenticated: false,
version: "1.2.3",
code: "dreamina_login_required",
message: "fixture",
};
}
function authenticatedStatus() {
return {
provider: "dreamina-cli",
state: "authenticated",
installed: true,
authenticated: true,
version: "1.2.3",
message: "fixture",
};
}
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
}

View File

@@ -1,197 +0,0 @@
import { expect, test } from "bun:test";
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DreaminaCliRuntime } from "../../canvas-agent/src/dreamina-cli-runtime";
import { createDreaminaHttpModule } from "../../canvas-agent/src/modules/dreamina-http";
import { waitForLocalDreaminaGenerationTask } from "../src/services/local-dreamina-generation";
test("Dreamina cross-Runtime completion survives HTTP wait and the Web parser without a second provider query", async () => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "dreamina-web-cross-runtime-"));
const stateFile = path.join(configDir, "dreamina-runtime-state.json");
const id = "dreamina-web-cross-runtime-0001";
const videoBytes = Buffer.from("00000018667479706d703432", "hex");
await fs.writeFile(
stateFile,
JSON.stringify({
version: 1,
records: [
{
ownerId: "owner-web-cross-runtime-0001",
idempotencyKey: id,
requestHash: "a".repeat(64),
state: "accepted",
submitId: "receipt-web-cross-runtime",
updatedAt: "2026-08-13T00:00:00.000Z",
taskVersion: 1,
operation: "text2video",
mode: "video",
model: "seedance2.0mini",
createdAt: "2026-08-13T00:00:00.000Z",
nextPollAt: "2000-01-01T00:00:00.000Z",
},
],
}),
);
let releaseWinner!: () => void;
let markWinnerEntered!: () => void;
const winnerGate = new Promise<void>((resolve) => {
releaseWinner = resolve;
});
const winnerEntered = new Promise<void>((resolve) => {
markWinnerEntered = resolve;
});
let winnerQueries = 0;
let waiterQueries = 0;
const common = {
ownerId: "owner-web-cross-runtime-0001",
stateFile,
ensureReady: async () => undefined,
discover: async () => ({ installed: true as const, executable: "dreamina-fixture" }),
};
const winner = new DreaminaCliRuntime({
...common,
generationRoot: path.join(configDir, "winner-runs"),
runProcess: async (input) => {
winnerQueries += 1;
if (winnerQueries === 1) {
markWinnerEntered();
await winnerGate;
}
const output = input.args.find((arg) => arg.startsWith("--download_dir="))?.slice("--download_dir=".length);
if (output) await fs.writeFile(path.join(output, "result.mp4"), videoBytes);
return { exitCode: 0, stdout: '{"status":"completed"}', stderr: "" };
},
});
const waiter = new DreaminaCliRuntime({
...common,
generationRoot: path.join(configDir, "waiter-runs"),
runProcess: async () => {
waiterQueries += 1;
return { exitCode: 0, stdout: '{"status":"failed"}', stderr: "" };
},
});
const module = createDreaminaHttpModule({
ownerId: common.ownerId,
configDir,
dreaminaRuntime: waiter,
});
try {
await winner.start();
await winnerEntered;
const parsed = waitForLocalDreaminaGenerationTask(id, "video", {
client: {
async connect() {
return connectedFixture();
},
async request(route, init) {
expect(route).toBe("/dreamina/generate/wait");
const response = await invokeDreaminaHttp(module, route, Buffer.from(String(init?.body)));
return jsonResponse(response.status, response.body);
},
},
});
await Promise.resolve();
releaseWinner();
await expect(parsed).resolves.toEqual({
id,
provider: "dreamina-cli",
mode: "video",
operation: "text2video",
model: "seedance2.0mini",
status: "succeeded",
stage: "succeeded",
progress: 100,
receiptRecorded: true,
officialStatus: "completed",
lifecycle: "TERMINAL",
terminalOutcome: "SUCCEEDED",
syncState: "SYNC_OK",
resultState: "PENDING_MATERIALIZATION",
outputs: [
{
outputIndex: 0,
mediaType: "video",
providerArtifactRef: expect.stringMatching(/^dreamina-provider-artifact:[a-f0-9-]{36}:0$/),
},
],
context: { scope: "legacy_unscoped" },
createdAt: "2026-08-13T00:00:00.000Z",
updatedAt: expect.any(String),
result: {
mode: "video",
video: {
dataUrl: `data:video/mp4;base64,${videoBytes.toString("base64")}`,
mimeType: "video/mp4",
bytes: videoBytes.byteLength,
},
},
});
expect(waiterQueries).toBe(0);
expect(winnerQueries).toBe(2);
const durableText = [await fs.readFile(stateFile, "utf8"), await fs.readFile(path.join(configDir, "dreamina-generation-task-store.json"), "utf8")].join("\n");
expect(durableText).not.toContain("data:video/");
expect(durableText).not.toContain(videoBytes.toString("base64"));
expect(durableText).not.toContain(path.join(configDir, "winner-runs"));
expect(durableText).not.toMatch(/stdout|stderr|token|cookie/i);
} finally {
releaseWinner();
await Promise.allSettled([winner.dispose(), module.dispose?.()]);
await fs.rm(configDir, { recursive: true, force: true });
}
});
async function invokeDreaminaHttp(module: ReturnType<typeof createDreaminaHttpModule>, routePath: string, body: Buffer): Promise<{ status: number; body: unknown }> {
const route = module.routes.find((candidate) => candidate.path === routePath);
if (!route) throw new Error(`missing Dreamina route ${routePath}`);
const req = new EventEmitter() as EventEmitter & { body?: Buffer };
req.body = body;
const res = new EventEmitter() as EventEmitter & {
destroyed: boolean;
writableEnded: boolean;
statusCode: number;
json(value: unknown): void;
status(code: number): typeof res;
};
res.destroyed = false;
res.writableEnded = false;
res.statusCode = 200;
let resolve!: (value: { status: number; body: unknown }) => void;
const response = new Promise<{ status: number; body: unknown }>((done) => {
resolve = done;
});
res.json = (value) => {
res.writableEnded = true;
resolve({ status: res.statusCode, body: value });
};
res.status = (code) => {
res.statusCode = code;
return res;
};
route.handler(req as never, res as never, (error?: unknown) => {
if (error) resolve({ status: 500, body: { ok: false, code: "test_route_error" } });
});
return response;
}
function jsonResponse(status: number, value: unknown) {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
}
function connectedFixture() {
return {
state: "connected" as const,
runtimeVersion: 2,
session: {
sessionId: "session-test-000000000001",
keyId: "browser-key-test",
scopes: ["dreamina:generate"],
expiresAt: "2099-01-01T00:00:00.000Z",
},
};
}

View File

@@ -1,814 +0,0 @@
import { expect, test } from "bun:test";
import {
cancelLocalDreaminaGenerationTask,
deleteLocalDreaminaGenerationTask,
queryLocalDreaminaGenerationTask,
refreshLocalDreaminaGenerationTask,
runLocalDreaminaGenerationTask,
waitForLocalDreaminaGenerationTask,
} from "../src/services/local-dreamina-generation";
import { projectLocalDreaminaTask } from "../src/services/local-dreamina-task-projection";
import { LocalRuntimeSessionClient, type RuntimeBrowserKeyRecord, type RuntimeBrowserKeyStore } from "../src/services/local-runtime-session";
const trustedOrigin = "http://127.0.0.1:18630";
test("deleting a Dreamina task removes only the local record through one signed mutation", async () => {
const requests: Array<{ path: string; method: string; body: string }> = [];
await deleteLocalDreaminaGenerationTask("dreamina-delete-local-0001", {
client: {
async connect() {
return connectedFixture();
},
async request(path, init) {
requests.push({ path, method: String(init?.method), body: String(init?.body) });
return jsonResponse(200, { ok: true, result: { deleted: true } });
},
},
});
expect(requests).toEqual([
{
path: "/dreamina/generate/delete",
method: "POST",
body: JSON.stringify({ idempotencyKey: "dreamina-delete-local-0001" }),
},
]);
});
test("manual Dreamina status refresh sends exactly one signed status-only request", async () => {
const requests: Array<{ path: string; method: string; body: string }> = [];
const task = await refreshLocalDreaminaGenerationTask("dreamina-refresh-local-0001", {
client: {
async connect() {
return connectedFixture();
},
async request(path, init) {
requests.push({ path, method: String(init?.method), body: String(init?.body) });
return jsonResponse(
200,
taskEnvelope("dreamina-refresh-local-0001", "failed", {
officialStatus: "failed",
errorCode: "dreamina_official_failed",
}),
);
},
},
});
expect(requests).toEqual([
{
path: "/dreamina/generate/refresh",
method: "POST",
body: JSON.stringify({ idempotencyKey: "dreamina-refresh-local-0001" }),
},
]);
expect(task).toMatchObject({ status: "failed", officialStatus: "failed", errorCode: "dreamina_official_failed" });
});
test("Dreamina official failure uses stable neutral copy without exposing provider details", async () => {
await expect(
runLocalDreaminaGenerationTask(videoInput(), {
client: {
async connect() {
return connectedFixture();
},
async request() {
const envelope = taskEnvelope("dreamina-official-fail-0001", "failed", {
officialStatus: "failed",
errorCode: "dreamina_official_failed",
});
return jsonResponse(200, { ...envelope, message: "private provider detail" });
},
},
idempotencyKey: () => "dreamina-official-fail-0001",
}),
).rejects.toMatchObject({
code: "dreamina_official_failed",
message: "任务未成功。当前 Dreamina CLI 无法可靠判断是官网取消还是生成失败。",
status: 502,
});
});
test("Dreamina Web parsing preserves the shared orthogonal task contract while keeping the legacy DTO", async () => {
const task = await queryLocalDreaminaGenerationTask("dreamina-contract-web-0001", "video", {
client: {
async connect() {
return connectedFixture();
},
async request() {
return jsonResponse(200, {
ok: true,
result: {
...taskEnvelope("dreamina-contract-web-0001", "failed").result,
officialStatus: "failed",
errorCode: "dreamina_official_failed",
lifecycle: "TERMINAL",
terminalOutcome: "FAILED_OR_CANCELLED",
syncState: "SYNC_OK",
resultState: "NOT_AVAILABLE",
outputs: [],
context: { scope: "legacy_unscoped" },
providerObservation: {
source: "query_result",
observedAt: "2026-08-13T00:00:00.000Z",
accountBinding: "account-binding-fixture",
fenceEpoch: 9,
status: "failed",
},
},
});
},
},
});
const contract = task as unknown as Record<string, unknown>;
expect(contract.lifecycle).toBe("TERMINAL");
expect(contract.terminalOutcome).toBe("FAILED_OR_CANCELLED");
expect(contract.syncState).toBe("SYNC_OK");
expect(contract.resultState).toBe("NOT_AVAILABLE");
expect(contract.context).toEqual({ scope: "legacy_unscoped" });
const projected = projectLocalDreaminaTask(task, {
projectId: "current-project-must-not-bind",
prompt: "fixture",
type: "canvas_video",
attempts: 1,
});
expect(projected.projectId).toBeUndefined();
expect(projected.status).toBe("failed");
expect(projected.error).toBe("任务未成功。当前 Dreamina CLI 无法可靠判断是官网取消还是生成失败。");
});
test("Dreamina SUBMISSION_UNCERTAIN projects as failed attention without claiming official completion", () => {
const projected = projectLocalDreaminaTask({
id: "dreamina-submission-uncertain-web-0001",
provider: "dreamina-cli",
mode: "video",
operation: "text2video",
model: "seedance2.0mini",
status: "failed",
stage: "submission_unknown",
receiptRecorded: false,
lifecycle: "SUBMISSION_UNCERTAIN",
syncState: "SYNC_UNCERTAIN",
resultState: "NOT_AVAILABLE",
outputs: [],
context: { scope: "legacy_unscoped" },
createdAt: "2026-08-13T00:00:00.000Z",
updatedAt: "2026-08-13T00:01:00.000Z",
});
expect(projected.status).toBe("failed");
expect(projected.stage).toBe("submission_unknown");
expect(projected.receiptRecorded).toBe(false);
expect(projected.completedAt).toBeUndefined();
});
test("Dreamina local generation posts only to its signed Runtime route and never to backend tasks", async () => {
const requests: Array<{ path: string; method: string; body: string }> = [];
const result = await runLocalDreaminaGenerationTask(
{
model: "local:dreamina-cli:seedance2.0mini",
mode: "video",
prompt: "A short test clip",
settings: { aspect: "16:9", resolution: "720", duration: 4 },
references: [],
},
{
client: {
async connect() {
return connectedFixture();
},
async request(path, init) {
requests.push({ path, method: String(init?.method), body: String(init?.body) });
return jsonResponse(200, path.endsWith("/wait") ? taskEnvelope("seedance-web-route-0001", "succeeded") : taskEnvelope("seedance-web-route-0001", "running"));
},
},
idempotencyKey: () => "seedance-web-route-0001",
},
);
expect(result).toEqual({
mode: "video",
video: { dataUrl: "data:video/mp4;base64,AAAA", mimeType: "video/mp4", bytes: 3 },
});
expect(requests[0]).toEqual({
path: "/dreamina/generate",
method: "POST",
body: JSON.stringify({
idempotencyKey: "seedance-web-route-0001",
clientOperationId: "seedance-web-route-0001",
operation: "text-to-video",
model: "seedance2.0mini",
prompt: "A short test clip",
settings: { aspect: "16:9", resolution: "720", duration: 4 },
references: [],
context: { scope: "scoped" },
}),
});
expect(requests[1]).toEqual({
path: "/dreamina/generate/wait",
method: "POST",
body: JSON.stringify({ idempotencyKey: "seedance-web-route-0001", mode: "video" }),
});
expect(requests.some((request) => request.path.includes("/tasks"))).toBe(false);
});
test("Dreamina signed generation boundary serializes typed multimodal references and scoped task identity", async () => {
const requests: Array<{ path: string; body: string }> = [];
await runLocalDreaminaGenerationTask(
{
model: "local:dreamina-cli:seedance2.5",
mode: "video",
prompt: "Multimodal signed fixture",
settings: { aspect: "16:9", resolution: "720", duration: 4 },
idempotencyKey: "retry-client-operation-0001",
clientOperationId: "retry-client-operation-0001",
context: {
scope: "scoped",
projectId: "project-signed-0001",
nodeId: "node-signed-0001",
retryOf: "dreamina:prior-signed-0001",
attemptGroupId: "dreamina:prior-signed-0001",
},
references: [
{ kind: "image", mimeType: "image/png", bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), metadata: { name: "image.png", width: 16, height: 9 } },
{ kind: "video", mimeType: "video/mp4", bytes: new Uint8Array([0, 0, 0, 16, 0x66, 0x74, 0x79, 0x70]), metadata: { name: "video.mp4", durationMs: 1000 } },
{ kind: "audio", mimeType: "audio/mpeg", bytes: new Uint8Array([0x49, 0x44, 0x33, 4]), metadata: { name: "audio.mp3", durationMs: 1000 } },
],
} as Parameters<typeof runLocalDreaminaGenerationTask>[0],
{
client: {
async connect() {
return connectedFixture();
},
async request(path, init) {
requests.push({ path, body: String(init?.body) });
return jsonResponse(200, path.endsWith("/wait") ? taskEnvelope("retry-client-operation-0001", "succeeded") : taskEnvelope("retry-client-operation-0001", "running"));
},
},
},
);
expect(JSON.parse(requests[0]!.body)).toEqual({
idempotencyKey: "retry-client-operation-0001",
clientOperationId: "retry-client-operation-0001",
context: {
scope: "scoped",
projectId: "project-signed-0001",
nodeId: "node-signed-0001",
retryOf: "dreamina:prior-signed-0001",
attemptGroupId: "dreamina:prior-signed-0001",
},
operation: "reference-to-video",
model: "seedance2.5",
prompt: "Multimodal signed fixture",
settings: { aspect: "16:9", resolution: "720", duration: 4 },
references: [
{ kind: "image", mimeType: "image/png", contentBase64: "iVBORw==", metadata: { name: "image.png", width: 16, height: 9 } },
{ kind: "video", mimeType: "video/mp4", contentBase64: "AAAAEGZ0eXA=", metadata: { name: "video.mp4", durationMs: 1000 } },
{ kind: "audio", mimeType: "audio/mpeg", contentBase64: "SUQzBA==", metadata: { name: "audio.mp3", durationMs: 1000 } },
],
});
});
test("Dreamina Seedance 2.5 accepts 30 image, 10 video, and 10 audio references while every adapter limit stays strict", async () => {
const makeReference = (kind: "image" | "video" | "audio", index: number) => ({
kind,
mimeType: kind === "image" ? "image/png" : kind === "video" ? "video/mp4" : "audio/mpeg",
bytes: new Uint8Array([index + 1]),
metadata: { name: `${kind}-${index + 1}` },
});
const references = [...Array.from({ length: 30 }, (_, index) => makeReference("image", index)), ...Array.from({ length: 10 }, (_, index) => makeReference("video", index)), ...Array.from({ length: 10 }, (_, index) => makeReference("audio", index))];
let requests = 0;
await runLocalDreaminaGenerationTask(
{
model: "local:dreamina-cli:seedance2.5",
mode: "video",
prompt: "Full multimodal boundary",
settings: { aspect: "16:9", resolution: "720", duration: 4 },
references,
} as Parameters<typeof runLocalDreaminaGenerationTask>[0],
{
client: {
async connect() {
return connectedFixture();
},
async request(path) {
requests += 1;
return jsonResponse(200, path.endsWith("/wait") ? taskEnvelope("seedance-boundary-0001", "succeeded") : taskEnvelope("seedance-boundary-0001", "running"));
},
},
idempotencyKey: () => "seedance-boundary-0001",
},
);
expect(requests).toBe(2);
const invalidCases = [
{
name: "Seedance 2.5 image limit",
model: "local:dreamina-cli:seedance2.5",
references: Array.from({ length: 31 }, (_, index) => makeReference("image", index)),
},
{
name: "Seedance 2.5 video limit",
model: "local:dreamina-cli:seedance2.5",
references: Array.from({ length: 11 }, (_, index) => makeReference("video", index)),
},
{
name: "Seedance 2.5 audio limit",
model: "local:dreamina-cli:seedance2.5",
references: Array.from({ length: 11 }, (_, index) => makeReference("audio", index)),
},
{
name: "other model total limit",
model: "local:dreamina-cli:seedance2.0mini",
references: Array.from({ length: 31 }, (_, index) => makeReference("image", index)),
},
];
for (const fixture of invalidCases) {
let invalidRequests = 0;
await expect(
runLocalDreaminaGenerationTask(
{
model: fixture.model,
mode: "video",
prompt: fixture.name,
settings: { aspect: "16:9", resolution: "720", duration: 4 },
references: fixture.references,
} as Parameters<typeof runLocalDreaminaGenerationTask>[0],
{
client: {
async connect() {
return connectedFixture();
},
async request() {
invalidRequests += 1;
throw new Error("must not request");
},
},
idempotencyKey: () => "seedance-invalid-0001",
},
),
).rejects.toMatchObject({ status: 400 });
expect(invalidRequests).toBe(0);
}
});
test("Dreamina local generation blocks Seedance mini below four seconds before a Runtime request", async () => {
let requests = 0;
await expect(
runLocalDreaminaGenerationTask(
{
model: "local:dreamina-cli:seedance2.0mini",
mode: "video",
prompt: "A short test clip",
settings: { resolution: "720", duration: 3 },
references: [],
},
{
client: {
async connect() {
return connectedFixture();
},
async request() {
requests += 1;
throw new Error("must not request");
},
},
idempotencyKey: () => "seedance-web-route-0002",
},
),
).rejects.toMatchObject({ code: "local_generation_model_unavailable" });
expect(requests).toBe(0);
});
test("Dreamina generation asks to reconnect without exposing origin authorization terminology", async () => {
let requests = 0;
await expect(
runLocalDreaminaGenerationTask(
{
model: "local:dreamina-cli:seedance2.0mini",
mode: "video",
prompt: "A short test clip",
settings: { resolution: "720", duration: 4 },
references: [],
},
{
client: {
async connect() {
return { state: "origin_not_trusted" as const, runtimeVersion: 2 };
},
async request() {
requests += 1;
throw new Error("must not submit");
},
},
idempotencyKey: () => "seedance-reconnect-copy-0001",
},
),
).rejects.toMatchObject({
code: "origin_not_trusted",
message: "本机连接需要重新建立",
status: 403,
});
expect(requests).toBe(0);
});
test("Dreamina generation never replays a paid POST after a 401 or 403 session failure", async () => {
for (const status of [401, 403]) {
let requests = 0;
await expect(
runLocalDreaminaGenerationTask(
{
model: "local:dreamina-cli:seedance2.0mini",
mode: "video",
prompt: "A short test clip",
settings: { resolution: "720", duration: 4 },
references: [],
},
{
client: {
async connect() {
return connectedFixture();
},
async request() {
requests += 1;
return new Response(JSON.stringify({ ok: false, code: "scope_denied" }), { status });
},
},
idempotencyKey: () => `seedance-no-replay-${status}`,
},
),
).rejects.toMatchObject({ code: "local_generation_request_failed", status });
expect(requests).toBe(1);
}
});
test("Dreamina query-only recovery reconnects once after an expired session without replaying generate", async () => {
let connects = 0;
let queries = 0;
let revocations = 0;
const task = await queryLocalDreaminaGenerationTask("dreamina-query-reconnect-0001", "video", {
client: {
async connect() {
connects += 1;
return connectedFixture();
},
revokeLocalSession() {
revocations += 1;
},
async request(path) {
expect(path).toBe("/dreamina/generate/query");
queries += 1;
return queries === 1 ? jsonResponse(403, { ok: false, code: "scope_denied" }) : jsonResponse(200, taskEnvelope("dreamina-query-reconnect-0001", "succeeded"));
},
},
});
expect(task.status).toBe("succeeded");
expect({ connects, queries, revocations }).toEqual({ connects: 2, queries: 2, revocations: 1 });
});
test("Dreamina accepted background action preserves provider reconciliation and sends no generation replay", async () => {
const requests: Array<{ path: string; method: string }> = [];
const task = await cancelLocalDreaminaGenerationTask("dreamina-local-stop-only-0001", {
client: {
async connect() {
return connectedFixture();
},
async request(path, init) {
requests.push({ path, method: String(init?.method) });
return jsonResponse(200, taskEnvelope("dreamina-local-stop-only-0001", "running"));
},
},
});
expect(task).toMatchObject({
status: "running",
receiptRecorded: true,
});
expect(task.errorCode).toBeUndefined();
expect(requests).toEqual([{ path: "/dreamina/generate/cancel", method: "POST" }]);
});
test("Dreamina generation establishes a signed session before exactly one paid POST", async () => {
const runtime = signedRuntimeFixture();
const client = new LocalRuntimeSessionClient({
origin: trustedOrigin,
keyStore: memoryKeyStore(),
fetch: runtime.fetch,
now: () => runtime.now,
});
const result = await runLocalDreaminaGenerationTask(videoInput(), {
client,
idempotencyKey: () => "seedance-session-preflight-0001",
});
expect(result.mode).toBe("video");
expect(runtime.requests.map((request) => new URL(request.url).pathname)).toEqual(["/runtime/info", "/runtime/session/challenge", "/runtime/session/exchange", "/dreamina/generate", "/dreamina/generate/wait"]);
expect(runtime.requests.filter((request) => request.path === "/dreamina/generate")).toHaveLength(1);
});
test("Dreamina generation cancellation during signed session preflight prevents the paid POST", async () => {
const runtime = signedRuntimeFixture({ blockInfoUntilAbort: true });
const client = new LocalRuntimeSessionClient({
origin: trustedOrigin,
keyStore: memoryKeyStore(),
fetch: runtime.fetch,
now: () => runtime.now,
});
const controller = new AbortController();
const pending = runLocalDreaminaGenerationTask(
videoInput(),
{
client,
idempotencyKey: () => "seedance-session-preflight-0002",
},
controller.signal,
);
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(runtime.requests.filter((request) => request.path === "/dreamina/generate")).toHaveLength(0);
});
test("Dreamina generation cancellation during the paid POST propagates without retry", async () => {
const runtime = signedRuntimeFixture({ blockGenerateUntilAbort: true });
const client = new LocalRuntimeSessionClient({
origin: trustedOrigin,
keyStore: memoryKeyStore(),
fetch: runtime.fetch,
now: () => runtime.now,
});
await client.connect();
runtime.requests.length = 0;
const controller = new AbortController();
const pending = runLocalDreaminaGenerationTask(
videoInput(),
{
client,
idempotencyKey: () => "seedance-session-preflight-0003",
},
controller.signal,
);
await Promise.resolve();
controller.abort();
await expect(pending).rejects.toMatchObject({ code: "dreamina_submission_unknown" });
expect(runtime.requests.filter((request) => request.path === "/dreamina/generate")).toHaveLength(1);
});
test("detaching a Dreamina wait never sends a provider cancellation", async () => {
const controller = new AbortController();
const requests: string[] = [];
let waitStarted!: () => void;
const waiting = new Promise<void>((resolve) => {
waitStarted = resolve;
});
const pending = runLocalDreaminaGenerationTask(
videoInput(),
{
client: {
async connect() {
return connectedFixture();
},
async request(path, init) {
requests.push(path);
if (path === "/dreamina/generate") return jsonResponse(200, taskEnvelope("dreamina-detached-wait-0001", "running"));
if (path === "/dreamina/generate/wait") {
waitStarted();
return await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
}
throw new Error(`unexpected mutation: ${path}`);
},
},
idempotencyKey: () => "dreamina-detached-wait-0001",
},
controller.signal,
);
await waiting;
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(requests).toEqual(["/dreamina/generate", "/dreamina/generate/wait"]);
});
test("Dreamina task listing uses one signed read and projects no request content", async () => {
const module = await import("../src/services/local-dreamina-generation");
expect(typeof module.listLocalDreaminaGenerationTasks).toBe("function");
const requests: Array<{ path: string; method: string }> = [];
const tasks = await module.listLocalDreaminaGenerationTasks({
client: {
async connect() {
return connectedFixture();
},
async request(path, init) {
requests.push({ path, method: String(init?.method) });
return jsonResponse(200, {
ok: true,
result: [taskEnvelope("dreamina-list-task-0001", "running").result],
});
},
},
});
expect(requests).toEqual([{ path: "/dreamina/generate/tasks?limit=100", method: "GET" }]);
expect(tasks).toHaveLength(1);
expect(tasks[0]).toMatchObject({ id: "dreamina-list-task-0001", status: "running", receiptRecorded: true });
expect(JSON.stringify(tasks)).not.toMatch(/prompt|submitId|receipt-/i);
});
test("Dreamina task listing follows cursor pages", async () => {
const module = await import("../src/services/local-dreamina-generation");
const paths: string[] = [];
const fixture = taskEnvelope("dreamina-page-task-0001", "running").result as Record<string, unknown>;
const tasks = await module.listLocalDreaminaGenerationTasks({
client: {
async connect() {
return connectedFixture();
},
async request(path) {
paths.push(path);
return paths.length === 1
? jsonResponse(200, { ok: true, result: { tasks: [{ ...fixture, id: "dreamina-page-task-0001" }], nextCursor: "cursor_page_2" } })
: jsonResponse(200, { ok: true, result: { tasks: [{ ...fixture, id: "dreamina-page-task-0002" }] } });
},
},
});
expect(paths).toEqual(["/dreamina/generate/tasks?limit=100", "/dreamina/generate/tasks?limit=100&cursor=cursor_page_2"]);
expect(tasks.map((task) => task.id)).toEqual(["dreamina-page-task-0001", "dreamina-page-task-0002"]);
});
test("Dreamina task listing reconnects once after an expired signed read", async () => {
const module = await import("../src/services/local-dreamina-generation");
let reads = 0;
let revocations = 0;
const tasks = await module.listLocalDreaminaGenerationTasks({
client: {
async connect() {
return connectedFixture();
},
revokeLocalSession() {
revocations += 1;
},
async request() {
reads += 1;
return reads === 1 ? jsonResponse(403, { ok: false, code: "session_invalid" }) : jsonResponse(200, { ok: true, result: [taskEnvelope("dreamina-list-task-0002", "running").result] });
},
},
});
expect(reads).toBe(2);
expect(revocations).toBe(1);
expect(tasks.map((task) => task.id)).toEqual(["dreamina-list-task-0002"]);
});
function videoInput() {
return {
model: "local:dreamina-cli:seedance2.0mini" as const,
mode: "video" as const,
prompt: "A short test clip",
settings: { resolution: "720", duration: 4 },
references: [],
};
}
function memoryKeyStore(): RuntimeBrowserKeyStore & { record?: RuntimeBrowserKeyRecord } {
return {
record: undefined,
async load() {
return this.record;
},
async save(record) {
this.record = record;
},
async clear() {
this.record = undefined;
},
};
}
function signedRuntimeFixture(options: { blockInfoUntilAbort?: boolean; blockGenerateUntilAbort?: boolean } = {}) {
const requests: Array<{ url: string; path: string; method: string }> = [];
const fixture = {
now: Date.parse("2026-08-11T00:00:00.000Z"),
keyId: "",
requests,
fetch: async (input: string | URL | Request, init: RequestInit = {}): Promise<Response> => {
const url = String(input);
const path = new URL(url).pathname;
requests.push({ url, path, method: String(init.method ?? "GET") });
if (path === "/runtime/info" && options.blockInfoUntilAbort) return await rejectOnAbort(init.signal);
if (path === "/runtime/info") {
return jsonResponse(200, {
runtime: "framefield-local-runtime",
apiVersion: 2,
protocolVersion: "framefield-runtime-session-v1",
runtimeInstanceId: "dreamina-generation-fixture",
originTrusted: true,
});
}
if (path === "/runtime/session/challenge") {
const body = JSON.parse(String(init.body)) as { keyId?: string; publicKeyJwk?: JsonWebKey };
fixture.keyId = body.keyId ?? (await keyIdForJwk(body.publicKeyJwk!));
return jsonResponse(200, {
state: "challenge",
challengeId: "challenge-generation-000001",
nonce: "bm9uY2UtZ2VuZXJhdGlvbi0wMDAwMDAwMDAwMDAwMDAwMDAw",
runtimeInstanceId: "dreamina-generation-fixture",
expiresAt: new Date(fixture.now + 60_000).toISOString(),
keyId: fixture.keyId,
});
}
if (path === "/runtime/session/exchange") {
return jsonResponse(200, {
sessionId: "session-generation-0000000001",
keyId: fixture.keyId,
scopes: ["runtime:status", "dreamina:models", "dreamina:generate"],
expiresAt: new Date(fixture.now + 10 * 60_000).toISOString(),
});
}
if (path === "/dreamina/generate" && options.blockGenerateUntilAbort) return await rejectOnAbort(init.signal);
if (path === "/dreamina/generate") {
return jsonResponse(200, taskEnvelope("seedance-session-preflight-0001", "running"));
}
if (path === "/dreamina/generate/wait") {
return jsonResponse(200, taskEnvelope("seedance-session-preflight-0001", "succeeded"));
}
return jsonResponse(404, { ok: false });
},
};
return fixture;
}
async function rejectOnAbort(signal: AbortSignal | null | undefined): Promise<Response> {
if (!signal) throw new Error("expected AbortSignal");
if (signal.aborted) throw signal.reason;
return await new Promise<Response>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
}
function jsonResponse(status: number, value: unknown) {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
}
async function keyIdForJwk(jwk: JsonWebKey) {
const canonical = JSON.stringify({ crv: "P-256", kty: "EC", x: jwk.x, y: jwk.y });
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)));
let binary = "";
for (const byte of digest) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function connectedFixture() {
return {
state: "connected" as const,
runtimeVersion: 2,
session: {
sessionId: "session-test-000000000001",
keyId: "browser-key-test",
scopes: ["dreamina:generate"],
expiresAt: "2099-01-01T00:00:00.000Z",
},
};
}
function taskEnvelope(id: string, status: "running" | "succeeded" | "failed", overrides: Record<string, unknown> = {}) {
return {
ok: true,
result: {
id,
provider: "dreamina-cli",
mode: "video",
operation: "text2video",
model: "seedance2.0mini",
status,
stage: status === "succeeded" ? "succeeded" : status === "failed" ? "failed" : "submitted",
progress: status === "succeeded" ? 100 : 10,
receiptRecorded: true,
createdAt: "2026-08-12T00:00:00.000Z",
updatedAt: "2026-08-12T00:00:00.000Z",
...(status === "succeeded"
? {
result: {
mode: "video",
video: { dataUrl: "data:video/mp4;base64,AAAA", mimeType: "video/mp4", bytes: 3 },
},
}
: {}),
...overrides,
},
};
}

View File

@@ -1,406 +0,0 @@
import { expect, test } from "bun:test";
import { getDreaminaModelCatalog } from "../src/services/local-dreamina-model-catalog";
import { LocalRuntimeClientError } from "../src/services/local-runtime-session";
test("Dreamina model discovery reads only the signed Runtime catalog", async () => {
const requests: string[] = [];
const catalog = await getDreaminaModelCatalog({
async request(path) {
requests.push(path);
return new Response(
JSON.stringify({
ok: true,
provider: "dreamina-cli",
accountBinding: "a".repeat(64),
sessionEpoch: 3,
models: [
{
provider: "dreamina-cli",
id: "seedance2.0mini",
displayName: "seedance2.0mini",
modality: "video",
operations: ["text-to-video", "image-to-video", "reference-to-video"],
adapterSupported: true,
accountEntitlement: "unknown",
currentlyObservedAvailable: "unknown",
settings: { aliases: [], aspects: ["16:9"], maxReferenceImages: 9, minDuration: 4, maxDuration: 15, tiers: ["720p"] },
source: "runtime-execution-contract",
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
});
expect(catalog).toHaveLength(1);
expect(catalog[0]).toMatchObject({
adapterSupported: true,
accountEntitlement: "unknown",
currentlyObservedAvailable: "unknown",
});
expect(catalog[0]?.settings.minDuration).toBe(4);
expect(requests).toEqual(["/dreamina/models"]);
});
test("Dreamina model discovery returns its authenticated cache scope with one signed GET", async () => {
const { getDreaminaModelCatalogSnapshot } = await import("../src/services/local-dreamina-model-catalog");
let requests = 0;
const snapshot = await getDreaminaModelCatalogSnapshot({
async request() {
requests += 1;
return catalogResponse();
},
});
expect(requests).toBe(1);
expect(snapshot).toMatchObject({ accountBinding: "a".repeat(64), sessionEpoch: 3 });
expect(snapshot.models).toHaveLength(1);
});
test("Dreamina model discovery treats a signed scope denial as an error, never a successful empty catalog", async () => {
await expect(
getDreaminaModelCatalog({
async request() {
return new Response(
JSON.stringify({
ok: false,
code: "scope_denied",
message: "public failure",
}),
{ status: 403, headers: { "content-type": "application/json" } },
);
},
}),
).rejects.toThrow("Dreamina model catalog is unavailable");
});
test("Dreamina catalog recovery replaces an obsolete 401/403 session and retries only the safe GET once", async () => {
const module = await import("../src/services/local-dreamina-model-catalog").catch(() => ({}));
const recover = (
module as {
getDreaminaModelCatalogWithSessionRecovery?: (client: { request(path: string, init?: RequestInit): Promise<Response>; connect(signal?: AbortSignal): Promise<{ state: string }>; revokeLocalSession(): void }) => Promise<unknown[]>;
}
).getDreaminaModelCatalogWithSessionRecovery;
expect(typeof recover).toBe("function");
if (!recover) return;
for (const rejectedStatus of [401, 403]) {
const requests: Array<{ path: string; method: string }> = [];
let revoked = 0;
let connected = 0;
const models = await recover({
async request(path, init) {
requests.push({ path, method: String(init?.method) });
if (requests.length === 1) {
return new Response(JSON.stringify({ ok: false }), { status: rejectedStatus });
}
return catalogResponse();
},
async connect() {
connected += 1;
return { state: "connected" };
},
revokeLocalSession() {
revoked += 1;
},
});
expect(models).toHaveLength(1);
expect(requests).toEqual([
{ path: "/dreamina/models", method: "GET" },
{ path: "/dreamina/models", method: "GET" },
]);
expect({ revoked, connected }).toEqual({ revoked: 1, connected: 1 });
}
});
test("Dreamina catalog snapshot recovery preserves the authenticated cache scope after one safe reconnect", async () => {
const module = await import("../src/services/local-dreamina-model-catalog").catch(() => ({}));
const recover = (
module as {
getDreaminaModelCatalogSnapshotWithSessionRecovery?: (client: {
request(path: string, init?: RequestInit): Promise<Response>;
connect(signal?: AbortSignal): Promise<{ state: string }>;
revokeLocalSession(): void;
}) => Promise<{ accountBinding: string; sessionEpoch: number; models: unknown[] }>;
}
).getDreaminaModelCatalogSnapshotWithSessionRecovery;
expect(typeof recover).toBe("function");
if (!recover) return;
let requests = 0;
let revoked = 0;
let connected = 0;
const snapshot = await recover({
async request() {
requests += 1;
if (requests === 1) return new Response(JSON.stringify({ ok: false }), { status: 401 });
return catalogResponse();
},
async connect() {
connected += 1;
return { state: "connected" };
},
revokeLocalSession() {
revoked += 1;
},
});
expect(snapshot).toMatchObject({ accountBinding: "a".repeat(64), sessionEpoch: 3 });
expect(snapshot.models).toHaveLength(1);
expect({ requests, revoked, connected }).toEqual({ requests: 2, revoked: 1, connected: 1 });
});
test("Dreamina catalog recovery reconnects once when the browser rejects an expired session before fetch", async () => {
const { getDreaminaModelCatalogWithSessionRecovery } = await import("../src/services/local-dreamina-model-catalog");
let requests = 0;
let revoked = 0;
let connected = 0;
const models = await getDreaminaModelCatalogWithSessionRecovery({
async request() {
requests += 1;
if (requests === 1) throw new LocalRuntimeClientError("session_required", "public failure", 401);
return catalogResponse();
},
async connect() {
connected += 1;
return { state: "connected" as const };
},
revokeLocalSession() {
revoked += 1;
},
});
expect(models).toHaveLength(1);
expect({ requests, revoked, connected }).toEqual({ requests: 2, revoked: 1, connected: 1 });
});
test("effective config projects an asynchronously arriving Dreamina catalog without persisting the local channel", async () => {
const module = await import("../src/stores/use-config-store").catch(() => ({}));
const project = (
module as {
effectiveConfigWithDreamina?: (
config: typeof import("../src/stores/use-config-store").defaultConfig,
state: "idle" | "loading" | "ready" | "error",
models: Array<Record<string, unknown>>,
) => typeof import("../src/stores/use-config-store").defaultConfig;
}
).effectiveConfigWithDreamina;
expect(typeof project).toBe("function");
if (!project) return;
const { defaultConfig } = await import("../src/stores/use-config-store");
const pending = project(defaultConfig, "loading", []);
const ready = project(defaultConfig, "ready", [
{
provider: "dreamina-cli",
id: "seedance2.0mini",
displayName: "seedance2.0mini",
modality: "video",
operations: ["text-to-video", "image-to-video", "reference-to-video"],
adapterSupported: true,
accountEntitlement: "unknown",
currentlyObservedAvailable: "unknown",
settings: { aliases: [], aspects: ["16:9"], maxReferenceImages: 9, minDuration: 4, maxDuration: 15, tiers: ["720p"] },
source: "runtime-execution-contract",
},
]);
expect(pending.videoModels).not.toContain("local:dreamina-cli:seedance2.0mini");
expect(ready.videoModels).toContain("local:dreamina-cli:seedance2.0mini");
expect(defaultConfig.channels.some((channel) => channel.id === "local:dreamina-cli")).toBe(false);
});
test("Dreamina catalog readiness is loading immediately and shares one in-flight bootstrap", async () => {
const module = await import("../src/stores/use-local-dreamina-model-store").catch(() => ({}));
const createStore = (
module as {
createLocalDreaminaModelStore?: (dependencies: Record<string, unknown>) => {
getState(): {
state: string;
models: Array<{ id: string }>;
ensureReady(signal?: AbortSignal): Promise<Array<{ id: string }>>;
};
};
}
).createLocalDreaminaModelStore;
expect(typeof createStore).toBe("function");
if (!createStore) return;
let requests = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const store = createStore({
getRuntimeState: () => ({ connection: "connected", modules: [{ id: "dreamina", scopes: ["dreamina:models"] }] }),
getClient: () => ({ request: async () => catalogResponse() }),
loadSnapshot: async () => {
requests += 1;
await gate;
return {
accountBinding: "a".repeat(64),
sessionEpoch: 3,
models: [
{
provider: "dreamina-cli",
id: "seedance2.0mini",
displayName: "seedance2.0mini",
modality: "video",
operations: ["text-to-video"],
adapterSupported: true,
accountEntitlement: "unknown",
currentlyObservedAvailable: "unknown",
settings: { aliases: [], aspects: ["16:9"], maxReferenceImages: 9, minDuration: 4, maxDuration: 15, tiers: ["720p"] },
source: "runtime-execution-contract",
},
],
};
},
});
const first = store.getState().ensureReady();
const second = store.getState().ensureReady();
expect(store.getState().state).toBe("loading");
expect(requests).toBe(1);
release();
const [firstModels, secondModels] = await Promise.all([first, second]);
expect(firstModels.map((item) => item.id)).toEqual(["seedance2.0mini"]);
expect(secondModels).toEqual(firstModels);
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:3` });
expect(requests).toBe(1);
});
test("Dreamina catalog timeout releases a hung shared request so generation can recover without reload", async () => {
const { createLocalDreaminaModelStore } = await import("../src/stores/use-local-dreamina-model-store");
let requests = 0;
const store = createLocalDreaminaModelStore({
getRuntimeState: () => ({ connection: "connected", modules: [{ id: "dreamina", scopes: ["dreamina:models"] }] }),
getClient: () => ({ request: async () => catalogResponse() }),
requestTimeoutMs: 10,
loadSnapshot: async (_client, signal) => {
requests += 1;
if (requests > 1) return catalogSnapshot("seedance-recovered", 4);
if (!signal) return await new Promise<ReturnType<typeof catalogSnapshot>>(() => undefined);
return await new Promise<ReturnType<typeof catalogSnapshot>>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
},
});
const caller = new AbortController();
const first = store.getState().ensureReady(caller.signal);
caller.abort();
await expect(first).rejects.toMatchObject({ name: "AbortError" });
await Bun.sleep(20);
const recovered = await Promise.race([store.getState().ensureReady(), Bun.sleep(40).then(() => "stuck" as const)]);
expect(recovered).not.toBe("stuck");
expect(recovered).toMatchObject([{ id: "seedance-recovered" }]);
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:4` });
expect(requests).toBe(2);
});
test("Dreamina catalog forced resync ignores a late result from the previous Runtime scope", async () => {
const { createLocalDreaminaModelStore } = await import("../src/stores/use-local-dreamina-model-store");
let requests = 0;
let releaseOld!: (snapshot: ReturnType<typeof catalogSnapshot>) => void;
const oldRequest = new Promise<ReturnType<typeof catalogSnapshot>>((resolve) => {
releaseOld = resolve;
});
const store = createLocalDreaminaModelStore({
getRuntimeState: () => ({ connection: "connected", modules: [{ id: "dreamina", scopes: ["dreamina:models"] }] }),
getClient: () => ({ request: async () => catalogResponse() }),
requestTimeoutMs: 100,
loadSnapshot: async () => {
requests += 1;
return requests === 1 ? oldRequest : catalogSnapshot("seedance-current", 5);
},
});
const oldSync = store.getState().sync();
const currentSync = store.getState().sync();
const currentResult = await Promise.race([currentSync.then(() => "ready" as const), Bun.sleep(40).then(() => "stuck" as const)]);
expect(currentResult).toBe("ready");
expect(requests).toBe(2);
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:5`, models: [{ id: "seedance-current" }] });
releaseOld(catalogSnapshot("seedance-stale", 3));
await oldSync;
expect(store.getState()).toMatchObject({ state: "ready", cacheScope: `${"a".repeat(64)}:5`, models: [{ id: "seedance-current" }] });
});
test("effective config removes custom channels when administrators disable them", async () => {
const { createModelChannel, effectiveConfigForCustomChannels, normalizeConfigSnapshot } = await import("../src/stores/use-config-store");
const config = normalizeConfigSnapshot({
config: {
channels: [
createModelChannel({
id: "system-1",
scope: "system",
name: "系统渠道",
baseUrl: "/api/ai/system/system-1",
apiKey: "system",
models: ["system-model"],
modelCosts: [{ model: "system-model", capability: "text", billingMode: "fixed_request", unitPriceMicrocredits: 0 }],
}),
createModelChannel({ id: "custom-1", scope: "user", name: "自定义渠道", baseUrl: "https://example.com", apiKey: "private-key", models: ["custom-model"] }),
],
},
}).config;
const effective = effectiveConfigForCustomChannels(config, false);
expect(effective.channels.map((channel) => channel.id)).toEqual(["system-1"]);
expect(effective.models).toContain("system-1::system-model");
expect(effective.models).not.toContain("custom-1::custom-model");
expect(config.channels.map((channel) => channel.id)).toEqual(["system-1", "custom-1"]);
});
function catalogResponse() {
return new Response(
JSON.stringify({
ok: true,
provider: "dreamina-cli",
accountBinding: "a".repeat(64),
sessionEpoch: 3,
models: [
{
provider: "dreamina-cli",
id: "seedance2.0mini",
displayName: "seedance2.0mini",
modality: "video",
operations: ["text-to-video", "image-to-video", "reference-to-video"],
adapterSupported: true,
accountEntitlement: "unknown",
currentlyObservedAvailable: "unknown",
settings: { aliases: [], aspects: ["16:9"], maxReferenceImages: 9, minDuration: 4, maxDuration: 15, tiers: ["720p"] },
source: "runtime-execution-contract",
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
function catalogSnapshot(id: string, sessionEpoch: number) {
return {
accountBinding: "a".repeat(64),
sessionEpoch,
models: [
{
provider: "dreamina-cli" as const,
id,
displayName: id,
modality: "video" as const,
operations: ["text-to-video" as const],
adapterSupported: true,
accountEntitlement: "unknown" as const,
currentlyObservedAvailable: "unknown" as const,
settings: { aliases: [], aspects: ["16:9"], maxReferenceImages: 9, minDuration: 4, maxDuration: 15, tiers: ["720p"] },
source: "runtime-execution-contract" as const,
},
],
};
}

View File

@@ -1,60 +0,0 @@
import { expect, test } from "bun:test";
const legacyToken = "legacy-master-token-canary";
test("legacy query and storage secrets are removed before application modules load", async () => {
const module = await import("../src/services/local-runtime-bootstrap").catch(() => ({}));
const runBootstrap = (
module as {
runLocalRuntimeBootstrap?: (environment: BootstrapEnvironment, loadApplication: (state: { legacyDeepLinkRejected: boolean }) => void) => { legacyDeepLinkRejected: boolean };
}
).runLocalRuntimeBootstrap;
expect(typeof runBootstrap).toBe("function");
if (!runBootstrap) return;
let href = `http://127.0.0.1:3001/canvas/demo?keep=1&agentUrl=http%3A%2F%2F127.0.0.1%3A17371&agentToken=${legacyToken}#timeline`;
const storage = new Map([
["canvas-agent-url", "http://127.0.0.1:17371"],
["canvas-agent-token", legacyToken],
["unrelated", "keep"],
]);
let rendered = false;
const state = runBootstrap(
{
get href() {
return href;
},
replaceUrl(url) {
href = new URL(url, href).toString();
},
removeStorageItem(key) {
storage.delete(key);
},
},
(bootstrapState) => {
rendered = true;
expect(href).toBe("http://127.0.0.1:3001/canvas/demo?keep=1#timeline");
expect(storage.has("canvas-agent-url")).toBe(false);
expect(storage.has("canvas-agent-token")).toBe(false);
expect(storage.get("unrelated")).toBe("keep");
expect(JSON.stringify(bootstrapState)).not.toContain(legacyToken);
},
);
expect(rendered).toBe(true);
expect(state).toEqual({ legacyDeepLinkRejected: true });
expect(href).not.toContain(legacyToken);
const readState = (
module as {
readLocalRuntimeBootstrapState?: () => { legacyDeepLinkRejected: boolean };
}
).readLocalRuntimeBootstrapState;
expect(typeof readState).toBe("function");
expect(readState?.()).toEqual({ legacyDeepLinkRejected: true });
});
type BootstrapEnvironment = {
readonly href: string;
replaceUrl(url: string): void;
removeStorageItem(key: string): void;
};

View File

@@ -1,357 +0,0 @@
import { expect, test } from "bun:test";
import fs from "node:fs/promises";
import { LocalRuntimeClientError } from "../src/services/local-runtime-session";
const privateCanary = "runtime-private-canary";
test("Runtime status projects only exact public module descriptors", async () => {
const module = await import("../src/services/local-runtime").catch(() => ({}));
const readStatus = (
module as {
readLocalRuntimeStatus?: (client: RuntimeTransport) => Promise<unknown>;
}
).readLocalRuntimeStatus;
expect(typeof readStatus).toBe("function");
if (!readStatus) return;
const requests: Array<{ path: string; init?: RequestInit }> = [];
const client: RuntimeTransport = {
async request(path, init) {
requests.push({ path, init });
return jsonResponse({
ok: true,
runtime: { id: "framefield-local-runtime", version: "0.1.0", apiVersion: 2 },
modules: [
{
id: "canvas-agent",
displayName: "Canvas Agent",
apiVersion: 1,
scopes: ["canvas:connect"],
},
{
id: "dreamina",
displayName: "Dreamina CLI",
apiVersion: 1,
scopes: ["dreamina:status", "dreamina:login", "dreamina:logout", "dreamina:models", "dreamina:generate"],
},
],
});
},
};
const status = await readStatus(client);
expect(requests).toHaveLength(1);
expect(requests[0]).toMatchObject({ path: "/runtime/status", init: { method: "GET" } });
expect(status).toEqual({
runtime: { id: "framefield-local-runtime", version: "0.1.0", apiVersion: 2 },
modules: [
{ id: "canvas-agent", displayName: "Canvas Agent", apiVersion: 1, scopes: ["canvas:connect"] },
{ id: "dreamina", displayName: "Dreamina CLI", apiVersion: 1, scopes: ["dreamina:status", "dreamina:login", "dreamina:logout", "dreamina:models", "dreamina:generate"] },
],
});
expect(JSON.stringify(status)).not.toContain("ownerId");
expect(JSON.stringify(status)).not.toContain("token");
});
test("Runtime status rejects sensitive or contradictory response fields without echoing them", async () => {
const module = await import("../src/services/local-runtime").catch(() => ({}));
const readStatus = (
module as {
readLocalRuntimeStatus?: (client: RuntimeTransport) => Promise<unknown>;
}
).readLocalRuntimeStatus;
expect(typeof readStatus).toBe("function");
if (!readStatus) return;
const fixtures = [
{
ok: true,
runtime: { id: "framefield-local-runtime", version: "0.1.0", apiVersion: 2, ownerId: privateCanary },
modules: [],
},
{
ok: true,
runtime: { id: "framefield-local-runtime", version: "0.1.0", apiVersion: 2 },
modules: [
{ id: "dreamina", displayName: "Dreamina CLI", apiVersion: 1, scopes: ["dreamina:status"] },
{ id: "dreamina", displayName: "Dreamina CLI", apiVersion: 1, scopes: ["dreamina:status"] },
],
},
{
ok: false,
code: "private_error",
message: privateCanary,
},
];
for (const [index, body] of fixtures.entries()) {
let caught: unknown;
try {
await readStatus({ request: async () => jsonResponse(body, index === 2 ? 500 : 200) });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(Error);
expect(String(caught)).not.toContain(privateCanary);
}
});
test("Runtime store automatically connects and reads descriptors without storing endpoint or bearer data", async () => {
const module = await import("../src/stores/use-local-runtime-store").catch(() => ({}));
const createStore = (
module as {
createLocalRuntimeStore?: (dependencies: unknown) => RuntimeStore;
}
).createLocalRuntimeStore;
expect(typeof createStore).toBe("function");
if (!createStore) return;
let statusRequests = 0;
const client = {
async connect() {
return {
state: "connected" as const,
runtimeVersion: 2,
session: {
sessionId: "public-session-handle",
keyId: "k".repeat(43),
scopes: ["runtime:status"],
expiresAt: "2026-08-10T00:10:00.000Z",
},
};
},
async request() {
statusRequests++;
return runtimeStatusResponse();
},
};
const store = createStore({ client, timeoutMs: 1_000 });
await store.getState().connect();
const connected = store.getState();
expect(connected.connection).toBe("connected");
expect(connected.modules.map((item) => item.id)).toEqual(["canvas-agent", "dreamina"]);
expect(statusRequests).toBe(1);
expect("endpoint" in connected).toBe(false);
expect("token" in connected).toBe(false);
expect("session" in connected).toBe(false);
});
test("Runtime store replaces a stale signed session after Runtime restart before publishing connected", async () => {
const module = await import("../src/stores/use-local-runtime-store");
let connects = 0;
let revocations = 0;
let statusReads = 0;
const store = module.createLocalRuntimeStore({
client: {
async connect() {
connects += 1;
return {
state: "connected" as const,
runtimeVersion: 2,
session: { sessionId: `session-${connects}`, keyId: "k".repeat(43), scopes: ["runtime:status"], expiresAt: "2099-01-01T00:00:00.000Z" },
};
},
revokeLocalSession() {
revocations += 1;
},
async request() {
statusReads += 1;
if (statusReads === 1) throw new LocalRuntimeClientError("session_required", "stale", 401);
return runtimeStatusResponse();
},
},
timeoutMs: 1_000,
});
await store.getState().connect();
expect({ connects, revocations, statusReads }).toEqual({ connects: 2, revocations: 1, statusReads: 2 });
expect(store.getState()).toMatchObject({ connection: "connected", connecting: false, error: "" });
expect(store.getState().modules.map((item) => item.id)).toEqual(["canvas-agent", "dreamina"]);
});
test("Runtime store exposes an actionable reconnect message without origin or authorization terminology", async () => {
const module = await import("../src/stores/use-local-runtime-store");
const store = module.createLocalRuntimeStore({
client: {
connect: async () => ({ state: "origin_not_trusted" as const, runtimeVersion: 2 }),
request: async () => {
throw new Error("must not read Runtime status");
},
},
timeoutMs: 1_000,
});
await store.getState().connect();
expect(store.getState()).toMatchObject({
connection: "origin_not_trusted",
connecting: false,
error: "本机连接需要重新建立",
});
expect(store.getState().error).not.toMatch(/来源|Origin|授权|Runtime/i);
});
test("Runtime store ignores an older connection result after a newer request succeeds", async () => {
const module = await import("../src/stores/use-local-runtime-store").catch(() => ({}));
const createStore = (
module as {
createLocalRuntimeStore?: (dependencies: unknown) => RuntimeStore;
}
).createLocalRuntimeStore;
expect(typeof createStore).toBe("function");
if (!createStore) return;
let resolveFirst!: (value: { state: "origin_not_trusted"; runtimeVersion: number }) => void;
const first = new Promise<{ state: "origin_not_trusted"; runtimeVersion: number }>((resolve) => {
resolveFirst = resolve;
});
let calls = 0;
const store = createStore({
client: {
connect: async () =>
calls++ === 0
? await first
: {
state: "connected" as const,
runtimeVersion: 2,
session: {
sessionId: "public-session-handle",
keyId: "k".repeat(43),
scopes: ["runtime:status"],
expiresAt: "2026-08-10T00:10:00.000Z",
},
},
request: async () => runtimeStatusResponse(),
},
timeoutMs: 1_000,
});
const stale = store.getState().connect();
await Promise.resolve();
await store.getState().connect();
resolveFirst({ state: "origin_not_trusted", runtimeVersion: 2 });
await stale;
expect(store.getState().connection).toBe("connected");
expect(store.getState().runtime?.id).toBe("framefield-local-runtime");
});
test("Runtime store does not dispatch an already-cancelled connection", async () => {
const module = await import("../src/stores/use-local-runtime-store");
let connectCalls = 0;
const store = module.createLocalRuntimeStore({
client: {
connect: async () => {
connectCalls++;
throw new DOMException("aborted", "AbortError");
},
request: async () => runtimeStatusResponse(),
},
timeoutMs: 1_000,
});
const controller = new AbortController();
controller.abort();
await store.getState().connect(controller.signal);
expect(connectCalls).toBe(0);
expect(store.getState()).toMatchObject({ connection: "idle", connecting: false, error: "" });
});
test("Runtime store exposes a bounded timeout instead of hanging", async () => {
const module = await import("../src/stores/use-local-runtime-store");
const store = module.createLocalRuntimeStore({
client: {
connect: async (signal?: AbortSignal) =>
await new Promise((_resolve, reject) => {
signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
}),
request: async () => runtimeStatusResponse(),
},
timeoutMs: 5,
});
await store.getState().connect();
expect(store.getState()).toMatchObject({
connection: "unreachable",
connecting: false,
error: "本机服务连接超时",
});
});
test("the application root starts local Runtime discovery before provider catalogs bootstrap", async () => {
const source = await fs.readFile(new URL("../src/components/layout/client-root-init.tsx", import.meta.url), "utf8");
expect(source).toContain("useLocalRuntimeBootstrap");
expect(source.indexOf("useLocalRuntimeBootstrap()")).toBeLessThan(source.indexOf("useLocalDreaminaModelBootstrap()"));
});
test("Runtime bootstrap schedules one connect and aborts it on cleanup", async () => {
const module = (await import("../src/stores/use-local-runtime-store")) as {
startLocalRuntimeBootstrap?: (connect: (signal?: AbortSignal) => Promise<void>, schedule: (run: () => void) => () => void) => () => void;
};
expect(typeof module.startLocalRuntimeBootstrap).toBe("function");
if (!module.startLocalRuntimeBootstrap) return;
let scheduled: (() => void) | undefined;
let cancelled = 0;
const signals: AbortSignal[] = [];
const cleanup = module.startLocalRuntimeBootstrap(
async (signal) => {
if (signal) signals.push(signal);
},
(run) => {
scheduled = run;
return () => {
cancelled += 1;
};
},
);
expect(signals).toHaveLength(0);
scheduled?.();
scheduled?.();
expect(signals).toHaveLength(1);
cleanup();
expect({ cancelled, aborted: signals[0]?.aborted }).toEqual({ cancelled: 1, aborted: true });
});
type RuntimeTransport = {
request(path: string, init?: RequestInit): Promise<Response>;
};
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
}
function runtimeStatusResponse() {
return jsonResponse({
ok: true,
runtime: { id: "framefield-local-runtime", version: "0.1.0", apiVersion: 2 },
modules: [
{ id: "canvas-agent", displayName: "Canvas Agent", apiVersion: 1, scopes: ["canvas:connect"] },
{
id: "dreamina",
displayName: "Dreamina CLI",
apiVersion: 1,
scopes: ["dreamina:status", "dreamina:login", "dreamina:logout"],
},
],
});
}
type RuntimeStore = {
getState(): {
connection: string;
connecting: boolean;
runtime: null | { id: string };
modules: Array<{ id: string }>;
connect(): Promise<void>;
};
};

View File

@@ -1,229 +0,0 @@
import { describe, expect, test } from "bun:test";
import { LOCAL_RUNTIME_ENDPOINT, LocalRuntimeClientError, LocalRuntimeSessionClient, resolveLocalRuntimeEndpoint, type RuntimeBrowserKeyRecord, type RuntimeBrowserKeyStore } from "../src/services/local-runtime-session";
const origin = "http://127.0.0.1:3001";
const runtimeInstanceId = "web-runtime-fixture";
const canary = "master-token-canary-must-not-leak";
describe("Local Runtime signed browser session", () => {
test("build-time endpoint accepts one exact isolated loopback origin without scanning", () => {
expect(resolveLocalRuntimeEndpoint(undefined)).toBe("http://127.0.0.1:17371");
expect(resolveLocalRuntimeEndpoint("http://127.0.0.1:31731")).toBe("http://127.0.0.1:31731");
for (const invalid of ["http://localhost:31731", "http://127.0.0.1:31731/path", "http://127.0.0.1:31731?next=1", "https://127.0.0.1:31731", "http://127.0.0.1:17371,http://127.0.0.1:31731"]) {
expect(() => resolveLocalRuntimeEndpoint(invalid)).toThrow();
}
});
test("first trusted connection persists only a non-extractable browser key and returns a signed session", async () => {
const keyStore = memoryKeyStore();
const runtime = runtimeFetchFixture();
const client = new LocalRuntimeSessionClient({
origin,
keyStore,
fetch: runtime.fetch,
now: () => runtime.now,
});
const result = await client.connect();
expect(result.state).toBe("connected");
if (result.state !== "connected") throw new Error("expected connected");
expect(JSON.stringify(result)).not.toContain("pair");
expect(keyStore.record?.privateKey.extractable).toBe(false);
expect(keyStore.record?.privateKey.usages).toEqual(["sign"]);
expect(keyStore.record?.publicKeyJwk.d).toBeUndefined();
expect(JSON.stringify(keyStore.record)).not.toContain(canary);
expect(client.currentSession()?.keyId).toBe(keyStore.record?.keyId);
expect(runtime.requests[0].url).toBe(`${LOCAL_RUNTIME_ENDPOINT}/runtime/info`);
expect(runtime.requests.every((request) => request.credentials === "omit")).toBe(true);
expect(runtime.requests.every((request) => request.redirect === "error")).toBe(true);
});
test("silent trusted connection creates only an in-memory session and signs each protected request", async () => {
const keyStore = memoryKeyStore();
const runtime = runtimeFetchFixture();
const client = new LocalRuntimeSessionClient({
origin,
keyStore,
fetch: runtime.fetch,
now: () => runtime.now,
});
expect((await client.connect()).state).toBe("connected");
const session = client.currentSession();
expect(session?.sessionId).toBe("session-fixture-000000000001");
expect(JSON.stringify(keyStore.record)).not.toContain(session?.sessionId ?? "missing");
const response = await client.request("/dreamina/status", { method: "GET" });
expect(response.status).toBe(200);
const protectedRequest = runtime.requests.at(-1)!;
expect(protectedRequest.url).toBe(`${LOCAL_RUNTIME_ENDPOINT}/dreamina/status`);
expect(protectedRequest.url).not.toContain("token");
expect(protectedRequest.headers.get("x-framefield-runtime-session")).toBe(session?.sessionId);
expect(protectedRequest.headers.get("x-framefield-runtime-nonce")).toMatch(/^[A-Za-z0-9_-]{22}$/);
expect(protectedRequest.headers.get("x-framefield-runtime-proof")).toMatch(/^[A-Za-z0-9_-]{86}$/);
expect(protectedRequest.headers.has("authorization")).toBe(false);
expect(protectedRequest.headers.has("x-canvas-agent-token")).toBe(false);
expect(JSON.stringify(runtime.requests)).not.toContain(canary);
});
test("a new page reuses the browser key but obtains a new session without a bearer fallback", async () => {
const keyStore = memoryKeyStore();
const runtime = runtimeFetchFixture();
const first = new LocalRuntimeSessionClient({ origin, keyStore, fetch: runtime.fetch, now: () => runtime.now });
await first.connect();
const savedKeyId = keyStore.record?.keyId;
const second = new LocalRuntimeSessionClient({ origin, keyStore, fetch: runtime.fetch, now: () => runtime.now });
const result = await second.connect();
expect(result.state).toBe("connected");
expect(second.currentSession()?.keyId).toBe(savedKeyId);
const challengeBody = runtime.requests.filter((request) => request.url.endsWith("/runtime/session/challenge")).at(-1)?.body;
expect(challengeBody).toBe(JSON.stringify({ keyId: savedKeyId }));
expect(challengeBody).not.toContain("publicKeyJwk");
});
test("untrusted Runtime error bodies cannot enter browser errors", async () => {
const client = new LocalRuntimeSessionClient({
origin,
keyStore: memoryKeyStore(),
fetch: async () =>
new Response(
JSON.stringify({
code: "private_runtime_error",
message: canary,
detail: "C:\\Users\\owner\\Cookies",
}),
{ status: 500, headers: { "content-type": "application/json" } },
),
});
let caught: unknown;
try {
await client.connect();
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(LocalRuntimeClientError);
expect(caught).toMatchObject({ code: "runtime_request_failed", message: "本机运行时请求失败", status: 500 });
expect(String(caught)).not.toContain(canary);
expect(String(caught)).not.toContain("Cookies");
});
test("client fails closed if an obsolete browser-confirmation response is returned", async () => {
const runtime = runtimeFetchFixture();
runtime.obsoleteChallenge = true;
const client = new LocalRuntimeSessionClient({ origin, keyStore: memoryKeyStore(), fetch: runtime.fetch, now: () => runtime.now });
await expect(client.connect()).rejects.toBeInstanceOf(LocalRuntimeClientError);
});
});
function memoryKeyStore(): RuntimeBrowserKeyStore & { record?: RuntimeBrowserKeyRecord } {
return {
record: undefined,
async load() {
return this.record;
},
async save(record) {
this.record = record;
},
async clear() {
this.record = undefined;
},
};
}
function runtimeFetchFixture() {
const requests: Array<{
url: string;
method: string;
headers: Headers;
body?: string;
credentials?: RequestCredentials;
redirect?: RequestRedirect;
}> = [];
const fixture = {
now: Date.parse("2026-08-10T00:00:00.000Z"),
obsoleteChallenge: false,
keyId: "",
requests,
fetch: async (input: string | URL | Request, init: RequestInit = {}) => {
const url = String(input);
const headers = new Headers(init.headers);
const body = typeof init.body === "string" ? init.body : undefined;
requests.push({
url,
method: init.method ?? "GET",
headers,
body,
credentials: init.credentials,
redirect: init.redirect,
});
if (url.endsWith("/runtime/info")) {
return jsonResponse(200, {
runtime: "framefield-local-runtime",
apiVersion: 2,
protocolVersion: "framefield-runtime-session-v1",
runtimeInstanceId,
originTrusted: true,
});
}
if (url.endsWith("/runtime/session/challenge")) {
const payload = JSON.parse(body ?? "{}") as { keyId?: string; publicKeyJwk?: JsonWebKey };
fixture.keyId = payload.keyId ?? (await keyIdForJwk(payload.publicKeyJwk!));
const base = {
challengeId: "challenge-fixture-000001",
nonce: "bm9uY2UtZml4dHVyZS0wMDAwMDAwMDAwMDAwMDAwMDAwMDA",
runtimeInstanceId,
expiresAt: new Date(fixture.now + 60_000).toISOString(),
keyId: fixture.keyId,
};
return jsonResponse(
200,
fixture.obsoleteChallenge
? {
state: "obsolete_browser_confirmation",
...base,
}
: { state: "challenge", ...base },
);
}
if (url.endsWith("/runtime/session/exchange")) {
return jsonResponse(200, {
sessionId: "session-fixture-000000000001",
keyId: fixture.keyId,
scopes: ["runtime:status", "dreamina:status", "dreamina:login", "dreamina:logout", "canvas:connect"],
expiresAt: new Date(fixture.now + 10 * 60_000).toISOString(),
});
}
if (url.endsWith("/dreamina/status")) {
return jsonResponse(200, { ok: true, status: { provider: "dreamina-cli", state: "installed" } });
}
return jsonResponse(404, { ok: false });
},
};
return fixture;
}
function jsonResponse(status: number, value: unknown) {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
}
async function keyIdForJwk(jwk: JsonWebKey) {
const canonical = JSON.stringify({ crv: "P-256", kty: "EC", x: jwk.x, y: jwk.y });
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)));
return base64Url(digest);
}
function base64Url(value: Uint8Array) {
let binary = "";
for (const byte of value) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

View File

@@ -1,108 +0,0 @@
import { describe, expect, test } from "bun:test";
import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy";
import { findPendingMediaConversionInput } from "../src/components/canvas/canvas-node-generation";
import { getNodeAcceptedInputKinds, getNodeMaxInputCount, getNodeResourceKind } from "../src/lib/canvas/node-registry";
import { mediaConversionSourceFingerprint } from "../src/lib/media-conversion/contracts";
import { convertImageLocally, LocalImageConversionError, transformPixels } from "../src/lib/media-conversion/local-converter";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
import type { AiConfig } from "../src/stores/use-config-store";
function node(type: CanvasNodeType, id: string, metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData {
return { id, type, title: id, position: { x: 0, y: 0 }, width: 380, height: 440, metadata };
}
const config = {} as AiConfig;
describe("本地转换节点注册", () => {
test("只允许一个图片或视频输入,完成后才作为图片素材", () => {
expect(getNodeAcceptedInputKinds(CanvasNodeType.MediaConversion)).toEqual(["image", "video"]);
expect(getNodeMaxInputCount(CanvasNodeType.MediaConversion)).toBe(1);
expect(getNodeResourceKind(node(CanvasNodeType.MediaConversion, "conversion"))).toBeNull();
expect(getNodeResourceKind(node(CanvasNodeType.MediaConversion, "conversion", {
mediaConversion: { schemaVersion: 1, operation: "grayscale", status: "completed", resultStorageKey: "image:scope:result" },
}))).toBe("image");
expect(getNodeResourceKind(node(CanvasNodeType.MediaConversion, "conversion", {
mediaConversion: { schemaVersion: 1, operation: "pose", status: "skipped", detectedPeople: 0 },
}))).toBeNull();
});
test("连接规则接受图片和视频,拒绝没有可读媒体的节点及第二个输入", () => {
const image = node(CanvasNodeType.Image, "image");
const video = node(CanvasNodeType.Video, "video");
const text = node(CanvasNodeType.Text, "text");
const conversion = node(CanvasNodeType.MediaConversion, "conversion");
const nodes = [image, video, text, conversion];
const candidate = (fromNodeId: string): CanvasConnection => ({ id: `candidate-${fromNodeId}`, fromNodeId, toNodeId: conversion.id });
expect(canvasConnectionError(config, nodes, [], candidate(image.id))).toBe("");
expect(canvasConnectionError(config, nodes, [], candidate(video.id))).toBe("");
expect(canvasConnectionError(config, nodes, [], candidate(text.id))).toContain("转换节点只接受图片或视频输入");
expect(canvasConnectionError(config, nodes, [{ id: "existing", fromNodeId: image.id, toNodeId: conversion.id }], candidate(video.id))).toContain("转换节点最多连接 1 个输入");
});
test("下游生成会阻止未完成或待更新的转换结果", () => {
const conversion = node(CanvasNodeType.MediaConversion, "conversion", {
mediaConversion: { schemaVersion: 1, operation: "depth", status: "unavailable" },
});
const input = node(CanvasNodeType.Image, "input", { content: "data:image/png;base64,source" });
const target = node(CanvasNodeType.Image, "target");
const connections: CanvasConnection[] = [
{ id: "input-conversion", fromNodeId: input.id, toNodeId: conversion.id },
{ id: "conversion-target", fromNodeId: conversion.id, toNodeId: target.id },
];
expect(findPendingMediaConversionInput(target.id, [input, conversion, target], connections)?.id).toBe(conversion.id);
conversion.metadata = { mediaConversion: { schemaVersion: 1, operation: "grayscale", status: "completed", sourceNodeId: input.id, sourceFingerprint: "v1-unknown", resultStorageKey: "image:scope:result" } };
expect(findPendingMediaConversionInput(target.id, [input, conversion, target], connections)?.id).toBe(conversion.id);
conversion.metadata.mediaConversion.sourceFingerprint = mediaConversionSourceFingerprint(input);
expect(findPendingMediaConversionInput(target.id, [input, conversion, target], connections)).toBeNull();
});
test("完成后的转换结果可作为生成参考图", async () => {
const { nodeReferenceImage } = await import("../src/lib/canvas/canvas-project-generation");
expect(nodeReferenceImage(node(CanvasNodeType.MediaConversion, "conversion", {
storageKey: "image:scope:result",
mimeType: "image/png",
mediaConversion: {
schemaVersion: 1,
operation: "grayscale",
status: "completed",
resultStorageKey: "image:scope:result",
},
}))).toMatchObject({
id: "conversion",
storageKey: "image:scope:result",
type: "image/png",
});
expect(nodeReferenceImage(node(CanvasNodeType.MediaConversion, "conversion", {
mediaConversion: { schemaVersion: 1, operation: "grayscale", status: "processing" },
}))).toBeNull();
});
});
describe("图片本地转换算法", () => {
test("灰度转换保留 alpha 并使用感知亮度", () => {
const pixels = new Uint8ClampedArray([255, 0, 0, 200, 0, 255, 0, 100]);
const output = transformPixels(pixels, 2, 1, "grayscale");
expect(Array.from(output)).toEqual([76, 76, 76, 200, 150, 150, 150, 100]);
});
test("Canny 转换输出黑白边缘图并保留 alpha", () => {
const pixels = new Uint8ClampedArray([
0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
]);
const output = transformPixels(pixels, 3, 3, "edge-canny");
for (let index = 0; index < output.length; index += 4) {
expect([0, 255]).toContain(output[index]);
expect(output[index]).toBe(output[index + 1]);
expect(output[index]).toBe(output[index + 2]);
expect(output[index + 3]).toBe(255);
}
});
test("浏览器算法转换把模型操作交给本机 Runtime", async () => {
await expect(convertImageLocally("", "depth")).rejects.toMatchObject<LocalImageConversionError>({ code: "model_missing" });
});
});

View File

@@ -2,14 +2,11 @@ import { expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
test("web and canvas-agent do not keep a second package manager lockfile", () => {
test("web does not keep a second package manager lockfile", () => {
const leftover = [
resolve(import.meta.dir, "../pnpm-lock.yaml"),
resolve(import.meta.dir, "../package-lock.json"),
resolve(import.meta.dir, "../yarn.lock"),
resolve(import.meta.dir, "../../canvas-agent/pnpm-lock.yaml"),
resolve(import.meta.dir, "../../canvas-agent/package-lock.json"),
resolve(import.meta.dir, "../../canvas-agent/yarn.lock"),
].filter((path) => existsSync(path));
expect(leftover).toEqual([]);
});

View File

@@ -1,32 +0,0 @@
import { describe, expect, it } from "bun:test";
import { isPortraitImageInput, reconcilePortraitClearanceInputBindings, swapPortraitClearanceDirectBindings } from "@/lib/portrait-clearance/input-bindings";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
function image(id: string, metadata: CanvasNodeData["metadata"] = {}) {
return { id, type: CanvasNodeType.Image, title: id, position: { x: 0, y: 0 }, width: 420, height: 236, metadata: { content: `data:image/png;base64,${id}`, ...metadata } } satisfies CanvasNodeData;
}
function connection(id: string, fromNodeId: string, toNodeId = "clearance") {
return { id, fromNodeId, toNodeId } satisfies CanvasConnection;
}
describe("portrait clearance input bindings", () => {
it("preserves existing roles, fills new connections deterministically, and removes stale ids", () => {
const nodes = [image("a"), image("b"), image("c")];
const bindings = reconcilePortraitClearanceInputBindings("direct-compare", "clearance", [connection("conn-2", "b"), connection("conn-1", "a"), connection("conn-3", "c")], nodes, [{ nodeId: "b", role: "reference" }, { nodeId: "missing", role: "query" }]);
expect(bindings).toEqual([{ nodeId: "b", role: "reference" }, { nodeId: "a", role: "query" }]);
});
it("uses candidate roles for additional network inputs and swaps only A/B", () => {
const nodes = [image("a"), image("b"), image("c")];
const bindings = reconcilePortraitClearanceInputBindings("network-search", "clearance", [connection("1", "a"), connection("2", "b"), connection("3", "c")], nodes);
expect(bindings).toEqual([{ nodeId: "a", role: "query" }, { nodeId: "b", role: "candidate" }, { nodeId: "c", role: "candidate" }]);
expect(swapPortraitClearanceDirectBindings([{ nodeId: "a", role: "query" }, { nodeId: "b", role: "reference" }, { nodeId: "c", role: "candidate" }])).toEqual([{ nodeId: "a", role: "reference" }, { nodeId: "b", role: "query" }, { nodeId: "c", role: "candidate" }]);
});
it("accepts a persisted storage key as usable image data but rejects empty image nodes", () => {
expect(isPortraitImageInput(image("storage", { content: undefined, storageKey: "image:user:one" }))).toBe(true);
expect(isPortraitImageInput(image("empty", { content: undefined }))).toBe(false);
});
});

View File

@@ -1,49 +0,0 @@
import { describe, expect, it } from "bun:test";
import { portraitVisionModelError, portraitVisionModels, resolvePortraitVisionModel } from "@/lib/portrait-clearance/vision";
import type { AiConfig } from "@/stores/use-config-store";
function config(): AiConfig {
return {
channelMode: "local",
baseUrl: "https://example.test",
apiKey: "key",
apiFormat: "openai",
model: "system::vision-text",
imageModel: "system::image",
videoModel: "",
textModel: "system::vision-text",
audioModel: "",
audioVoice: "",
audioFormat: "mp3",
audioSpeed: "1",
audioInstructions: "",
videoSeconds: "6",
vquality: "720",
videoGenerateAudio: "true",
videoWatermark: "false",
videoArkPrivateAssetUpload: "true",
systemPrompt: "",
models: [],
imageModels: [],
videoModels: [],
textModels: [],
audioModels: [],
quality: "auto",
size: "1:1",
transparentBackground: "false",
count: "1",
canvasImageCount: "1",
channels: [{ id: "system", name: "系统", baseUrl: "https://example.test", apiKey: "key", apiFormat: "openai", models: ["vision-text", "text-only"], modelCosts: [{ model: "vision-text", capability: "text", billingMode: "fixed_request", unitPriceMicrocredits: 1, capabilityConfig: { version: 1, text: { references: { promptMaxChars: 10000, maxImages: 2, maxImageBytes: 12_000_000, maxVideos: 0, maxVideoBytes: 0 } } } }, { model: "text-only", capability: "text", billingMode: "fixed_request", unitPriceMicrocredits: 1 }] }],
};
}
describe("portrait clearance model selection", () => {
it("uses any selectable text model without requiring a separate image capability flag", () => {
const value = config();
expect(portraitVisionModels(value)).toEqual(["system::vision-text", "system::text-only"]);
expect(resolvePortraitVisionModel(value, { mode: "project-default" })).toBe("system::vision-text");
expect(resolvePortraitVisionModel(value, { mode: "pinned", modelRef: "system::text-only" })).toBe("system::text-only");
expect(portraitVisionModelError(value, { mode: "pinned", modelRef: "system::text-only" })).toBe("");
});
});

View File

@@ -1,54 +0,0 @@
import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { CANVAS_READ_TOOL_NAMES, isCanvasReadTool, isWritableToolCall } from "@/lib/canvas/canvas-agent-protocol";
import { CANVAS_ONLINE_AGENT_TOOLS } from "@/lib/canvas/canvas-agent-tools";
import { CANVAS_BUILTIN_PRESETS, IMAGE_PROMPT_REVERSE, parseGeneratedStory, workflowStarterPrompt } from "@/lib/prompts";
const webRoot = resolve(import.meta.dir, "..");
function source(relativePath: string) {
return readFileSync(resolve(webRoot, relativePath), "utf8");
}
describe("prompt catalog ownership", () => {
it("keeps product LLM instructions out of page and panel files", () => {
const cases = [
["src/components/canvas/canvas-assistant-panel.tsx", "你是当前创作工作台内置的在线画布助手"],
["src/components/canvas/canvas-preset-picker.tsx", "生成角色设定图"],
["src/pages/canvas/use-canvas-media-tools.ts", "请根据参考图片反推"],
["src/lib/canvas/skill-drafting.ts", "你是一位技能编写助手"],
["src/pages/projects/index.tsx", "你是短剧编剧"],
["src/lib/canvas/canvas-agent-workflow.ts", "拆分主要角色,并为每个角色生成"],
["src/pages/canvas/use-canvas-generation-executor.ts", "请根据要求修改以下文本"],
["src/pages/tasks/index.tsx", "请对以下视频结果版本做对比分析"],
["src/components/canvas/canvas-local-agent-panel.tsx", 'name === "canvas_get_state" || name === "canvas_get_context"'],
] as const;
for (const [file, snippet] of cases) {
expect(source(file).includes(snippet), `${file} still embeds ${snippet}`).toBe(false);
}
});
it("owns canvas starter prompts and short-drama parsing in the catalog", () => {
expect(CANVAS_BUILTIN_PRESETS).toHaveLength(6);
expect(IMAGE_PROMPT_REVERSE).toContain("只输出提示词正文");
expect(workflowStarterPrompt("character_cards", "角色卡片", "夜灯")).toContain("拆分主要角色");
const parsed = parseGeneratedStory('{"title":"夜灯","synopsis":"租客发现房东不是人","chapters":[{"title":"入住","content":"林夏搬进旧公寓。"}]}');
expect(parsed).toEqual({
title: "夜灯",
synopsis: "租客发现房东不是人",
chapters: [{ title: "入住", content: "林夏搬进旧公寓。" }],
});
});
it("uses one read-tool list for online write-gate and local log classification", () => {
const toolNames = CANVAS_ONLINE_AGENT_TOOLS.map((tool) => tool.function.name);
for (const name of CANVAS_READ_TOOL_NAMES) {
expect(toolNames).toContain(name);
expect(isCanvasReadTool(name)).toBe(true);
expect(isWritableToolCall({ id: name, type: "function", function: { name, arguments: "{}" } })).toBe(false);
}
expect(isWritableToolCall({ id: "write", type: "function", function: { name: "canvas_apply_ops", arguments: "{}" } })).toBe(true);
});
});

View File

@@ -54,22 +54,15 @@ function file(path: string, content: string, kind: SkillPackageFile["kind"] = "m
}
describe("skill runtime", () => {
test("可检索首屏之外的技能并分页,不暴露未加入技能", async () => {
const runtime = createSkillRuntime();
const skills = Array.from({ length: 45 }, (_, index) => skill({ skillId: `skill-${index}`, skillName: `技能 ${index}`, description: index === 44 ? "商品构图" : "通用" }));
skills.push(skill({ skillId: "hidden", description: "商品构图", isAdded: false }));
const first = await runtime.executeAgentTool("onlineAgent", "canvas_list_skills", {}, skills);
expect(first?.ok && first.data).toMatchObject({ total: 45, nextOffset: 40 });
const found = await runtime.executeAgentTool("onlineAgent", "canvas_list_skills", { query: "商品" }, skills);
expect(found?.ok && found.data).toMatchObject({ total: 1, nextOffset: null, items: [{ skillId: "skill-44" }] });
});
test("技能引用解析由统一规则同时支持稳定 token 和自然提及", () => {
const director = skill();
const storyboard = skill({ skillId: "storyboard", skillName: "小说转分镜" });
expect(resolveSkillMentions("用 @[skill:director] 处理", [director, storyboard]).map((item) => item.skillId)).toEqual(["director"]);
expect(resolveSkillMentions("请用 @小说转分镜。", [director, storyboard]).map((item) => item.skillId)).toEqual(["storyboard"]);
expect(resolveSkillMentions("请用 /小说转分镜 处理。", [director, storyboard]).map((item) => item.skillId)).toEqual(["storyboard"]);
expect(resolveSkillMentions("@AI导演增强版", [director])).toEqual([]);
expect(resolveSkillMentions("/AI导演增强版", [director])).toEqual([]);
});
test("普通生成只加载入口和与当前任务最相关的直接引用文本", async () => {
@@ -95,8 +88,6 @@ describe("skill runtime", () => {
return { file: file(path, path.includes("prompt_templates") ? "视频提示词模板正文" : "角色资产正文") };
},
listFiles: async () => ({ files }),
searchFiles: async () => ({ results: [] }),
getBundle: async () => { throw new Error("不应读取完整包"); },
});
const result = await runtime.prepare({ profile: "canvas", prompt: "@[skill:director] 帮我生成视频提示词", skills: [skill()] });
@@ -110,41 +101,15 @@ describe("skill runtime", () => {
expect(result.metadata.skillIds).toEqual(["director"]);
});
test("本地 Agent 通过同一 Runtime 投递完整原生技能包", async () => {
const runtime = createSkillRuntime({
getFile: async () => { throw new Error("不应读取单文件"); },
listFiles: async () => ({ files: [] }),
searchFiles: async () => ({ results: [] }),
getBundle: async () => ({
bundle: {
skillId: "director",
name: "AI导演",
description: "导演工作流",
versionId: "version-2",
version: "2.0.0",
contentHash: "hash",
files: [{ path: "SKILL.md", mimeType: "text/markdown", contentBase64: "IyBBSuWvv+a8lA==" }],
},
}),
});
const result = await runtime.prepare({ profile: "localAgent", prompt: "@[skill:director] 开始", skills: [skill()] });
expect(result.prompt).toBe("@AI导演 开始");
expect(result.skills).toEqual([{ skillId: "director", name: "AI导演", description: "导演工作流", version: "2.0.0", files: [{ path: "SKILL.md", mimeType: "text/markdown", contentBase64: "IyBBSuWvv+a8lA==" }] }]);
});
test("在线 Agent 的技能工具由 Runtime 注册表统一执行", async () => {
test("云端 Agent 只准备已加入技能的上下文并保留来源", async () => {
const runtime = createSkillRuntime({
getFile: async () => ({ file: file("SKILL.md", "# AI导演") }),
listFiles: async () => ({ files: [] }),
searchFiles: async () => ({ results: [] }),
getBundle: async () => { throw new Error("不应读取完整包"); },
});
expect(runtime.agentToolNames("onlineAgent").has("canvas_get_skill")).toBe(true);
const result = await runtime.executeAgentTool("onlineAgent", "canvas_get_skill", { skillId: "director" }, [skill()]);
expect(result?.ok).toBe(true);
expect(result && "data" in result ? result.data : null).toMatchObject({ skillId: "director", version: "2.0.0" });
const result = await runtime.prepare({ profile: "canvas", prompt: "@[skill:director] 开始", skills: [skill()] });
expect(result.selectedSkills.map((item) => item.skillId)).toEqual(["director"]);
expect(result.prompt).toContain('<skill-context skill-id="director"');
expect(result.metadata.skillIds).toEqual(["director"]);
});
});

View File

@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { apiClient } from "@/services/api/request";
import { listAddedSkills } from "@/services/api/skills";
test("added Skills retries a transient backend proxy failure", async () => {
const original = apiClient.request;
let attempts = 0;
apiClient.request = (async () => {
attempts += 1;
if (attempts === 1) {
throw {
isAxiosError: true,
message: "Request failed with status code 502",
response: { status: 502, data: "Bad Gateway", headers: {} },
};
}
return { data: { code: 0, data: { skills: [] }, msg: "ok" }, status: 200, headers: {} };
}) as typeof apiClient.request;
try {
await expect(listAddedSkills()).resolves.toEqual({ skills: [] });
expect(attempts).toBe(2);
} finally {
apiClient.request = original;
}
});

View File

@@ -3,7 +3,7 @@ import { submitStoryboardTask } from "../src/services/storyboard-submission";
import { creationRuns, type CreationSubmission } from "../src/services/api/creation-runs";
import type { CreateTaskInput } from "../src/services/api/task-center";
const request = { type: "agent_storyboard_rows", projectId: "canvas", prompt: "剧本" } as CreateTaskInput;
const request = { type: "canvas_text", operation: "storyboard", projectId: "canvas", prompt: "剧本" } as CreateTaskInput;
function harness(expiresAt = "2099-01-01") {
const calls: string[] = [];
const run = { id: "run", executionEpoch: 1, executionOwner: "owner" };

File diff suppressed because it is too large Load Diff