chore: commit workspace updates
This commit is contained in:
194
web/test/agent-api-reliability.test.ts
Normal file
194
web/test/agent-api-reliability.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
web/test/agent-approval-presentation.test.ts
Normal file
54
web/test/agent-approval-presentation.test.ts
Normal 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("拒绝则保留草稿");
|
||||
});
|
||||
});
|
||||
19
web/test/agent-canvas-actions.test.ts
Normal file
19
web/test/agent-canvas-actions.test.ts
Normal 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([]);
|
||||
});
|
||||
69
web/test/agent-canvas-patch.test.ts
Normal file
69
web/test/agent-canvas-patch.test.ts
Normal 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);
|
||||
});
|
||||
140
web/test/agent-canvas-refresh.test.ts
Normal file
140
web/test/agent-canvas-refresh.test.ts
Normal 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();
|
||||
});
|
||||
73
web/test/agent-canvas-sync.test.ts
Normal file
73
web/test/agent-canvas-sync.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
49
web/test/agent-chat-tool-card.test.tsx
Normal file
49
web/test/agent-chat-tool-card.test.tsx
Normal 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");
|
||||
});
|
||||
19
web/test/agent-debug-export.test.ts
Normal file
19
web/test/agent-debug-export.test.ts
Normal 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");
|
||||
});
|
||||
67
web/test/agent-panel-layout.test.ts
Normal file
67
web/test/agent-panel-layout.test.ts
Normal 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 能力尚未开放");
|
||||
});
|
||||
});
|
||||
@@ -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 请求体", () => {
|
||||
|
||||
@@ -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"] }]);
|
||||
});
|
||||
37
web/test/agent-tool-presentation.test.ts
Normal file
37
web/test/agent-tool-presentation.test.ts
Normal 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("获取画布内容失败");
|
||||
});
|
||||
});
|
||||
@@ -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 () => ({
|
||||
|
||||
41
web/test/canvas-agent-focus.test.ts
Normal file
41
web/test/canvas-agent-focus.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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("创作上下文") })]));
|
||||
});
|
||||
});
|
||||
@@ -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("未达到预期");
|
||||
});
|
||||
@@ -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>;
|
||||
};
|
||||
};
|
||||
@@ -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] || "";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
31
web/test/canvas-storyboard-panel.test.ts
Normal file
31
web/test/canvas-storyboard-panel.test.ts
Normal 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)}");
|
||||
});
|
||||
});
|
||||
44
web/test/canvas-text-mention.test.ts
Normal file
44
web/test/canvas-text-mention.test.ts
Normal 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("清晨,人物推开木门");
|
||||
});
|
||||
@@ -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"]);
|
||||
25
web/test/cloud-agent-conversations.test.ts
Normal file
25
web/test/cloud-agent-conversations.test.ts
Normal 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("规划短剧 技能包");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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][] = [];
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
29
web/test/fixtures/canvas-node-drag-harness.tsx
vendored
Normal file
29
web/test/fixtures/canvas-node-drag-harness.tsx
vendored
Normal 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();
|
||||
}
|
||||
@@ -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
@@ -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("当前模型请求地址 无效");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
};
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
27
web/test/skills-api.test.ts
Normal file
27
web/test/skills-api.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user