326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
|
|
test("Canvas consumes signed fetch SSE without bearer data in the URL or headers", async () => {
|
|
const module = await import("../src/lib/canvas/local-runtime-connection").catch(() => ({}));
|
|
const consume = (
|
|
module as {
|
|
consumeLocalRuntimeEventStream?: (client: RuntimeTransport, path: string, options: { lastEventId?: string; onEvent(event: RuntimeEvent): void }) => Promise<void>;
|
|
}
|
|
).consumeLocalRuntimeEventStream;
|
|
expect(typeof consume).toBe("function");
|
|
if (!consume) return;
|
|
|
|
const calls: Array<{ path: string; headers: Headers; method: string }> = [];
|
|
const events: RuntimeEvent[] = [];
|
|
await consume(
|
|
{
|
|
async request(path, init = {}) {
|
|
calls.push({ path, headers: new Headers(init.headers), method: String(init.method || "GET") });
|
|
return sseResponse(['id: 7\nevent: hello\ndata: {"ok":true}\n\n', "id: 8\nevent: agent_log\ndata: first\ndata: second\n\n"]);
|
|
},
|
|
},
|
|
"/events?clientId=client-1",
|
|
{
|
|
lastEventId: "6",
|
|
onEvent: (event) => events.push(event),
|
|
},
|
|
);
|
|
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]).toMatchObject({ path: "/events?clientId=client-1", method: "GET" });
|
|
expect(calls[0].headers.get("last-event-id")).toBe("6");
|
|
expect(calls[0].headers.has("authorization")).toBe(false);
|
|
expect(calls[0].headers.has("x-canvas-agent-token")).toBe(false);
|
|
expect(calls[0].path).not.toContain("token");
|
|
expect(events).toEqual([
|
|
{ type: "hello", data: '{"ok":true}', id: "7" },
|
|
{ type: "agent_log", data: "first\nsecond", id: "8" },
|
|
]);
|
|
});
|
|
|
|
test("Canvas SSE rejects oversized events and pre-cancelled requests", async () => {
|
|
const module = await import("../src/lib/canvas/local-runtime-connection");
|
|
let calls = 0;
|
|
const cancelled = new AbortController();
|
|
cancelled.abort();
|
|
await expect(
|
|
module.consumeLocalRuntimeEventStream(
|
|
{
|
|
request: async () => {
|
|
calls++;
|
|
return sseResponse([]);
|
|
},
|
|
},
|
|
"/events?clientId=client-1",
|
|
{ signal: cancelled.signal, onEvent: () => undefined },
|
|
),
|
|
).rejects.toMatchObject({ name: "AbortError" });
|
|
expect(calls).toBe(0);
|
|
|
|
await expect(
|
|
module.consumeLocalRuntimeEventStream(
|
|
{
|
|
request: async () => sseResponse([`event: agent_log\ndata: ${"x".repeat(300 * 1024)}\n\n`]),
|
|
},
|
|
"/events?clientId=client-1",
|
|
{ onEvent: () => undefined },
|
|
),
|
|
).rejects.toMatchObject({ code: "canvas_event_too_large" });
|
|
});
|
|
|
|
test("Canvas SSE accepts one large transport chunk containing many bounded events", async () => {
|
|
const module = await import("../src/lib/canvas/local-runtime-connection");
|
|
const chunk = Array.from({ length: 3_000 }, (_, index) => `id: ${index}\nevent: agent_log\ndata: ${"x".repeat(80)}\n\n`).join("");
|
|
let events = 0;
|
|
|
|
await module.consumeLocalRuntimeEventStream(
|
|
{
|
|
request: async () => sseResponse([chunk]),
|
|
},
|
|
"/events?clientId=client-1",
|
|
{
|
|
onEvent: () => {
|
|
events++;
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(events).toBe(3_000);
|
|
});
|
|
|
|
test("Canvas Agent UI store contains no Runtime endpoint, bearer, or session fields", async () => {
|
|
const { useCanvasAgentStore } = await import("../src/stores/canvas/use-canvas-agent-store");
|
|
const state = useCanvasAgentStore.getState();
|
|
|
|
expect("url" in state).toBe(false);
|
|
expect("token" in state).toBe(false);
|
|
expect("endpoint" in state).toBe(false);
|
|
expect("session" in state).toBe(false);
|
|
});
|
|
|
|
test("Canvas Agent reconnect intent persists while transient disconnects preserve the active conversation", async () => {
|
|
const module = await import("../src/stores/canvas/use-canvas-agent-store").catch(() => ({}));
|
|
const connectionStartingPatch = (
|
|
module as {
|
|
canvasAgentConnectionStartingPatch?: () => Record<string, unknown>;
|
|
}
|
|
).canvasAgentConnectionStartingPatch;
|
|
const transientDisconnectPatch = (
|
|
module as {
|
|
canvasAgentTransientDisconnectPatch?: (activity: string, connectError: string) => Record<string, unknown>;
|
|
}
|
|
).canvasAgentTransientDisconnectPatch;
|
|
const writeEnabled = (
|
|
module as {
|
|
writeCanvasAgentEnabledPreference?: (enabled: boolean, storage: { setItem(key: string, value: string): void }) => void;
|
|
}
|
|
).writeCanvasAgentEnabledPreference;
|
|
const readEnabled = (
|
|
module as {
|
|
readCanvasAgentEnabledPreference?: (storage: { getItem(key: string): string | null }) => boolean;
|
|
}
|
|
).readCanvasAgentEnabledPreference;
|
|
|
|
expect(typeof connectionStartingPatch).toBe("function");
|
|
expect(typeof transientDisconnectPatch).toBe("function");
|
|
expect(typeof writeEnabled).toBe("function");
|
|
expect(typeof readEnabled).toBe("function");
|
|
if (!connectionStartingPatch || !transientDisconnectPatch || !writeEnabled || !readEnabled) return;
|
|
|
|
const durable = {
|
|
messages: [{ id: "message-1", role: "assistant", text: "kept" }],
|
|
threads: [{ id: "thread-1", preview: "kept" }],
|
|
activeThreadId: "thread-1",
|
|
workspacePath: "D:/workspace",
|
|
pendingTool: { requestId: "tool-1", name: "canvas_get_state" },
|
|
};
|
|
const starting = { ...durable, ...connectionStartingPatch() };
|
|
expect(starting).toMatchObject({ enabled: true, connected: false, activity: "连接中", ...durable });
|
|
|
|
const reconnecting = { ...starting, ...transientDisconnectPatch("正在重连", "连接已断开") };
|
|
expect(reconnecting).toMatchObject({ enabled: true, connected: false, activity: "正在重连", connectError: "连接已断开", ...durable });
|
|
|
|
const values = new Map<string, string>();
|
|
const storage = {
|
|
getItem: (key: string) => values.get(key) ?? null,
|
|
setItem: (key: string, value: string) => values.set(key, value),
|
|
};
|
|
writeEnabled(true, storage);
|
|
expect(readEnabled(storage)).toBe(true);
|
|
writeEnabled(false, storage);
|
|
expect(readEnabled(storage)).toBe(false);
|
|
});
|
|
|
|
test("Canvas Agent status shows reconnecting instead of a stale connection failure", async () => {
|
|
const module = await import("../src/stores/canvas/use-canvas-agent-store").catch(() => ({}));
|
|
const statusText = (
|
|
module as {
|
|
canvasAgentConnectionStatusText?: (state: { enabled: boolean; connected: boolean; activity: string; connectError: string }) => string;
|
|
}
|
|
).canvasAgentConnectionStatusText;
|
|
expect(typeof statusText).toBe("function");
|
|
if (!statusText) return;
|
|
|
|
expect(statusText({ enabled: true, connected: false, activity: "正在重连", connectError: "连接已断开" })).toBe("正在重连");
|
|
expect(statusText({ enabled: true, connected: false, activity: "连接失败", connectError: "首次连接失败" })).toBe("连接失败");
|
|
});
|
|
|
|
test("Canvas connection reuses the shared Runtime store and requires the canvas module", async () => {
|
|
const module = await import("../src/lib/canvas/local-runtime-connection");
|
|
const prepare = (
|
|
module as {
|
|
prepareCanvasRuntimeConnection?: (store: RuntimeStore, signal?: AbortSignal) => Promise<void>;
|
|
}
|
|
).prepareCanvasRuntimeConnection;
|
|
expect(typeof prepare).toBe("function");
|
|
if (!prepare) return;
|
|
|
|
let connectCalls = 0;
|
|
const state = {
|
|
connection: "idle",
|
|
modules: [] as Array<{ id: string }>,
|
|
async connect() {
|
|
connectCalls++;
|
|
state.connection = "connected";
|
|
state.modules = [{ id: "canvas-agent" }, { id: "dreamina" }];
|
|
},
|
|
};
|
|
await prepare({ getState: () => state });
|
|
|
|
expect(connectCalls).toBe(1);
|
|
expect(state.connection).toBe("connected");
|
|
|
|
await prepare({ getState: () => state });
|
|
expect(connectCalls).toBe(2);
|
|
|
|
const missingState = {
|
|
connection: "connected",
|
|
modules: [{ id: "dreamina" }],
|
|
error: "",
|
|
async connect() {
|
|
return;
|
|
},
|
|
};
|
|
await expect(prepare({ getState: () => missingState })).rejects.toMatchObject({ code: "canvas_module_unavailable" });
|
|
|
|
const failedRefreshState = {
|
|
connection: "connected",
|
|
modules: [{ id: "canvas-agent" }],
|
|
error: "",
|
|
async connect() {
|
|
failedRefreshState.error = "本机运行时连接超时";
|
|
},
|
|
};
|
|
await expect(prepare({ getState: () => failedRefreshState })).rejects.toMatchObject({ code: "canvas_runtime_unavailable" });
|
|
});
|
|
|
|
test("Canvas launch mode auto-connects after rejected legacy deep-link secrets are removed", async () => {
|
|
const module = await import("../src/lib/canvas/local-runtime-connection");
|
|
const shouldAutoConnect = (
|
|
module as {
|
|
shouldAutoConnectCanvasRuntime?: (params: URLSearchParams) => boolean;
|
|
}
|
|
).shouldAutoConnectCanvasRuntime;
|
|
expect(typeof shouldAutoConnect).toBe("function");
|
|
if (!shouldAutoConnect) return;
|
|
|
|
expect(shouldAutoConnect(new URLSearchParams("mode=new"))).toBe(true);
|
|
expect(shouldAutoConnect(new URLSearchParams("mode=recent"))).toBe(true);
|
|
expect(shouldAutoConnect(new URLSearchParams("mode=choose"))).toBe(true);
|
|
expect(shouldAutoConnect(new URLSearchParams("agentUrl=http://127.0.0.1:17371"))).toBe(false);
|
|
});
|
|
|
|
test("Canvas 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>;
|
|
};
|
|
};
|