mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-10 01:28:25 +00:00
230 lines
7.1 KiB
TypeScript
230 lines
7.1 KiB
TypeScript
import { expect, test, type APIRequestContext } from "@playwright/test";
|
|
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import type { Readable } from "node:stream";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const projectRoot = path.resolve(__dirname, "..", "..");
|
|
const cliPath = path.join(projectRoot, "packages", "cli", "dist", "main", "cli.js");
|
|
const host = "127.0.0.1";
|
|
const cliWebAuthToken = "playwright-cli-web-auth-token";
|
|
const startupTimeoutMs = 10_000;
|
|
const shutdownTimeoutMs = 5_000;
|
|
|
|
type CliWebRuntime = {
|
|
baseUrl: string;
|
|
child: CliWebChild;
|
|
testHome: string;
|
|
token: string;
|
|
};
|
|
|
|
type CliWebChild = ChildProcessByStdio<null, Readable, Readable>;
|
|
|
|
let runtime: CliWebRuntime | undefined;
|
|
|
|
test.beforeAll(async () => {
|
|
runtime = await startCliWebServer();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
if (!runtime) {
|
|
return;
|
|
}
|
|
await stopCliWebServer(runtime.child);
|
|
rmSync(runtime.testHome, { force: true, recursive: true });
|
|
runtime = undefined;
|
|
});
|
|
|
|
test("uses CCR_WEB_AUTH_TOKEN for CLI web authentication", async () => {
|
|
const current = requireRuntime();
|
|
expect(current.token).toBe(cliWebAuthToken);
|
|
});
|
|
|
|
test("serves the management UI in a browser", async ({ page }) => {
|
|
const current = requireRuntime();
|
|
await page.goto(`${current.baseUrl}/?ccr_web_token=${current.token}`);
|
|
await expect(page).toHaveTitle("Claude Code Router");
|
|
await expect(page.locator("#root")).toBeAttached();
|
|
await expect(page.locator("body")).toContainText(/Configure provider|Connect agent|Let's start/, { timeout: 15_000 });
|
|
await expect(page.evaluate(() => Boolean(window.ccr?.getAppInfo))).resolves.toBe(true);
|
|
await expect(page.evaluate(async () => {
|
|
const presets = await window.ccr?.getProviderPresets?.();
|
|
return presets?.map((preset) => preset.id) ?? [];
|
|
})).resolves.toContain("openai");
|
|
|
|
await page.getByRole("button", { name: /Select preset provider|选择 预设供应商/ }).first().click();
|
|
await expect(page.getByRole("option", { name: "OpenAI" })).toBeVisible();
|
|
});
|
|
|
|
test("serves static assets used by the web UI", async ({ request }) => {
|
|
const current = requireRuntime();
|
|
await expectStaticAsset(request, current.baseUrl, "/assets/main.js", "text/javascript");
|
|
await expectStaticAsset(request, current.baseUrl, "/assets/main.css", "text/css");
|
|
await expectStaticAsset(request, current.baseUrl, "/assets/web-client-bridge.js", "text/javascript");
|
|
});
|
|
|
|
test("handles authenticated web RPC requests", async ({ request }) => {
|
|
const current = requireRuntime();
|
|
const response = await request.post(`${current.baseUrl}/api/ccr/rpc`, {
|
|
data: { args: [], method: "getAppInfo" },
|
|
headers: {
|
|
"x-ccr-web-auth": current.token
|
|
}
|
|
});
|
|
const payload = await response.json();
|
|
|
|
expect(response.status()).toBe(200);
|
|
expect(payload.ok).toBe(true);
|
|
expect(payload.value.name).toBe("Claude Code Router");
|
|
expect(payload.value.configDir).toContain(current.testHome);
|
|
expect(payload.value.appConfigDbFile).toContain("config.sqlite");
|
|
expect(payload.value.usageDbFile).toContain("usage.sqlite");
|
|
});
|
|
|
|
test("rejects RPC requests without the web auth token", async ({ request }) => {
|
|
const current = requireRuntime();
|
|
const response = await request.post(`${current.baseUrl}/api/ccr/rpc`, {
|
|
data: { args: [], method: "getAppInfo" }
|
|
});
|
|
const payload = await response.json();
|
|
|
|
expect(response.status()).toBe(401);
|
|
expect(payload.ok).toBe(false);
|
|
});
|
|
|
|
async function expectStaticAsset(
|
|
request: APIRequestContext,
|
|
baseUrl: string,
|
|
pathname: string,
|
|
expectedContentType: string
|
|
) {
|
|
const response = await request.head(`${baseUrl}${pathname}`);
|
|
expect(response.status()).toBe(200);
|
|
expect(response.headers()["content-type"]).toContain(expectedContentType);
|
|
expect(Number(response.headers()["content-length"] ?? "0")).toBeGreaterThan(0);
|
|
}
|
|
|
|
async function startCliWebServer(): Promise<CliWebRuntime> {
|
|
const port = await findAvailablePort();
|
|
const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-playwright-home-"));
|
|
const child = spawn(process.execPath, [
|
|
cliPath,
|
|
"serve",
|
|
"--no-gateway",
|
|
"--host",
|
|
host,
|
|
"--port",
|
|
String(port),
|
|
"--no-open"
|
|
], {
|
|
cwd: projectRoot,
|
|
env: {
|
|
...process.env,
|
|
CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"),
|
|
CCR_INTERNAL_HOME_DIR: testHome,
|
|
CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"),
|
|
CCR_WEB_AUTH_TOKEN: cliWebAuthToken,
|
|
HOME: testHome
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
|
|
const service = await new Promise<{ baseUrl: string; token: string }>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
reject(new Error(`CCR web service did not start within ${startupTimeoutMs}ms.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
|
}, startupTimeoutMs);
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
child.stdout.off("data", onStdout);
|
|
child.off("exit", onExit);
|
|
};
|
|
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
cleanup();
|
|
reject(new Error(`CCR web service exited during startup code=${code ?? "null"} signal=${signal ?? "null"}.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
|
};
|
|
const onStdout = () => {
|
|
const match = stdout.match(/CCR web management is running at (http:\/\/[^\s]+)/);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
cleanup();
|
|
const url = new URL(match[1]);
|
|
resolve({
|
|
baseUrl: `${url.protocol}//${url.host}`,
|
|
token: url.searchParams.get("ccr_web_token") ?? ""
|
|
});
|
|
};
|
|
|
|
child.on("exit", onExit);
|
|
child.stdout.on("data", onStdout);
|
|
});
|
|
|
|
if (!service.token) {
|
|
await stopCliWebServer(child);
|
|
rmSync(testHome, { force: true, recursive: true });
|
|
throw new Error("CCR web service started without a web auth token.");
|
|
}
|
|
|
|
return {
|
|
...service,
|
|
child,
|
|
testHome
|
|
};
|
|
}
|
|
|
|
async function stopCliWebServer(child: CliWebChild): Promise<void> {
|
|
if (child.exitCode !== null) {
|
|
return;
|
|
}
|
|
|
|
child.kill("SIGINT");
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGKILL");
|
|
resolve();
|
|
}, shutdownTimeoutMs);
|
|
child.once("exit", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function findAvailablePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.on("error", reject);
|
|
server.listen(0, host, () => {
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : undefined;
|
|
server.close(() => {
|
|
if (!port) {
|
|
reject(new Error("Failed to allocate a local test port."));
|
|
return;
|
|
}
|
|
resolve(port);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function requireRuntime(): CliWebRuntime {
|
|
if (!runtime) {
|
|
throw new Error("CLI web runtime was not started.");
|
|
}
|
|
return runtime;
|
|
}
|