feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
258
web/test/canvas-local-runtime.test.ts
Normal file
258
web/test/canvas-local-runtime.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
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 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 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>;
|
||||
};
|
||||
};
|
||||
340
web/test/create-canvas-handoff.test.ts
Normal file
340
web/test/create-canvas-handoff.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { createGenerationTaskSubscriptionService, type GenerationTask } from "../src/services/api/task-center";
|
||||
import { updateCreationConversationSnapshot } from "../src/services/creation-conversation-store";
|
||||
|
||||
test("Create exposes one accessible copy action beside each displayed user prompt", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text();
|
||||
expect(source).toContain('aria-label="复制提示词"');
|
||||
expect(source).toContain('copyText(visiblePrompt, "提示词已复制")');
|
||||
});
|
||||
|
||||
test("Create submit button does not forward the browser click event as retry context", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text();
|
||||
expect(source).toContain("onSubmit: () => void submit()");
|
||||
expect(source).not.toContain("onSubmit: submit,");
|
||||
});
|
||||
|
||||
test("Create keeps optimistic messages when the first local task binds immediately", () => {
|
||||
const initial = [{ id: "conversation-0001", title: "新创作", updatedAt: "2026-08-14T00:00:00.000Z", messages: [] as Array<Record<string, unknown>> }];
|
||||
const optimistic = updateCreationConversationSnapshot(initial, "conversation-0001", (conversation) => ({
|
||||
...conversation,
|
||||
messages: [
|
||||
{ id: "user-0001", role: "user", content: "fixture" },
|
||||
{ id: "assistant-0001", role: "assistant", content: "", status: "pending" },
|
||||
],
|
||||
}));
|
||||
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)),
|
||||
}));
|
||||
|
||||
expect(bound[0].messages).toHaveLength(2);
|
||||
expect(bound[0].messages[1]).toMatchObject({ id: "assistant-0001", taskIds: ["local:dreamina-cli:task-0001"] });
|
||||
});
|
||||
|
||||
test("Create refresh subscriptions share one durable scheduler observation without page polling", async () => {
|
||||
let queryCalls = 0;
|
||||
let waitCalls = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolveGate) => {
|
||||
release = resolveGate;
|
||||
});
|
||||
const running: GenerationTask = {
|
||||
id: "dreamina:create-refresh-task-0001",
|
||||
projectId: "create-project-0001",
|
||||
type: "canvas_video",
|
||||
status: "running",
|
||||
prompt: "fixture",
|
||||
attempts: 1,
|
||||
clientContext: { conversationId: "conversation-0001", messageId: "message-0001" },
|
||||
createdAt: "2026-08-13T00:00:00.000Z",
|
||||
updatedAt: "2026-08-13T00:00:00.000Z",
|
||||
};
|
||||
const service = createGenerationTaskSubscriptionService({
|
||||
async queryTask() {
|
||||
queryCalls += 1;
|
||||
return running;
|
||||
},
|
||||
async waitTask() {
|
||||
waitCalls += 1;
|
||||
await gate;
|
||||
return { ...running, status: "succeeded", updatedAt: "2026-08-13T00:01:00.000Z" };
|
||||
},
|
||||
});
|
||||
const beforeRefresh: GenerationTask[] = [];
|
||||
const afterRefresh: GenerationTask[] = [];
|
||||
const disconnect = service.subscribe([running.id], (task) => beforeRefresh.push(task));
|
||||
await Promise.resolve();
|
||||
disconnect();
|
||||
const reconnect = service.subscribe([running.id], (task) => afterRefresh.push(task));
|
||||
release();
|
||||
await new Promise((resolveTick) => setTimeout(resolveTick, 0));
|
||||
reconnect();
|
||||
|
||||
expect(queryCalls).toBe(1);
|
||||
expect(waitCalls).toBe(1);
|
||||
expect(beforeRefresh[0]).toMatchObject({ id: running.id, status: "running" });
|
||||
expect(afterRefresh.at(-1)).toMatchObject({
|
||||
id: running.id,
|
||||
status: "succeeded",
|
||||
clientContext: { conversationId: "conversation-0001", messageId: "message-0001" },
|
||||
});
|
||||
});
|
||||
|
||||
test("Create durably correlates failures that happen before the first Runtime response", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text();
|
||||
expect(source).toContain("generationErrorCode?: string");
|
||||
expect(source).toContain("generationOperation?: string");
|
||||
expect(source).toContain("generationOperation: task.operation");
|
||||
expect(source).toContain("generationErrorCode: task.errorCode");
|
||||
expect(source).toContain("generationErrorCode(error)");
|
||||
expect(source).toContain("createdAt: assistantMessage.createdAt");
|
||||
});
|
||||
|
||||
test("creation results hand off exact asset ids to one new canvas and consume the route once", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff").catch(() => ({}));
|
||||
|
||||
expect(typeof module.creationCanvasHandoffPath).toBe("function");
|
||||
expect(typeof module.canvasAssetHandoffIds).toBe("function");
|
||||
expect(typeof module.consumeCanvasAssetHandoff).toBe("function");
|
||||
|
||||
const path = module.creationCanvasHandoffPath(["asset one", "asset/two", "asset one", ""]);
|
||||
expect(path).toBe("/canvas?mode=handoff&asset=asset+one&asset=asset%2Ftwo");
|
||||
expect(module.creationCanvasHandoffPath(["asset/two"], 2)).toBeUndefined();
|
||||
|
||||
const params = new URLSearchParams("mode=new&asset=asset+one&asset=asset%2Ftwo&asset=asset+one&keep=1");
|
||||
expect(module.canvasAssetHandoffIds(params)).toEqual(["asset one", "asset/two"]);
|
||||
expect(module.consumeCanvasAssetHandoff(params).toString()).toBe("keep=1");
|
||||
});
|
||||
|
||||
test("canvas handoff resolves generated image and video assets in requested order", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff");
|
||||
expect(typeof module.canvasAssetHandoffPayloads).toBe("function");
|
||||
|
||||
const assets = [
|
||||
{
|
||||
id: "image-1",
|
||||
kind: "image" as const,
|
||||
title: "image title",
|
||||
coverUrl: "/image.png",
|
||||
tags: ["creation"],
|
||||
status: "confirmed" as const,
|
||||
source: "creation",
|
||||
createdAt: "2026-08-11T00:00:00.000Z",
|
||||
updatedAt: "2026-08-11T00:00:00.000Z",
|
||||
data: { dataUrl: "/image.png", storageKey: "resource:image", width: 1664, height: 936, bytes: 625000, mimeType: "image/png" },
|
||||
},
|
||||
{
|
||||
id: "video-1",
|
||||
kind: "video" as const,
|
||||
title: "video title",
|
||||
coverUrl: "/video.mp4",
|
||||
tags: ["creation"],
|
||||
status: "confirmed" as const,
|
||||
source: "creation",
|
||||
createdAt: "2026-08-11T00:00:00.000Z",
|
||||
updatedAt: "2026-08-11T00:00:00.000Z",
|
||||
data: { url: "/video.mp4", storageKey: "resource:video", width: 1280, height: 720, durationMs: 6000, bytes: 800000, mimeType: "video/mp4" },
|
||||
},
|
||||
];
|
||||
|
||||
expect(module.canvasAssetHandoffPayloads(assets, ["video-1", "missing", "image-1"])).toEqual({
|
||||
payloads: [
|
||||
{ kind: "video", url: "/video.mp4", storageKey: "resource:video", title: "video title", width: 1280, height: 720, durationMs: 6000, bytes: 800000, mimeType: "video/mp4", assetId: "video-1" },
|
||||
{ kind: "image", dataUrl: "/image.png", storageKey: "resource:image", title: "image title", assetId: "image-1" },
|
||||
],
|
||||
missingAssetIds: ["missing"],
|
||||
});
|
||||
});
|
||||
|
||||
test("creation result handoff selects only assets owned by that message in result order", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff");
|
||||
const image = (id: string, url: string, messageId: string, resultIndex: number) => ({
|
||||
id,
|
||||
kind: "image" as const,
|
||||
title: id,
|
||||
coverUrl: url,
|
||||
tags: ["creation"],
|
||||
status: "confirmed" as const,
|
||||
source: "creation",
|
||||
createdAt: "2026-08-11T00:00:00.000Z",
|
||||
updatedAt: "2026-08-11T00:00:00.000Z",
|
||||
metadata: { source: "create-generation", messageId, resultIndex },
|
||||
data: { dataUrl: url, width: 1664, height: 936, bytes: 1, mimeType: "image/png" },
|
||||
});
|
||||
const assets = [image("second", "/second.png", "message-1", 1), image("unrelated", "/unrelated.png", "message-2", 0), image("first", "/first.png", "message-1", 0)];
|
||||
|
||||
expect(module.creationResultAssetIds(assets, { messageId: "message-1", taskIds: [], resultUrls: ["/first.png", "/second.png"] })).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
test("creation result handoff recognizes unified generation-task materializer metadata", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff");
|
||||
const asset = {
|
||||
id: "materialized-image",
|
||||
kind: "image" as const,
|
||||
title: "image",
|
||||
coverUrl: "/stored.png",
|
||||
tags: [],
|
||||
status: "confirmed" as const,
|
||||
source: "generation task",
|
||||
createdAt: "2026-08-14T00:00:00.000Z",
|
||||
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||
metadata: { source: "generation-task", taskId: "dreamina:task-1", messageId: "message-1", outputIndex: 0 },
|
||||
data: { dataUrl: "/stored.png", width: 1024, height: 1024, bytes: 1, mimeType: "image/png" },
|
||||
};
|
||||
|
||||
expect(
|
||||
module.creationResultAssetIds([asset], {
|
||||
messageId: "message-1",
|
||||
taskIds: ["dreamina:task-1"],
|
||||
resultUrls: ["/stored.png"],
|
||||
}),
|
||||
).toEqual(["materialized-image"]);
|
||||
});
|
||||
|
||||
test("canvas handoff consumes its route only after merged nodes are durably persisted", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff");
|
||||
let releasePersistence = () => {};
|
||||
const persistenceGate = new Promise<void>((resolve) => {
|
||||
releasePersistence = resolve;
|
||||
});
|
||||
let persistedNodes: Array<{ id: string }> = [];
|
||||
let settled = false;
|
||||
const pending = module
|
||||
.finalizeCanvasAssetHandoff({
|
||||
searchParams: new URLSearchParams("mode=handoff&asset=image-1&keep=1"),
|
||||
currentNodes: [{ id: "existing" }, { id: "image-1" }],
|
||||
createdNodes: [{ id: "image-1" }, { id: "image-2" }],
|
||||
persist: async (nodes: Array<{ id: string }>) => {
|
||||
persistedNodes = nodes;
|
||||
await persistenceGate;
|
||||
},
|
||||
})
|
||||
.then((result: { nodes: Array<{ id: string }>; searchParams: URLSearchParams }) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
expect(persistedNodes.map((node) => node.id)).toEqual(["existing", "image-1", "image-2"]);
|
||||
releasePersistence();
|
||||
const result = await pending;
|
||||
expect(result.nodes.map((node) => node.id)).toEqual(["existing", "image-1", "image-2"]);
|
||||
expect(result.searchParams.toString()).toBe("keep=1");
|
||||
});
|
||||
|
||||
test("canvas handoff keeps its route when durable persistence fails", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff");
|
||||
const searchParams = new URLSearchParams("mode=handoff&asset=image-1&keep=1");
|
||||
await expect(
|
||||
module.finalizeCanvasAssetHandoff({
|
||||
searchParams,
|
||||
currentNodes: [{ id: "existing" }],
|
||||
createdNodes: [{ id: "image-1" }],
|
||||
persist: async () => {
|
||||
throw new Error("persistence failed");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("persistence failed");
|
||||
expect(searchParams.toString()).toBe("mode=handoff&asset=image-1&keep=1");
|
||||
});
|
||||
|
||||
test("canvas handoff with no available assets remains retryable", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff").catch(() => ({}));
|
||||
expect(typeof module.canvasAssetHandoffAttempt).toBe("function");
|
||||
expect(module.canvasAssetHandoffAttempt([], new URLSearchParams("mode=handoff&asset=missing&keep=1"))).toEqual({ kind: "retry", assetIds: ["missing"], payloads: [], missingAssetIds: ["missing"] });
|
||||
});
|
||||
|
||||
test("canvas handoff waits until every requested asset is available before creating any node", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff");
|
||||
const image = {
|
||||
id: "image-1",
|
||||
kind: "image" as const,
|
||||
title: "image",
|
||||
coverUrl: "/image.png",
|
||||
tags: [],
|
||||
status: "confirmed" as const,
|
||||
source: "creation",
|
||||
createdAt: "2026-08-11T00:00:00.000Z",
|
||||
updatedAt: "2026-08-11T00:00:00.000Z",
|
||||
data: { dataUrl: "/image.png", width: 1, height: 1, bytes: 1, mimeType: "image/png" },
|
||||
};
|
||||
expect(module.canvasAssetHandoffAttempt([image], new URLSearchParams("mode=handoff&asset=image-1&asset=late-video"))).toEqual({
|
||||
kind: "retry",
|
||||
assetIds: ["image-1", "late-video"],
|
||||
payloads: [],
|
||||
missingAssetIds: ["late-video"],
|
||||
});
|
||||
});
|
||||
|
||||
test("canvas handoff reuses nodes carrying the same asset id after a persistence retry", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff");
|
||||
expect(typeof module.uninsertedCanvasAssetHandoffPayloads).toBe("function");
|
||||
const payloads = [{ kind: "image" as const, dataUrl: "/image.png", title: "image", assetId: "image-1" }];
|
||||
const existingNodes = [{ id: "random-node-id", metadata: { assetId: "image-1" } }];
|
||||
expect(module.uninsertedCanvasAssetHandoffPayloads(existingNodes, payloads)).toEqual([]);
|
||||
});
|
||||
|
||||
test("creation result handoff falls back by stable result order only for a complete owned asset set", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-asset-handoff");
|
||||
const image = (id: string, resultIndex: number) => ({
|
||||
id,
|
||||
kind: "image" as const,
|
||||
title: id,
|
||||
coverUrl: `/stored-${id}.png`,
|
||||
tags: ["creation"],
|
||||
status: "confirmed" as const,
|
||||
source: "creation",
|
||||
createdAt: "2026-08-11T00:00:00.000Z",
|
||||
updatedAt: "2026-08-11T00:00:00.000Z",
|
||||
metadata: { source: "create-generation", messageId: "message-1", resultIndex },
|
||||
data: { dataUrl: `/stored-${id}.png`, width: 1664, height: 936, bytes: 1, mimeType: "image/png" },
|
||||
});
|
||||
const complete = [image("second", 1), image("first", 0)];
|
||||
expect(module.creationResultAssetIds(complete, { messageId: "message-1", taskIds: [], resultUrls: ["/official-first.png", "/official-second.png"] })).toEqual(["first", "second"]);
|
||||
expect(module.creationResultAssetIds(complete.slice(0, 1), { messageId: "message-1", taskIds: [], resultUrls: ["/official-first.png", "/official-second.png"] })).toEqual([]);
|
||||
});
|
||||
|
||||
test("Create forwards owned result assets through one new canvas and the project persists before clearing the handoff", () => {
|
||||
const create = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
|
||||
const canvasIndex = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/index.tsx"), "utf8");
|
||||
const canvasProject = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/project.tsx"), "utf8");
|
||||
|
||||
expect(create).toContain('import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"');
|
||||
expect(create).toContain("const resultAssetIds = result && resultUrls.length ? creationResultAssetIds(assets, { messageId: result.id, taskIds: result.taskIds || [], resultUrls }) : [];");
|
||||
expect(create).toContain('const canvasHandoffPath = result ? creationCanvasHandoffPath(resultAssetIds, resultUrls.length) : "";');
|
||||
expect(create).toContain('const canvasPath = canvasHandoffPath || "/canvas";');
|
||||
expect(create).toContain('<Link to={canvasPath}>{canvasHandoffPath ? "添加到画布" : "打开画布"}</Link>');
|
||||
expect(canvasIndex).toContain('const handoffMode = mode === "handoff"');
|
||||
expect(canvasIndex).toContain('mode !== "new" && mode !== "recent" && mode !== "handoff"');
|
||||
expect(canvasProject).toContain('import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff"');
|
||||
expect(canvasProject).toContain('if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return');
|
||||
expect(canvasProject).toContain("const pendingPayloads = uninsertedCanvasAssetHandoffPayloads(nodesRef.current, payloads)");
|
||||
expect(canvasProject).toContain("await flushCanvasStorePersistence()");
|
||||
expect(canvasProject.indexOf("await flushCanvasStorePersistence()")).toBeLessThan(canvasProject.indexOf("setSearchParams(finalized.searchParams"));
|
||||
});
|
||||
|
||||
test("Create image batch retry preserves per-index lineage under one attempt group", async () => {
|
||||
const module = await import("../src/lib/canvas/canvas-project-generation");
|
||||
const createBatchRetryContexts = (
|
||||
module as {
|
||||
createGenerationBatchRetryContexts?: (taskIds: readonly string[], attemptGroupId: string) => Promise<Array<{ retryOf: string; attemptGroupId: string; clientOperationId: string }>>;
|
||||
}
|
||||
).createGenerationBatchRetryContexts;
|
||||
expect(typeof createBatchRetryContexts).toBe("function");
|
||||
if (!createBatchRetryContexts) return;
|
||||
|
||||
const oldTaskIds = ["dreamina:create-image-old-0001", "dreamina:create-image-old-0002", "dreamina:create-image-old-0003"];
|
||||
const contexts = await createBatchRetryContexts(oldTaskIds, "create-image-attempt-group-0001");
|
||||
|
||||
expect(contexts.map(({ retryOf, attemptGroupId }) => ({ retryOf, attemptGroupId }))).toEqual([
|
||||
{ retryOf: oldTaskIds[0], attemptGroupId: "create-image-attempt-group-0001" },
|
||||
{ retryOf: oldTaskIds[1], attemptGroupId: "create-image-attempt-group-0001" },
|
||||
{ retryOf: oldTaskIds[2], attemptGroupId: "create-image-attempt-group-0001" },
|
||||
]);
|
||||
expect(new Set(contexts.map((context) => context.clientOperationId)).size).toBe(3);
|
||||
});
|
||||
@@ -2,30 +2,36 @@ import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function compactSource(source: string) {
|
||||
return source.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
describe("creation library button", () => {
|
||||
test("places a library control beside the generation mode picker", () => {
|
||||
const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
|
||||
const modePickerIndex = source.indexOf("<ModePicker mode={props.mode} onModeChange={props.onModeChange} />");
|
||||
const libraryButtonIndex = source.indexOf('className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"');
|
||||
const dockStart = source.indexOf('<footer className="creation-chat-dock">');
|
||||
const dockEnd = source.indexOf("</footer>", dockStart);
|
||||
|
||||
expect(modePickerIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(libraryButtonIndex).toBeGreaterThan(modePickerIndex);
|
||||
expect(source.slice(libraryButtonIndex, libraryButtonIndex + 180)).toContain("<FolderOpen />");
|
||||
expect(source.slice(libraryButtonIndex, libraryButtonIndex + 180)).toContain("<span>素材库</span>");
|
||||
expect(dockStart).toBeGreaterThanOrEqual(0);
|
||||
expect(dockEnd).toBeGreaterThan(dockStart);
|
||||
expect(compactSource(source.slice(dockStart, dockEnd))).toMatch(
|
||||
/<ModePicker mode=\{props\.mode\} onModeChange=\{props\.onModeChange\} \/> <Tooltip\b[^>]*>\s*<button(?=[^>]*className="creation-chat-control")(?=[^>]*onClick=\{props\.onOpenLibrary\})(?=[^>]*disabled=\{props\.busy \|\| !referencesSupported\})(?=[^>]*aria-label="打开素材库选择参考内容")[^>]*>\s*<FolderOpen \/>\s*<span>素材库<\/span>\s*<\/button>\s*<\/Tooltip>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("uploads from the library without adding a reference before confirmation", () => {
|
||||
const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
|
||||
const pickerSource = readFileSync(resolve(import.meta.dir, "../src/components/assets/asset-library-picker-modal.tsx"), "utf8");
|
||||
const uploadStart = source.indexOf("const uploadLibraryAssets = async");
|
||||
const uploadEnd = source.indexOf("const handleFileChange", uploadStart);
|
||||
|
||||
expect(uploadStart).toBeGreaterThanOrEqual(0);
|
||||
expect(uploadEnd).toBeGreaterThan(uploadStart);
|
||||
expect(source.slice(uploadStart, uploadEnd)).not.toContain("setAttachments");
|
||||
expect(source).toContain("onUpload={uploadLibraryAssets}");
|
||||
expect(source).toContain("onUpload: uploadLibraryAssets");
|
||||
expect(source).not.toContain("onUpload={() => fileInputRef.current?.click()}");
|
||||
expect(source).toContain("上传后保存到素材库");
|
||||
expect(source).toContain("正在保存到素材库,完成后会自动选中");
|
||||
expect(pickerSource).toContain("保存完成后会自动选中");
|
||||
expect(source).toContain("个素材已上传到素材库并自动选中");
|
||||
});
|
||||
|
||||
|
||||
415
web/test/generation-asset-repository.node.test.ts
Normal file
415
web/test/generation-asset-repository.node.test.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationAssetStorageLock, type GenerationAssetRecord } from "../src/services/generation-asset-repository";
|
||||
import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "../src/services/generation-artifact-sink";
|
||||
|
||||
test("generation asset insert waits for persistence and reload returns the same durable row", async () => {
|
||||
let assets: GenerationAssetRecord[] = [];
|
||||
let durableAssets: GenerationAssetRecord[] = [];
|
||||
let releasePersistence!: () => void;
|
||||
const persistenceGate = new Promise<void>((resolve) => {
|
||||
releasePersistence = resolve;
|
||||
});
|
||||
let settled = false;
|
||||
|
||||
const inserting = insertOrReturnGenerationAsset({
|
||||
storageScope: "test-scope",
|
||||
effectKey: "materialize:task-persistence:0",
|
||||
assetId: "generation-stable-id",
|
||||
createAsset: () => ({
|
||||
id: "generation-stable-id",
|
||||
metadata: { generationEffectKey: "materialize:task-persistence:0" },
|
||||
}),
|
||||
updateAssets: (updater) => {
|
||||
assets = updater(assets);
|
||||
},
|
||||
readAssets: () => assets,
|
||||
readPersistedAssets: async () => structuredClone(durableAssets),
|
||||
persistAssets: async (nextAssets) => {
|
||||
await persistenceGate;
|
||||
durableAssets = structuredClone(nextAssets);
|
||||
},
|
||||
}).then((assetId) => {
|
||||
settled = true;
|
||||
return assetId;
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false, "Agent effect must not complete while persistence is pending");
|
||||
|
||||
releasePersistence();
|
||||
const assetId = await inserting;
|
||||
assets = structuredClone(durableAssets);
|
||||
|
||||
assert.equal(assets.length, 1);
|
||||
assert.equal(assets[0]?.id, assetId);
|
||||
assert.equal(assets[0]?.metadata?.generationEffectKey, "materialize:task-persistence:0");
|
||||
});
|
||||
|
||||
test("generation asset insertion does not resurrect a row deleted from durable storage", async () => {
|
||||
const deleted = {
|
||||
id: "asset-deleted",
|
||||
metadata: { title: "stale memory row" },
|
||||
} satisfies GenerationAssetRecord;
|
||||
let assets: GenerationAssetRecord[] = [deleted];
|
||||
let durableAssets: GenerationAssetRecord[] = [];
|
||||
let persistenceWrites = 0;
|
||||
|
||||
await insertOrReturnGenerationAsset({
|
||||
storageScope: "test-deletion-scope",
|
||||
effectKey: "materialize:after-delete:0",
|
||||
assetId: "asset-generated",
|
||||
createAsset: () => ({
|
||||
id: "asset-generated",
|
||||
metadata: { generationEffectKey: "materialize:after-delete:0" },
|
||||
}),
|
||||
updateAssets: (updater) => {
|
||||
assets = updater(assets);
|
||||
},
|
||||
readAssets: () => assets,
|
||||
readPersistedAssets: async () => structuredClone(durableAssets),
|
||||
persistAssets: async (nextAssets) => {
|
||||
persistenceWrites += 1;
|
||||
durableAssets = structuredClone(nextAssets);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
durableAssets.map((asset) => asset.id),
|
||||
["asset-generated"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
assets.map((asset) => asset.id),
|
||||
["asset-generated"],
|
||||
);
|
||||
assert.equal(persistenceWrites, 1);
|
||||
});
|
||||
|
||||
test("generation asset replay preserves the hydrated in-memory preview for the same durable row", async () => {
|
||||
const effectKey = "materialize:hydrated-preview:0";
|
||||
const persisted = {
|
||||
id: "asset-hydrated-preview",
|
||||
title: "durable title",
|
||||
coverUrl: "https://persisted.invalid/preview.png",
|
||||
data: { dataUrl: "https://persisted.invalid/preview.png", storageKey: "generation-image:test-scope:hydrated-preview" },
|
||||
metadata: { generationEffectKey: effectKey, durableMarker: "durable" },
|
||||
} satisfies GenerationAssetRecord & { title: string; coverUrl: string; data: { dataUrl: string; storageKey: string } };
|
||||
const hydrated = {
|
||||
...persisted,
|
||||
title: "stale hydrated title",
|
||||
coverUrl: "blob:hydrated-cover",
|
||||
data: { ...persisted.data, dataUrl: "blob:hydrated-preview" },
|
||||
metadata: { generationEffectKey: effectKey, durableMarker: "stale" },
|
||||
};
|
||||
let assets: (typeof hydrated)[] = [hydrated];
|
||||
|
||||
const assetId = await insertOrReturnGenerationAsset({
|
||||
storageScope: "test-scope",
|
||||
effectKey,
|
||||
assetId: persisted.id,
|
||||
createAsset: () => {
|
||||
throw new Error("existing generation asset must be reused");
|
||||
},
|
||||
updateAssets: (updater) => {
|
||||
assets = updater(assets) as (typeof hydrated)[];
|
||||
},
|
||||
readAssets: () => assets,
|
||||
readPersistedAssets: async () => [persisted],
|
||||
persistAssets: async () => {
|
||||
throw new Error("existing generation asset must not be rewritten");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(assetId, persisted.id);
|
||||
assert.equal(assets[0]?.coverUrl, "blob:hydrated-cover");
|
||||
assert.equal(assets[0]?.data.dataUrl, "blob:hydrated-preview");
|
||||
assert.equal(assets[0]?.data.storageKey, persisted.data.storageKey);
|
||||
assert.equal(assets[0]?.title, "durable title");
|
||||
assert.equal(assets[0]?.metadata?.durableMarker, "durable");
|
||||
});
|
||||
|
||||
test("generation asset replay does not carry a blob preview across a changed storage key", async () => {
|
||||
const effectKey = "materialize:hydrated-preview-storage-change:0";
|
||||
const persisted = {
|
||||
id: "asset-hydrated-preview-storage-change",
|
||||
coverUrl: "https://persisted.invalid/new-preview.png",
|
||||
data: { dataUrl: "https://persisted.invalid/new-preview.png", storageKey: "generation-image:test-scope:new-key" },
|
||||
metadata: { generationEffectKey: effectKey },
|
||||
} satisfies GenerationAssetRecord & { coverUrl: string; data: { dataUrl: string; storageKey: string } };
|
||||
const hydrated = {
|
||||
...persisted,
|
||||
coverUrl: "blob:stale-cover",
|
||||
data: { dataUrl: "blob:stale-preview", storageKey: "generation-image:test-scope:old-key" },
|
||||
};
|
||||
let assets: Array<typeof persisted | typeof hydrated> = [hydrated];
|
||||
|
||||
await insertOrReturnGenerationAsset({
|
||||
storageScope: "test-scope",
|
||||
effectKey,
|
||||
assetId: persisted.id,
|
||||
createAsset: () => {
|
||||
throw new Error("existing generation asset must be reused");
|
||||
},
|
||||
updateAssets: (updater) => {
|
||||
assets = updater(assets as (typeof persisted)[]) as Array<typeof persisted | typeof hydrated>;
|
||||
},
|
||||
readAssets: () => assets as (typeof persisted)[],
|
||||
readPersistedAssets: async () => [persisted],
|
||||
persistAssets: async () => {
|
||||
throw new Error("existing generation asset must not be rewritten");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(assets[0]?.coverUrl, persisted.coverUrl);
|
||||
assert.equal(assets[0]?.data.dataUrl, persisted.data.dataUrl);
|
||||
assert.equal(assets[0]?.data.storageKey, persisted.data.storageKey);
|
||||
});
|
||||
|
||||
test("generation asset replay rejects a deterministic row deleted after its base revision", async () => {
|
||||
let assets: GenerationAssetRecord[] = [];
|
||||
await assert.rejects(
|
||||
insertOrReturnGenerationAsset({
|
||||
storageScope: "test-tombstone-scope",
|
||||
effectKey: "materialize:tombstoned:0",
|
||||
assetId: "asset-tombstoned",
|
||||
createAsset: () => ({
|
||||
id: "asset-tombstoned",
|
||||
metadata: { generationEffectKey: "materialize:tombstoned:0" },
|
||||
}),
|
||||
updateAssets: (updater) => {
|
||||
assets = updater(assets);
|
||||
},
|
||||
readAssets: () => assets,
|
||||
readPersistedAssets: async () => [],
|
||||
isAssetDeleted: () => true,
|
||||
persistAssets: async () => {
|
||||
throw new Error("tombstoned generation asset must not be persisted");
|
||||
},
|
||||
} as Parameters<typeof insertOrReturnGenerationAsset>[0]),
|
||||
/生成素材已被用户删除/,
|
||||
);
|
||||
assert.deepEqual(assets, []);
|
||||
});
|
||||
|
||||
test("concurrent generation asset inserts merge inside a per-scope storage lock", async () => {
|
||||
const durableByScope = new Map<string, GenerationAssetRecord[]>();
|
||||
let releaseFirstWrite!: () => void;
|
||||
const firstWriteGate = new Promise<void>((resolve) => {
|
||||
releaseFirstWrite = resolve;
|
||||
});
|
||||
let firstWriteStartedResolve!: () => void;
|
||||
const firstWriteStarted = new Promise<void>((resolve) => {
|
||||
firstWriteStartedResolve = resolve;
|
||||
});
|
||||
|
||||
const insert = (scope: string, effectKey: string, assetId: string, blockWrite = false) => {
|
||||
let assets: GenerationAssetRecord[] = [];
|
||||
return insertOrReturnGenerationAsset({
|
||||
storageScope: scope,
|
||||
effectKey,
|
||||
assetId,
|
||||
createAsset: () => ({
|
||||
id: assetId,
|
||||
metadata: { generationEffectKey: effectKey },
|
||||
}),
|
||||
updateAssets: (updater) => {
|
||||
assets = updater(assets);
|
||||
},
|
||||
readAssets: () => assets,
|
||||
readPersistedAssets: async () => structuredClone(durableByScope.get(scope) ?? []),
|
||||
persistAssets: async (nextAssets) => {
|
||||
if (blockWrite) {
|
||||
firstWriteStartedResolve();
|
||||
await firstWriteGate;
|
||||
}
|
||||
durableByScope.set(scope, structuredClone(nextAssets));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const first = insert("account-A", "materialize:concurrent-a:0", "asset-a", true);
|
||||
await firstWriteStarted;
|
||||
let secondSettled = false;
|
||||
const second = insert("account-A", "materialize:concurrent-b:0", "asset-b").then((assetId) => {
|
||||
secondSettled = true;
|
||||
return assetId;
|
||||
});
|
||||
let otherScopeSettled = false;
|
||||
const otherScope = insert("account-B", "materialize:isolated:0", "asset-other").then(() => {
|
||||
otherScopeSettled = true;
|
||||
});
|
||||
|
||||
await otherScope;
|
||||
assert.equal(otherScopeSettled, true, "another user scope must not wait on account-A's lock");
|
||||
assert.equal(secondSettled, false, "same-scope insert must wait for the first write");
|
||||
|
||||
releaseFirstWrite();
|
||||
await Promise.all([first, second, otherScope]);
|
||||
|
||||
assert.deepEqual(
|
||||
durableByScope
|
||||
.get("account-A")
|
||||
?.map((asset) => asset.id)
|
||||
.sort(),
|
||||
["asset-a", "asset-b"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
durableByScope.get("account-B")?.map((asset) => asset.id),
|
||||
["asset-other"],
|
||||
);
|
||||
});
|
||||
|
||||
test("browser generation asset writes use the storage-level Web Locks boundary", async () => {
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
const originalNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
const lockNames: string[] = [];
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
locks: {
|
||||
request: async <T>(name: string, callback: () => Promise<T>) => {
|
||||
lockNames.push(name);
|
||||
return callback();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await withGenerationAssetStorageLock("account-A", async () => undefined);
|
||||
assert.deepEqual(lockNames, ["infinite-canvas:generation-asset-storage-lock:account-A"]);
|
||||
} finally {
|
||||
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
|
||||
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
|
||||
if (originalNavigator === undefined) delete (globalThis as { navigator?: unknown }).navigator;
|
||||
else Object.defineProperty(globalThis, "navigator", { configurable: true, value: originalNavigator });
|
||||
}
|
||||
});
|
||||
|
||||
test("competing browser generation asset writes fail closed before entering storage without Web Locks", async () => {
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
const originalDocument = (globalThis as { document?: unknown }).document;
|
||||
const originalNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: {} });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} });
|
||||
let entered = 0;
|
||||
|
||||
try {
|
||||
const compete = () =>
|
||||
withGenerationAssetStorageLock(
|
||||
"account-no-web-locks",
|
||||
async () => {
|
||||
entered += 1;
|
||||
},
|
||||
{ requireCrossRealmLock: true },
|
||||
);
|
||||
await Promise.all([assert.rejects(compete(), /跨标签存储锁/), assert.rejects(compete(), /跨标签存储锁/)]);
|
||||
assert.equal(entered, 0, "neither competing generation write may enter the critical section without a cross-tab lock");
|
||||
} finally {
|
||||
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
|
||||
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
|
||||
if (originalDocument === undefined) delete (globalThis as { document?: unknown }).document;
|
||||
else Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument });
|
||||
if (originalNavigator === undefined) delete (globalThis as { navigator?: unknown }).navigator;
|
||||
else Object.defineProperty(globalThis, "navigator", { configurable: true, value: originalNavigator });
|
||||
}
|
||||
});
|
||||
|
||||
test("an aborted generation asset fence prevents the durable catalog write", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
let persistenceWrites = 0;
|
||||
|
||||
await assert.rejects(
|
||||
insertOrReturnGenerationAsset({
|
||||
storageScope: "aborted-generation-asset",
|
||||
effectKey: "materialize:aborted-generation-asset:0",
|
||||
assetId: "asset-aborted-generation-asset",
|
||||
signal: controller.signal,
|
||||
createAsset: () => ({ id: "asset-aborted-generation-asset", metadata: { generationEffectKey: "materialize:aborted-generation-asset:0" } }),
|
||||
updateAssets: () => undefined,
|
||||
readAssets: () => [],
|
||||
readPersistedAssets: async () => [],
|
||||
persistAssets: async () => {
|
||||
persistenceWrites += 1;
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof DOMException && error.name === "AbortError",
|
||||
);
|
||||
assert.equal(persistenceWrites, 0);
|
||||
});
|
||||
|
||||
test("generation asset lock flush drains tails added while the flush is running", async () => {
|
||||
let releaseFirst!: () => void;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let firstStartedResolve!: () => void;
|
||||
const firstStarted = new Promise<void>((resolve) => {
|
||||
firstStartedResolve = resolve;
|
||||
});
|
||||
const first = withGenerationAssetStorageLock("flush-scope", async () => {
|
||||
firstStartedResolve();
|
||||
await firstGate;
|
||||
});
|
||||
await firstStarted;
|
||||
|
||||
let flushSettled = false;
|
||||
const flushing = flushGenerationAssetStorageLocks().then(() => {
|
||||
flushSettled = true;
|
||||
});
|
||||
|
||||
let releaseSecond!: () => void;
|
||||
const secondGate = new Promise<void>((resolve) => {
|
||||
releaseSecond = resolve;
|
||||
});
|
||||
let secondStartedResolve!: () => void;
|
||||
const secondStarted = new Promise<void>((resolve) => {
|
||||
secondStartedResolve = resolve;
|
||||
});
|
||||
const second = withGenerationAssetStorageLock("flush-scope", async () => {
|
||||
secondStartedResolve();
|
||||
await secondGate;
|
||||
});
|
||||
|
||||
releaseFirst();
|
||||
await secondStarted;
|
||||
try {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(flushSettled, false, "flush must wait for a tail queued during the active flush");
|
||||
} finally {
|
||||
releaseSecond();
|
||||
await Promise.all([first, second, flushing]);
|
||||
}
|
||||
assert.equal(flushSettled, true);
|
||||
});
|
||||
|
||||
test("generation artifact sink recovers after ack crash without a second materialization", async () => {
|
||||
const durable = new Map<string, { storageKey: string }>();
|
||||
const effectKey = "materialize:task-ack-crash:0";
|
||||
const storageKey = generationArtifactStorageKey(effectKey, "image", "test-scope");
|
||||
let materializations = 0;
|
||||
const run = () =>
|
||||
loadOrStoreGenerationArtifact({
|
||||
effectKey: storageKey,
|
||||
read: async (key) => durable.get(key) ?? null,
|
||||
materialize: async () => {
|
||||
materializations += 1;
|
||||
return { storageKey };
|
||||
},
|
||||
write: async (key, artifact) => {
|
||||
durable.set(key, artifact);
|
||||
},
|
||||
});
|
||||
|
||||
await run();
|
||||
// Simulate a process crash after the durable sink succeeded but before the Agent effect ack.
|
||||
for (let replay = 0; replay < 3; replay += 1) await run();
|
||||
|
||||
assert.equal(materializations, 1);
|
||||
assert.equal(durable.size, 1);
|
||||
assert.equal(durable.get(storageKey)?.storageKey, storageKey);
|
||||
});
|
||||
52
web/test/generation-storage-consistency.test.ts
Normal file
52
web/test/generation-storage-consistency.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race";
|
||||
type ScenarioResponse<T> = { ok: true; result: T } | { ok: false; error: string };
|
||||
|
||||
function runScenario<T>(scenario: Scenario): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const worker = new Worker(new URL("./helpers/generation-storage-consistency.worker.ts", import.meta.url).href, { type: "module" });
|
||||
const finish = () => {
|
||||
worker.terminate();
|
||||
};
|
||||
worker.onmessage = (event: MessageEvent<ScenarioResponse<T>>) => {
|
||||
finish();
|
||||
if (event.data.ok) resolve(event.data.result);
|
||||
else reject(new Error(event.data.error));
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
finish();
|
||||
reject(event.error ?? new Error(event.message));
|
||||
};
|
||||
worker.postMessage(scenario);
|
||||
});
|
||||
}
|
||||
|
||||
test("generation image cleanup removes unused generation-image blobs and preserves referenced ones", async () => {
|
||||
const result = await runScenario<{ usedPresent: boolean; unusedPresent: boolean }>("image-cleanup");
|
||||
expect(result.usedPresent).toBe(true);
|
||||
expect(result.unusedPresent).toBe(false);
|
||||
});
|
||||
|
||||
test("delayed cleanup keeps account-A Canvas-only image references after switching the active account", async () => {
|
||||
const result = await runScenario<{ referencedPresent: boolean; unusedPresent: boolean; otherScopePresent: boolean }>("scope-cleanup-switch");
|
||||
expect(result.referencedPresent).toBe(true);
|
||||
expect(result.unusedPresent).toBe(false);
|
||||
expect(result.otherScopePresent).toBe(true);
|
||||
});
|
||||
|
||||
test("delayed cleanup preserves a same-scope Canvas-only reference added after cleanup was queued", async () => {
|
||||
const result = await runScenario<{ referencedPresent: boolean; unusedPresent: boolean }>("scope-cleanup-late-canvas-reference");
|
||||
expect(result.referencedPresent).toBe(true);
|
||||
expect(result.unusedPresent).toBe(false);
|
||||
});
|
||||
|
||||
test("generation video and audio materialization cannot race cleanup into a catalog row whose blob is missing", async () => {
|
||||
for (const mediaType of ["video", "audio"] as const) {
|
||||
const result = await runScenario<{ kind?: string; storageKey?: string; blobPresent: boolean; generationAssetCount: number }>(`${mediaType}-commit-race`);
|
||||
expect(result.kind).toBe(mediaType);
|
||||
expect(result.storageKey).toMatch(new RegExp(`^generation-${mediaType}:generation-media-commit-race-${mediaType}:`));
|
||||
expect(result.blobPresent).toBe(true);
|
||||
expect(result.generationAssetCount).toBe(1);
|
||||
}
|
||||
});
|
||||
110
web/test/generation-task-durability.node.test.ts
Normal file
110
web/test/generation-task-durability.node.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import type { GenerationTask } from "../src/services/api/task-center";
|
||||
import { applyGenerationConsumerEffect } from "../src/services/generation-consumer-dedupe";
|
||||
import { createGenerationTaskMaterializer, type GenerationTaskEffectResult, type GenerationTaskEffectStore } from "../src/services/generation-task-materializer";
|
||||
|
||||
test("node message and agent consumers receive stable effect keys across three replays", async () => {
|
||||
const completed = new Map<string, GenerationTaskEffectResult>();
|
||||
const claimed = new Set<string>();
|
||||
const effects: GenerationTaskEffectStore = {
|
||||
async claim(effectKey) {
|
||||
const result = completed.get(effectKey);
|
||||
if (result) return { status: "completed", result };
|
||||
if (claimed.has(effectKey)) return { status: "busy" };
|
||||
claimed.add(effectKey);
|
||||
return { status: "claimed", fence: 1 };
|
||||
},
|
||||
async renew() {
|
||||
return { fence: 1 };
|
||||
},
|
||||
async complete(effectKey, _taskId, result) {
|
||||
claimed.delete(effectKey);
|
||||
completed.set(effectKey, result);
|
||||
},
|
||||
async release(effectKey) {
|
||||
claimed.delete(effectKey);
|
||||
},
|
||||
};
|
||||
const materializer = createGenerationTaskMaterializer({
|
||||
effects,
|
||||
async materializeOutput() {
|
||||
throw new Error("already materialized");
|
||||
},
|
||||
});
|
||||
const task: GenerationTask = {
|
||||
id: "task-consumer-effect-keys",
|
||||
type: "image",
|
||||
status: "succeeded",
|
||||
prompt: "redacted",
|
||||
attempts: 1,
|
||||
resultState: "READY",
|
||||
outputs: [{ outputIndex: 0, mediaType: "image", materializedAssetId: "asset-stable-id" }],
|
||||
createdAt: "2026-08-13T00:00:00.000Z",
|
||||
updatedAt: "2026-08-13T00:00:00.000Z",
|
||||
};
|
||||
const seen: string[] = [];
|
||||
|
||||
for (let replay = 0; replay < 3; replay += 1) {
|
||||
await materializer.attachNode(task, "node-safe-id", 0, async ({ effectKey }) => {
|
||||
seen.push(effectKey);
|
||||
});
|
||||
await materializer.attachMessage(task, "message-safe-id", 0, async ({ effectKey }) => {
|
||||
seen.push(effectKey);
|
||||
});
|
||||
await materializer.resumeAgent(task, "continuation-safe-id", async ({ effectKey }) => {
|
||||
seen.push(effectKey);
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepEqual(seen, ["attach-node:task-consumer-effect-keys:node-safe-id:0", "attach-message:task-consumer-effect-keys:message-safe-id:0", "agent-resume:task-consumer-effect-keys:continuation-safe-id"]);
|
||||
});
|
||||
|
||||
test("production-shaped node message and agent records replay three times with one side effect", () => {
|
||||
const effectKeys = {
|
||||
node: "attach-node:task-consumer:node-safe-id:0",
|
||||
message: "attach-message:task-consumer:message-safe-id:0",
|
||||
agent: "agent-resume:task-consumer:continuation-safe-id",
|
||||
};
|
||||
const calls = { node: 0, message: 0, agent: 0 };
|
||||
let node = { generationEffectKeys: [] as string[], content: "" };
|
||||
let message = { generationEffectKeys: [] as string[], resultUrls: [] as string[] };
|
||||
let agent = { generationEffectKeys: [] as string[], resumed: false };
|
||||
|
||||
for (let replay = 0; replay < 3; replay += 1) {
|
||||
node = applyGenerationConsumerEffect(node, effectKeys.node, (current) => {
|
||||
calls.node += 1;
|
||||
return { ...current, content: "opaque://asset" };
|
||||
}).value;
|
||||
message = applyGenerationConsumerEffect(message, effectKeys.message, (current) => {
|
||||
calls.message += 1;
|
||||
return { ...current, resultUrls: ["opaque://asset"] };
|
||||
}).value;
|
||||
agent = applyGenerationConsumerEffect(agent, effectKeys.agent, (current) => {
|
||||
calls.agent += 1;
|
||||
return { ...current, resumed: true };
|
||||
}).value;
|
||||
}
|
||||
|
||||
node = structuredClone(node);
|
||||
message = structuredClone(message);
|
||||
agent = structuredClone(agent);
|
||||
node = applyGenerationConsumerEffect(node, effectKeys.node, () => {
|
||||
calls.node += 1;
|
||||
return node;
|
||||
}).value;
|
||||
message = applyGenerationConsumerEffect(message, effectKeys.message, () => {
|
||||
calls.message += 1;
|
||||
return message;
|
||||
}).value;
|
||||
agent = applyGenerationConsumerEffect(agent, effectKeys.agent, () => {
|
||||
calls.agent += 1;
|
||||
return agent;
|
||||
}).value;
|
||||
|
||||
assert.deepEqual(calls, { node: 1, message: 1, agent: 1 });
|
||||
assert.equal(node.generationEffectKeys.length, 1);
|
||||
assert.equal(message.generationEffectKeys.length, 1);
|
||||
assert.equal(agent.generationEffectKeys.length, 1);
|
||||
});
|
||||
2425
web/test/generation-task-local.test.ts
Normal file
2425
web/test/generation-task-local.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
3117
web/test/generation-task-materializer.test.ts
Normal file
3117
web/test/generation-task-materializer.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
409
web/test/helpers/generation-storage-consistency.worker.ts
Normal file
409
web/test/helpers/generation-storage-consistency.worker.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import localforage from "localforage";
|
||||
|
||||
import { getActiveUserScope, setActiveUserScope } from "../../src/lib/user-scope";
|
||||
|
||||
type InstanceHook = (storeName: string, key: string, value: unknown) => Promise<void> | void;
|
||||
|
||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race";
|
||||
|
||||
function installStorageHarness() {
|
||||
const originalCreateInstance = localforage.createInstance.bind(localforage);
|
||||
const originalGetItem = localforage.getItem.bind(localforage);
|
||||
const originalSetItem = localforage.setItem.bind(localforage);
|
||||
const originalRemoveItem = localforage.removeItem.bind(localforage);
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
const originalNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
const originalDocument = (globalThis as { document?: unknown }).document;
|
||||
const realSetTimeout = globalThis.setTimeout.bind(globalThis);
|
||||
|
||||
const defaultValues = new Map<string, unknown>();
|
||||
const instanceValues = new Map<string, Map<string, unknown>>();
|
||||
const localStorageValues = new Map<string, string>();
|
||||
const scheduled: Promise<void>[] = [];
|
||||
const hooks: { onSet?: InstanceHook } = {};
|
||||
const lockTails = new Map<string, Promise<void>>();
|
||||
|
||||
const storeValues = (storeName: string) => {
|
||||
let values = instanceValues.get(storeName);
|
||||
if (!values) {
|
||||
values = new Map<string, unknown>();
|
||||
instanceValues.set(storeName, values);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
localforage.getItem = (async (key: string) => defaultValues.get(key) ?? null) as typeof localforage.getItem;
|
||||
localforage.setItem = (async (key: string, value: unknown) => {
|
||||
defaultValues.set(key, value);
|
||||
return value;
|
||||
}) as typeof localforage.setItem;
|
||||
localforage.removeItem = (async (key: string) => {
|
||||
defaultValues.delete(key);
|
||||
}) as typeof localforage.removeItem;
|
||||
localforage.createInstance = ((options: { storeName?: string }) => {
|
||||
const storeName = options.storeName || "default";
|
||||
const values = storeValues(storeName);
|
||||
return {
|
||||
getItem: async (key: string) => values.get(key) ?? null,
|
||||
setItem: async (key: string, value: unknown) => {
|
||||
values.set(key, value);
|
||||
await hooks.onSet?.(storeName, key, value);
|
||||
return value;
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
iterate: async (iterator: (value: unknown, key: string, iterationNumber: number) => unknown) => {
|
||||
let index = 1;
|
||||
for (const [key, value] of values) {
|
||||
const result = iterator(value, key, index);
|
||||
index += 1;
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
clear: async () => values.clear(),
|
||||
keys: async () => [...values.keys()],
|
||||
length: async () => values.size,
|
||||
} as never;
|
||||
}) as typeof localforage.createInstance;
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => localStorageValues.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => localStorageValues.set(key, value),
|
||||
removeItem: (key: string) => localStorageValues.delete(key),
|
||||
},
|
||||
setTimeout: (handler: () => unknown, delay = 0) => {
|
||||
const scheduledRun = new Promise<void>((resolve, reject) => {
|
||||
realSetTimeout(() => {
|
||||
Promise.resolve(handler()).then(() => resolve(), reject);
|
||||
}, delay);
|
||||
});
|
||||
scheduled.push(scheduledRun);
|
||||
return scheduled.length;
|
||||
},
|
||||
clearTimeout: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
locks: {
|
||||
request: async <T>(name: string, callback: () => Promise<T>) => {
|
||||
const previous = lockTails.get(name) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const tail = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const queued = previous.catch(() => undefined).then(() => tail);
|
||||
lockTails.set(name, queued);
|
||||
await previous.catch(() => undefined);
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
release();
|
||||
if (lockTails.get(name) === queued) lockTails.delete(name);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
|
||||
return {
|
||||
hooks,
|
||||
scheduled,
|
||||
realSetTimeout,
|
||||
restore() {
|
||||
localforage.createInstance = originalCreateInstance as typeof localforage.createInstance;
|
||||
localforage.getItem = originalGetItem;
|
||||
localforage.setItem = originalSetItem;
|
||||
localforage.removeItem = originalRemoveItem;
|
||||
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
|
||||
else Object.defineProperty(globalThis, "window", { configurable: true, writable: true, value: originalWindow });
|
||||
if (originalNavigator === undefined) delete (globalThis as { navigator?: unknown }).navigator;
|
||||
else Object.defineProperty(globalThis, "navigator", { configurable: true, writable: true, value: originalNavigator });
|
||||
if (originalDocument === undefined) delete (globalThis as { document?: unknown }).document;
|
||||
else Object.defineProperty(globalThis, "document", { configurable: true, writable: true, value: originalDocument });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runImageCleanup() {
|
||||
const previousScope = getActiveUserScope();
|
||||
const harness = installStorageHarness();
|
||||
const scope = "generation-image-cleanup";
|
||||
const usedKey = `generation-image:${scope}:used`;
|
||||
const unusedKey = `generation-image:${scope}:unused`;
|
||||
const imageStorage = await import("../../src/services/image-storage.ts?generation-image-cleanup-contract-worker");
|
||||
|
||||
try {
|
||||
setActiveUserScope(scope);
|
||||
await imageStorage.setImageBlob(usedKey, new Blob(["used"], { type: "image/png" }));
|
||||
await imageStorage.setImageBlob(unusedKey, new Blob(["unused"], { type: "image/png" }));
|
||||
await imageStorage.cleanupUnusedImages({ assets: [{ data: { storageKey: usedKey } }] });
|
||||
return {
|
||||
usedPresent: (await imageStorage.getImageBlob(usedKey)) instanceof Blob,
|
||||
unusedPresent: (await imageStorage.getImageBlob(unusedKey)) instanceof Blob,
|
||||
};
|
||||
} finally {
|
||||
setActiveUserScope(previousScope);
|
||||
harness.restore();
|
||||
}
|
||||
}
|
||||
|
||||
async function runScopeCleanupAfterSwitch() {
|
||||
const previousScope = getActiveUserScope();
|
||||
const harness = installStorageHarness();
|
||||
const scopeA = "cleanup-account-a";
|
||||
const scopeB = "cleanup-account-b";
|
||||
const referencedKey = `image:${scopeA}:canvas-only`;
|
||||
const unusedKey = `image:${scopeA}:unused`;
|
||||
const otherScopeKey = `image:${scopeB}:unrelated`;
|
||||
|
||||
try {
|
||||
setActiveUserScope(scopeA);
|
||||
const imageStorage = await import("../../src/services/image-storage.ts?scope-cleanup-switch-worker");
|
||||
const { useAssetStore } = await import("../../src/stores/use-asset-store");
|
||||
const { useCanvasStore } = await import("../../src/stores/canvas/use-canvas-store");
|
||||
await imageStorage.setImageBlob(referencedKey, new Blob(["account-a-referenced"], { type: "image/png" }));
|
||||
await imageStorage.setImageBlob(unusedKey, new Blob(["account-a-unused"], { type: "image/png" }));
|
||||
await imageStorage.setImageBlob(otherScopeKey, new Blob(["account-b-unrelated"], { type: "image/png" }));
|
||||
useAssetStore.setState({ assets: [] });
|
||||
useCanvasStore.setState({
|
||||
projects: [
|
||||
{
|
||||
id: "canvas-account-a",
|
||||
title: "account A",
|
||||
createdAt: "2026-08-14T00:00:00.000Z",
|
||||
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||
nodes: [
|
||||
{
|
||||
id: "node-account-a-image",
|
||||
type: "image",
|
||||
title: "account A image",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 1,
|
||||
height: 1,
|
||||
metadata: { storageKey: referencedKey },
|
||||
},
|
||||
],
|
||||
connections: [],
|
||||
chatSessions: [],
|
||||
activeChatId: null,
|
||||
backgroundMode: "dots",
|
||||
showImageInfo: false,
|
||||
viewport: { x: 0, y: 0, k: 1 },
|
||||
directorScenes: [],
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
useAssetStore.getState().cleanupImages({ assets: [] });
|
||||
const cleanupRun = harness.scheduled.at(-1);
|
||||
if (!cleanupRun) throw new Error("cleanup was not scheduled");
|
||||
|
||||
setActiveUserScope(scopeB);
|
||||
useAssetStore.setState({ assets: [] });
|
||||
useCanvasStore.setState({
|
||||
projects: [
|
||||
{
|
||||
id: "canvas-account-b",
|
||||
title: "account B",
|
||||
createdAt: "2026-08-14T00:00:00.000Z",
|
||||
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||
nodes: [],
|
||||
connections: [],
|
||||
chatSessions: [],
|
||||
activeChatId: null,
|
||||
backgroundMode: "dots",
|
||||
showImageInfo: false,
|
||||
viewport: { x: 0, y: 0, k: 1 },
|
||||
directorScenes: [],
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
await cleanupRun;
|
||||
|
||||
return {
|
||||
referencedPresent: (await imageStorage.getImageBlob(referencedKey)) instanceof Blob,
|
||||
unusedPresent: (await imageStorage.getImageBlob(unusedKey)) instanceof Blob,
|
||||
otherScopePresent: (await imageStorage.getImageBlob(otherScopeKey)) instanceof Blob,
|
||||
};
|
||||
} finally {
|
||||
setActiveUserScope(previousScope);
|
||||
harness.restore();
|
||||
}
|
||||
}
|
||||
|
||||
async function runScopeCleanupAfterLateCanvasReference() {
|
||||
const previousScope = getActiveUserScope();
|
||||
const harness = installStorageHarness();
|
||||
const scope = "cleanup-late-canvas-reference";
|
||||
const referencedKey = `image:${scope}:late-canvas-only`;
|
||||
const unusedKey = `image:${scope}:unused`;
|
||||
|
||||
try {
|
||||
setActiveUserScope(scope);
|
||||
const imageStorage = await import("../../src/services/image-storage.ts?scope-cleanup-late-canvas-reference-worker");
|
||||
const { useAssetStore } = await import("../../src/stores/use-asset-store");
|
||||
const { useCanvasStore } = await import("../../src/stores/canvas/use-canvas-store");
|
||||
await imageStorage.setImageBlob(referencedKey, new Blob(["late-canvas-reference"], { type: "image/png" }));
|
||||
await imageStorage.setImageBlob(unusedKey, new Blob(["still-unused"], { type: "image/png" }));
|
||||
useAssetStore.setState({ assets: [] });
|
||||
useCanvasStore.setState({ projects: [] });
|
||||
|
||||
useAssetStore.getState().cleanupImages({ assets: [] });
|
||||
const cleanupRun = harness.scheduled.at(-1);
|
||||
if (!cleanupRun) throw new Error("cleanup was not scheduled");
|
||||
|
||||
useCanvasStore.setState({
|
||||
projects: [
|
||||
{
|
||||
id: "canvas-late-reference",
|
||||
title: "late reference",
|
||||
createdAt: "2026-08-14T00:00:00.000Z",
|
||||
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||
nodes: [
|
||||
{
|
||||
id: "node-late-reference",
|
||||
type: "image",
|
||||
title: "late referenced image",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 1,
|
||||
height: 1,
|
||||
metadata: { storageKey: referencedKey },
|
||||
},
|
||||
],
|
||||
connections: [],
|
||||
chatSessions: [],
|
||||
activeChatId: null,
|
||||
backgroundMode: "dots",
|
||||
showImageInfo: false,
|
||||
viewport: { x: 0, y: 0, k: 1 },
|
||||
directorScenes: [],
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
await cleanupRun;
|
||||
|
||||
return {
|
||||
referencedPresent: (await imageStorage.getImageBlob(referencedKey)) instanceof Blob,
|
||||
unusedPresent: (await imageStorage.getImageBlob(unusedKey)) instanceof Blob,
|
||||
};
|
||||
} finally {
|
||||
setActiveUserScope(previousScope);
|
||||
harness.restore();
|
||||
}
|
||||
}
|
||||
|
||||
async function runMediaCommitRace(mediaType: "video" | "audio") {
|
||||
const previousScope = getActiveUserScope();
|
||||
const harness = installStorageHarness();
|
||||
const scope = `generation-media-commit-race-${mediaType}`;
|
||||
let releaseMediaWrite!: () => void;
|
||||
const mediaWriteGate = new Promise<void>((resolve) => {
|
||||
releaseMediaWrite = resolve;
|
||||
});
|
||||
let mediaWriteStartedResolve!: () => void;
|
||||
const mediaWriteStarted = new Promise<void>((resolve) => {
|
||||
mediaWriteStartedResolve = resolve;
|
||||
});
|
||||
let blocked = false;
|
||||
harness.hooks.onSet = async (storeName, key) => {
|
||||
if (!blocked && storeName === "media_files" && key.startsWith(`generation-${mediaType}:${scope}:`)) {
|
||||
blocked = true;
|
||||
mediaWriteStartedResolve();
|
||||
await mediaWriteGate;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
setActiveUserScope(scope);
|
||||
const { materializeGenerationTaskAssets } = await import("../../src/services/project-asset-sync.ts?generation-media-commit-race-worker");
|
||||
const { useAssetStore } = await import("../../src/stores/use-asset-store");
|
||||
await import("../../src/stores/canvas/use-canvas-store");
|
||||
const fileStorage = await import("../../src/services/file-storage");
|
||||
const task = {
|
||||
id: `remote-generation-media-race-${mediaType}`,
|
||||
provider: "remote-test-provider",
|
||||
type: mediaType,
|
||||
status: "succeeded",
|
||||
prompt: "redacted",
|
||||
attempts: 1,
|
||||
resultState: "PENDING_MATERIALIZATION",
|
||||
resultJson: JSON.stringify(
|
||||
mediaType === "video"
|
||||
? {
|
||||
mode: "video",
|
||||
video: {
|
||||
dataUrl: "data:video/mp4;base64,AAAA",
|
||||
width: 16,
|
||||
height: 9,
|
||||
mimeType: "video/mp4",
|
||||
},
|
||||
}
|
||||
: {
|
||||
mode: "audio",
|
||||
audio: {
|
||||
dataUrl: "data:audio/mpeg;base64,AAAA",
|
||||
mimeType: "audio/mpeg",
|
||||
},
|
||||
},
|
||||
),
|
||||
createdAt: "2026-08-14T00:00:00.000Z",
|
||||
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||
} as const;
|
||||
|
||||
const materializing = materializeGenerationTaskAssets(task as never);
|
||||
await mediaWriteStarted;
|
||||
useAssetStore.getState().cleanupImages({ assets: [] });
|
||||
const cleanupRun = harness.scheduled.at(-1);
|
||||
if (!cleanupRun) throw new Error("cleanup was not scheduled");
|
||||
await Promise.race([
|
||||
cleanupRun,
|
||||
new Promise<void>((resolve) => {
|
||||
harness.realSetTimeout(resolve, 20);
|
||||
}),
|
||||
]);
|
||||
|
||||
releaseMediaWrite();
|
||||
const materialized = await materializing;
|
||||
await cleanupRun;
|
||||
|
||||
const assetId = materialized.outputs?.[0]?.materializedAssetId;
|
||||
const asset = useAssetStore.getState().assets.find((candidate) => candidate.id === assetId);
|
||||
const storageKey = asset?.kind === "video" || asset?.kind === "audio" ? asset.data.storageKey : undefined;
|
||||
const blobPresent = storageKey ? (await fileStorage.getMediaBlob(storageKey)) instanceof Blob : false;
|
||||
const generationAssetCount = useAssetStore.getState().assets.filter((candidate) => candidate.metadata?.generationEffectKey === `materialize:${task.id}:0`).length;
|
||||
return { kind: asset?.kind, storageKey, blobPresent, generationAssetCount };
|
||||
} finally {
|
||||
releaseMediaWrite();
|
||||
setActiveUserScope(previousScope);
|
||||
harness.restore();
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<Scenario>) => {
|
||||
try {
|
||||
const result =
|
||||
event.data === "image-cleanup"
|
||||
? await runImageCleanup()
|
||||
: event.data === "scope-cleanup-switch"
|
||||
? await runScopeCleanupAfterSwitch()
|
||||
: event.data === "scope-cleanup-late-canvas-reference"
|
||||
? await runScopeCleanupAfterLateCanvasReference()
|
||||
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
|
||||
self.postMessage({ ok: true, result });
|
||||
} catch (error) {
|
||||
self.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
|
||||
}
|
||||
};
|
||||
123
web/test/local-channel-permission.test.ts
Normal file
123
web/test/local-channel-permission.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import axios from "axios";
|
||||
|
||||
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 { channelConnectionSignature, createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, type ModelChannel } from "../src/stores/use-config-store";
|
||||
import { defaultFeatureAvailability, useUserStore } from "../src/stores/use-user-store";
|
||||
|
||||
const originalAxiosPost = axios.post;
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
axios.post = originalAxiosPost;
|
||||
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>[] = [];
|
||||
axios.post = (async (_url: string, body: Record<string, unknown>) => {
|
||||
bodies.push(body);
|
||||
return { data: { code: 0, data: { models: [{ id: "local-model" }] }, msg: "ok" } };
|
||||
}) as typeof axios.post;
|
||||
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");
|
||||
});
|
||||
});
|
||||
122
web/test/local-channel-runtime-projection.node.test.mjs
Normal file
122
web/test/local-channel-runtime-projection.node.test.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, test } from "node:test";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import axios from "axios";
|
||||
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 originalAxiosPost = axios.post;
|
||||
|
||||
after(async () => {
|
||||
await runtime.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axios.post = originalAxiosPost;
|
||||
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 = [];
|
||||
axios.post = async (_url, body) => {
|
||||
bodies.push(body);
|
||||
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");
|
||||
});
|
||||
71
web/test/local-channel-task-center.node.test.mjs
Normal file
71
web/test/local-channel-task-center.node.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
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 originalPost = requestModule.apiClient.post;
|
||||
requestModule.apiClient.post = async (url, body) => {
|
||||
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" } };
|
||||
}
|
||||
return { data: { code: 0, data: { id: "task", type: "canvas_text", status: "queued", prompt: "p", attempts: 1, createdAt: "now", updatedAt: "now" }, msg: "ok" } };
|
||||
};
|
||||
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.post = originalPost;
|
||||
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);
|
||||
});
|
||||
117
web/test/local-channel-ui.node.test.mjs
Normal file
117
web/test/local-channel-ui.node.test.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
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 });
|
||||
});
|
||||
247
web/test/local-cli-settings.test.ts
Normal file
247
web/test/local-cli-settings.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
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;
|
||||
255
web/test/local-dreamina-cli.test.ts
Normal file
255
web/test/local-dreamina-cli.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
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" },
|
||||
});
|
||||
}
|
||||
197
web/test/local-dreamina-cross-runtime.test.ts
Normal file
197
web/test/local-dreamina-cross-runtime.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
814
web/test/local-dreamina-generation.test.ts
Normal file
814
web/test/local-dreamina-generation.test.ts
Normal file
@@ -0,0 +1,814 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
240
web/test/local-dreamina-model-catalog.test.ts
Normal file
240
web/test/local-dreamina-model-catalog.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
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);
|
||||
});
|
||||
|
||||
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" } },
|
||||
);
|
||||
}
|
||||
60
web/test/local-runtime-bootstrap.test.ts
Normal file
60
web/test/local-runtime-bootstrap.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
};
|
||||
357
web/test/local-runtime-connection.test.ts
Normal file
357
web/test/local-runtime-connection.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
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>;
|
||||
};
|
||||
};
|
||||
229
web/test/local-runtime.test.ts
Normal file
229
web/test/local-runtime.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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, "");
|
||||
}
|
||||
4014
web/test/user-scoped-generation-persistence.test.ts
Normal file
4014
web/test/user-scoped-generation-persistence.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user