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>;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user