mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-16 15:34:08 +08:00
262 lines
7.8 KiB
TypeScript
262 lines
7.8 KiB
TypeScript
import { createServer } from "node:net";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { ElectronDevHttpBridge } from "./devHttpBridge";
|
|
|
|
const activeBridges: ElectronDevHttpBridge[] = [];
|
|
type DevHttpBridgeInvoke = ConstructorParameters<
|
|
typeof ElectronDevHttpBridge
|
|
>[0]["invoke"];
|
|
|
|
async function createStartedBridge(
|
|
invoke: DevHttpBridgeInvoke = vi.fn<DevHttpBridgeInvoke>(
|
|
async () => undefined,
|
|
),
|
|
): Promise<{ bridge: ElectronDevHttpBridge; invoke: DevHttpBridgeInvoke }> {
|
|
const port = await reserveLocalPort();
|
|
const bridge = new ElectronDevHttpBridge({
|
|
host: "127.0.0.1",
|
|
port,
|
|
invoke,
|
|
});
|
|
activeBridges.push(bridge);
|
|
bridge.start();
|
|
await waitForHealth(bridge.url);
|
|
return { bridge, invoke };
|
|
}
|
|
|
|
async function reserveLocalPort(): Promise<number> {
|
|
const server = createServer();
|
|
return new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close(() => reject(new Error("failed to reserve local port")));
|
|
return;
|
|
}
|
|
server.close(() => resolve(address.port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForHealth(baseUrl: string): Promise<void> {
|
|
const deadline = Date.now() + 2_000;
|
|
let lastError: unknown;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(`${baseUrl}/health`);
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error("dev HTTP bridge did not become healthy");
|
|
}
|
|
|
|
async function postJson(
|
|
baseUrl: string,
|
|
payload: unknown,
|
|
): Promise<{ status: number; body: unknown }> {
|
|
const response = await fetch(`${baseUrl}/invoke`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return {
|
|
status: response.status,
|
|
body: await response.json(),
|
|
};
|
|
}
|
|
|
|
describe("electron/devHttpBridge", () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const bridge of activeBridges.splice(0)) {
|
|
bridge.stop();
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("health 端点必须声明当前 transport 为 electron-host", async () => {
|
|
const { bridge } = await createStartedBridge();
|
|
|
|
const response = await fetch(`${bridge.url}/health`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("access-control-allow-origin")).toBe("*");
|
|
await expect(response.json()).resolves.toEqual({
|
|
status: "ok",
|
|
transport: "electron-host",
|
|
});
|
|
});
|
|
|
|
it("/invoke 只调用注入的 Electron Host invoke 并返回 result envelope", async () => {
|
|
const invoke = vi.fn().mockResolvedValue({ accepted: true });
|
|
const { bridge } = await createStartedBridge(invoke);
|
|
|
|
const response = await postJson(bridge.url, {
|
|
cmd: " app_server_handle_json_lines ",
|
|
args: { workspaceRoot: "/tmp/workspace" },
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
status: 200,
|
|
body: { result: { accepted: true } },
|
|
});
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
expect(invoke).toHaveBeenCalledWith("app_server_handle_json_lines", {
|
|
workspaceRoot: "/tmp/workspace",
|
|
});
|
|
});
|
|
|
|
it("/invoke 支持 Design Canvas 工程导出级别的大 payload", async () => {
|
|
const invoke = vi.fn().mockResolvedValue({ accepted: true });
|
|
const { bridge } = await createStartedBridge(invoke);
|
|
const largeProjectPayload = "x".repeat(2 * 1024 * 1024);
|
|
|
|
const response = await postJson(bridge.url, {
|
|
cmd: "save_layered_design_project_export",
|
|
args: {
|
|
request: {
|
|
projectRootPath: "/tmp/workspace",
|
|
documentId: "design-canvas-smoke",
|
|
title: "Design Canvas Smoke",
|
|
files: [
|
|
{
|
|
relativePath: "design.json",
|
|
encoding: "utf8",
|
|
content: largeProjectPayload,
|
|
},
|
|
{
|
|
relativePath: "export-manifest.json",
|
|
encoding: "utf8",
|
|
content: '{"assets":[]}',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
status: 200,
|
|
body: { result: { accepted: true } },
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
"save_layered_design_project_export",
|
|
expect.objectContaining({
|
|
request: expect.objectContaining({
|
|
documentId: "design-canvas-smoke",
|
|
files: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
content: largeProjectPayload,
|
|
}),
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("/invoke 缺 command 或 body 非对象时 fail closed 且不调用 invoke", async () => {
|
|
const invoke = vi.fn();
|
|
const { bridge } = await createStartedBridge(invoke);
|
|
|
|
await expect(postJson(bridge.url, { args: { ignored: true } })).resolves.toEqual(
|
|
{
|
|
status: 400,
|
|
body: { error: "cmd is required" },
|
|
},
|
|
);
|
|
|
|
const response = await fetch(`${bridge.url}/invoke`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(["legacy_command"]),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toEqual({
|
|
error: "request body must be a JSON object",
|
|
});
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("/invoke 把 Electron Host 错误投影为 error envelope,不能伪装成功", async () => {
|
|
const invoke = vi.fn().mockRejectedValue(new Error("unsupported command"));
|
|
const { bridge } = await createStartedBridge(invoke);
|
|
|
|
await expect(
|
|
postJson(bridge.url, { cmd: "legacy_command", args: { a: 1 } }),
|
|
).resolves.toEqual({
|
|
status: 200,
|
|
body: { error: "unsupported command" },
|
|
});
|
|
});
|
|
|
|
it("OPTIONS 和未知路径保持固定 HTTP 边界", async () => {
|
|
const { bridge } = await createStartedBridge();
|
|
|
|
const options = await fetch(`${bridge.url}/invoke`, { method: "OPTIONS" });
|
|
expect(options.status).toBe(204);
|
|
expect(options.headers.get("access-control-allow-methods")).toBe(
|
|
"GET,POST,OPTIONS",
|
|
);
|
|
|
|
const notFound = await fetch(`${bridge.url}/unknown`);
|
|
expect(notFound.status).toBe(404);
|
|
await expect(notFound.json()).resolves.toEqual({ error: "not found" });
|
|
});
|
|
|
|
it("events 端点只向订阅的事件推送 SSE message", async () => {
|
|
const { bridge } = await createStartedBridge();
|
|
const response = await fetch(`${bridge.url}/events?event=session:update`);
|
|
const reader = response.body?.getReader();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
|
expect(reader).toBeDefined();
|
|
|
|
bridge.broadcast("ignored:event", { ignored: true });
|
|
bridge.broadcast("session:update", { id: "session-1" });
|
|
|
|
const text = await readUntil(reader!, "session:update");
|
|
expect(text).toContain(": connected");
|
|
expect(text).toContain("event: message");
|
|
expect(text).toContain(
|
|
'data: {"event":"session:update","payload":{"id":"session-1"}}',
|
|
);
|
|
expect(text).not.toContain("ignored:event");
|
|
|
|
await reader?.cancel();
|
|
});
|
|
});
|
|
|
|
async function readUntil(
|
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
needle: string,
|
|
): Promise<string> {
|
|
const decoder = new TextDecoder();
|
|
let text = "";
|
|
const deadline = Date.now() + 2_000;
|
|
while (!text.includes(needle) && Date.now() < deadline) {
|
|
const result = await reader.read();
|
|
if (result.done) {
|
|
break;
|
|
}
|
|
text += decoder.decode(result.value, { stream: true });
|
|
}
|
|
if (!text.includes(needle)) {
|
|
throw new Error(`SSE stream did not include ${needle}`);
|
|
}
|
|
return text;
|
|
}
|