mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-31 01:35:16 +08:00
559 lines
19 KiB
TypeScript
559 lines
19 KiB
TypeScript
import { chromium, expect, test, type Browser, type Page } from "@playwright/test";
|
|
import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process";
|
|
import electronModule from "electron";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { Readable } from "node:stream";
|
|
|
|
const projectRoot = path.resolve(__dirname, "..", "..");
|
|
const host = "127.0.0.1";
|
|
const startupTimeoutMs = 30_000;
|
|
const shutdownTimeoutMs = 10_000;
|
|
const hookTimeoutMs = (startupTimeoutMs * 3) + shutdownTimeoutMs + 20_000;
|
|
const electronExecutable = electronModule as unknown as string;
|
|
|
|
type ElectronCdpRuntime = {
|
|
browser: Browser;
|
|
cdpUrl: string;
|
|
child: ElectronChild;
|
|
gatewayPort: number;
|
|
mainPage: Page;
|
|
output: ProcessOutput;
|
|
testHome: string;
|
|
};
|
|
|
|
type ElectronChild = ChildProcessByStdio<null, Readable, Readable>;
|
|
type ProcessOutput = {
|
|
stderr: string;
|
|
stdout: string;
|
|
};
|
|
|
|
let runtime: ElectronCdpRuntime | undefined;
|
|
|
|
test.skip(
|
|
process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY,
|
|
"Electron CDP E2E requires a desktop session on Linux."
|
|
);
|
|
|
|
test.beforeAll(async () => {
|
|
test.setTimeout(hookTimeoutMs);
|
|
runtime = await startElectronOverCdp();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
test.setTimeout(shutdownTimeoutMs + 5_000);
|
|
const current = runtime;
|
|
runtime = undefined;
|
|
if (!current) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await stopElectronOverCdp(current);
|
|
} finally {
|
|
removeElectronTestHome(current.testHome);
|
|
}
|
|
});
|
|
|
|
test("exposes the desktop renderer through CDP", async () => {
|
|
const current = requireRuntime();
|
|
expect(current.cdpUrl).toBe(`http://${host}:${new URL(current.cdpUrl).port}`);
|
|
expect(current.mainPage.url()).toContain("/pages/home/index.html");
|
|
await expect(current.mainPage).toHaveTitle("Claude Code Router");
|
|
});
|
|
|
|
test("loads the Electron preload bridge and IPC contract", async () => {
|
|
const current = requireRuntime();
|
|
const page = current.mainPage;
|
|
|
|
await page.waitForFunction(() => {
|
|
return Boolean(window.ccr?.getAppInfo && document.querySelector("#root")?.childElementCount);
|
|
});
|
|
|
|
const appInfo = await page.evaluate(async () => window.ccr?.getAppInfo());
|
|
expect(appInfo?.desktop).toBe(true);
|
|
expect(appInfo?.name).toBe("Claude Code Router");
|
|
expect(appInfo?.configDir).toContain(current.testHome);
|
|
expect(appInfo?.configDbFile).toContain("config.sqlite");
|
|
|
|
const config = await page.evaluate(async () => window.ccr?.getConfig());
|
|
expect(config?.gateway.enabled).toBe(false);
|
|
expect(config?.gateway.port).toBe(current.gatewayPort);
|
|
expect(config?.routerEndpoint).toBe(`http://${host}:${current.gatewayPort}`);
|
|
expect(config?.APIKEYS.some((item) => item.key === "sk-electron-cdp-legacy")).toBe(true);
|
|
|
|
const gatewayStatus = await page.evaluate(async () => window.ccr?.getGatewayStatus());
|
|
expect(gatewayStatus?.state).toBe("stopped");
|
|
|
|
const configDir = path.join(current.testHome, ".claude-code-router");
|
|
const configDbFile = path.join(configDir, "config.sqlite");
|
|
expect(existsSync(configDbFile)).toBe(true);
|
|
expect(existsSync(path.join(configDir, "config.json"))).toBe(false);
|
|
expect(existsSync(path.join(current.testHome, "user-data", "api-keys.sqlite"))).toBe(false);
|
|
|
|
const tables = readSqliteRows<{ name: string }>(
|
|
configDbFile,
|
|
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
|
);
|
|
expect(tables.map((item) => item.name)).toEqual(expect.arrayContaining([
|
|
"api_keys",
|
|
"app_config",
|
|
"config_schema_migrations",
|
|
"legacy_storage_backups",
|
|
"runtime_state"
|
|
]));
|
|
});
|
|
|
|
test("starts the managed gateway from IPC without runtime config JSON", async () => {
|
|
const current = requireRuntime();
|
|
const status = await current.mainPage.evaluate(async () => {
|
|
const config = await window.ccr?.getConfig();
|
|
if (!config) {
|
|
throw new Error("Config bridge is unavailable.");
|
|
}
|
|
config.Providers = [{
|
|
api_base_url: "http://127.0.0.1:9/v1",
|
|
api_key: "e2e-provider-key",
|
|
id: "electron-cdp-provider",
|
|
models: ["electron-cdp-model"],
|
|
name: "Electron CDP Provider",
|
|
type: "openai_chat_completions"
|
|
}];
|
|
config.preferredProvider = "Electron CDP Provider";
|
|
config.gateway.enabled = true;
|
|
await window.ccr?.saveConfig(config, { applyProfile: false });
|
|
return window.ccr?.getGatewayStatus();
|
|
});
|
|
|
|
expect(status?.state).toBe("running");
|
|
const configDir = path.join(current.testHome, ".claude-code-router");
|
|
expect(existsSync(path.join(configDir, "gateway.config.json"))).toBe(false);
|
|
expect(existsSync(path.join(configDir, "gateway-runtime.json"))).toBe(false);
|
|
|
|
const runtimeMarker = readGatewayRuntimeMarker(path.join(configDir, "config.sqlite"));
|
|
expect(runtimeMarker).toMatchObject({
|
|
pid: expect.any(Number),
|
|
runtimeId: expect.any(String)
|
|
});
|
|
|
|
await current.mainPage.evaluate(() => window.ccr?.stopGateway());
|
|
|
|
expect(readGatewayRuntimeMarker(path.join(configDir, "config.sqlite"))).toBeUndefined();
|
|
});
|
|
|
|
test("applies Workbuddy profiles through the desktop bridge", async () => {
|
|
const current = requireRuntime();
|
|
const profileId = "workbuddy-e2e";
|
|
const result = await current.mainPage.evaluate(async (input) => {
|
|
const config = await window.ccr?.getConfig();
|
|
if (!config) {
|
|
throw new Error("Config bridge is unavailable.");
|
|
}
|
|
config.APIKEYS = [];
|
|
config.Providers = [{
|
|
api_base_url: "http://127.0.0.1:9/v1",
|
|
api_key: "e2e-provider-key",
|
|
id: "workbuddy-e2e-provider",
|
|
models: ["gpt-5-codex"],
|
|
name: "Workbuddy E2E Provider",
|
|
type: "openai_responses"
|
|
}];
|
|
config.preferredProvider = "Workbuddy E2E Provider";
|
|
config.gateway.enabled = false;
|
|
config.profile = {
|
|
...config.profile,
|
|
enabled: true,
|
|
profiles: [{
|
|
agent: "workbuddy",
|
|
cliMiddleware: true,
|
|
codexCliPath: "",
|
|
codexHome: "",
|
|
configFile: "~/.workbuddy/config.toml",
|
|
configFormat: "separate_profile_files",
|
|
enabled: true,
|
|
env: {},
|
|
id: input.profileId,
|
|
managedCompact: false,
|
|
model: "Workbuddy E2E Provider/gpt-5-codex",
|
|
name: "Workbuddy E2E",
|
|
providerId: "claude-code-router",
|
|
providerName: "Claude Code Router",
|
|
showAllSessions: true,
|
|
scope: "ccr",
|
|
surface: "app"
|
|
}]
|
|
};
|
|
|
|
const saved = await window.ccr?.saveConfig(config, { applyProfile: false });
|
|
const applyResult = await window.ccr?.applyProfile();
|
|
return { applyResult, saved };
|
|
}, { profileId });
|
|
|
|
expect(result.saved?.profile.profiles).toHaveLength(1);
|
|
expect(result.saved?.profile.profiles[0]?.agent).toBe("workbuddy");
|
|
expect(result.saved?.profile.profiles[0]?.showAllSessions).toBe(false);
|
|
expect(result.saved?.profile.profiles[0]?.surface).toBe("app");
|
|
|
|
const workbuddyStatus = result.applyResult?.clients.find((client) => client.client === "workbuddy");
|
|
expect(workbuddyStatus?.ok, workbuddyStatus?.message).toBe(true);
|
|
|
|
const configDir = path.join(current.testHome, ".claude-code-router");
|
|
const profileHome = path.join(configDir, "profiles", profileId, "workbuddy");
|
|
const configFile = path.join(profileHome, "config.toml");
|
|
const launcherFile = path.join(
|
|
configDir,
|
|
"bin",
|
|
process.platform === "win32"
|
|
? `ccr-codex-cli-stdio-${profileId}.cmd`
|
|
: `ccr-codex-cli-stdio-${profileId}`
|
|
);
|
|
expect(workbuddyStatus?.path).toBe(configFile);
|
|
|
|
const toml = readFileSync(configFile, "utf8");
|
|
expect(toml).toContain('model_provider = "claude-code-router"');
|
|
expect(toml).toContain('model = "Workbuddy E2E Provider/gpt-5-codex"');
|
|
expect(toml).toContain(`model_catalog_json = "${path.join(profileHome, "ccr-model-catalog.json")}"`);
|
|
expect(toml).toContain(`base_url = "http://${host}:${current.gatewayPort}/v1"`);
|
|
expect(toml).toContain('wire_api = "responses"');
|
|
expect(toml).not.toContain("show_all_sessions = true");
|
|
|
|
const workbuddyModelsFile = path.join(profileHome, "models.json");
|
|
expect(existsSync(workbuddyModelsFile)).toBe(true);
|
|
const workbuddyModels = JSON.parse(readFileSync(workbuddyModelsFile, "utf8"));
|
|
expect(workbuddyModels.availableModels).toEqual(["Workbuddy E2E Provider/gpt-5-codex"]);
|
|
expect(workbuddyModels.models).toHaveLength(1);
|
|
expect(workbuddyModels.models[0]).toMatchObject({
|
|
apiKey: "${CCR_PROFILE_API_KEY}",
|
|
id: "Workbuddy E2E Provider/gpt-5-codex",
|
|
supportsToolCall: true,
|
|
tags: ["chat", "custom"],
|
|
url: `http://${host}:${current.gatewayPort}/v1`,
|
|
vendor: "Workbuddy E2E Provider"
|
|
});
|
|
|
|
const launcher = readFileSync(launcherFile, "utf8");
|
|
expect(launcher).toContain(profileHome);
|
|
expect(launcher).toContain("WORKBUDDY_HOME");
|
|
expect(launcher).toContain("WORKBUDDY_CONFIG_DIR");
|
|
expect(launcher).toContain("CODEBUDDY_CONFIG_DIR");
|
|
expect(launcher).toContain("CCR_REAL_CODEX_CLI_PATH");
|
|
expect(launcher).toContain("codebuddy");
|
|
|
|
const virtualAuthFile = workbuddyVirtualAuthFile(profileHome);
|
|
expect(existsSync(virtualAuthFile)).toBe(true);
|
|
const virtualSession = JSON.parse(readFileSync(virtualAuthFile, "utf8"));
|
|
expect(virtualSession.auth.accessToken).toBe("ccr-local-profile");
|
|
expect(virtualSession.auth.domain).toBe("www.workbuddy.ai");
|
|
expect(virtualSession.account.uid).toBe("ccr-local-profile");
|
|
expect(virtualSession.accounts).toHaveLength(1);
|
|
expect(virtualSession.allAccounts).toHaveLength(1);
|
|
});
|
|
|
|
async function startElectronOverCdp(): Promise<ElectronCdpRuntime> {
|
|
const cdpPort = await findAvailablePort();
|
|
const gatewayPort = await findAvailablePort();
|
|
const gatewayCorePort = await findAvailablePort();
|
|
const proxyPort = await findAvailablePort();
|
|
const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-electron-cdp-home-"));
|
|
const output: ProcessOutput = { stderr: "", stdout: "" };
|
|
let browser: Browser | undefined;
|
|
let child: ElectronChild | undefined;
|
|
|
|
try {
|
|
writeElectronE2eConfig(testHome, {
|
|
gatewayCorePort,
|
|
gatewayPort,
|
|
proxyPort
|
|
});
|
|
|
|
child = spawn(electronExecutable, [
|
|
"--disable-gpu",
|
|
"--no-sandbox",
|
|
"--remote-allow-origins=*",
|
|
`--remote-debugging-address=${host}`,
|
|
`--remote-debugging-port=${cdpPort}`,
|
|
projectRoot
|
|
], {
|
|
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"),
|
|
ELECTRON_DISABLE_SECURITY_WARNINGS: "1",
|
|
HOME: testHome,
|
|
NODE_ENV: "test"
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => {
|
|
output.stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
output.stderr += chunk;
|
|
});
|
|
child.on("error", (error) => {
|
|
output.stderr += `\nElectron process error: ${formatError(error)}`;
|
|
});
|
|
|
|
const cdpUrl = await waitForCdpUrl(child, output, cdpPort);
|
|
browser = await chromium.connectOverCDP(cdpUrl, { timeout: startupTimeoutMs });
|
|
const mainPage = await waitForMainPage(browser, child, output);
|
|
|
|
return {
|
|
browser,
|
|
cdpUrl,
|
|
child,
|
|
gatewayPort,
|
|
mainPage,
|
|
output,
|
|
testHome
|
|
};
|
|
} catch (error) {
|
|
await browser?.close().catch(() => undefined);
|
|
if (child) {
|
|
await stopElectronProcess(child).catch(() => undefined);
|
|
}
|
|
removeElectronTestHome(testHome);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function stopElectronOverCdp(current: ElectronCdpRuntime): Promise<void> {
|
|
await current.mainPage.evaluate(() => window.ccr?.quitApp()).catch(() => undefined);
|
|
await current.browser.close().catch(() => undefined);
|
|
await stopElectronProcess(current.child);
|
|
}
|
|
|
|
function writeElectronE2eConfig(
|
|
testHome: string,
|
|
ports: { gatewayCorePort: number; gatewayPort: number; proxyPort: number }
|
|
): void {
|
|
const configDir = path.join(testHome, ".claude-code-router");
|
|
mkdirSync(configDir, { recursive: true });
|
|
writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
|
|
HOST: host,
|
|
PORT: ports.gatewayPort,
|
|
Providers: [],
|
|
autoStart: false,
|
|
gateway: {
|
|
corePort: ports.gatewayCorePort,
|
|
enabled: false,
|
|
host,
|
|
port: ports.gatewayPort
|
|
},
|
|
profile: {
|
|
enabled: false,
|
|
profiles: []
|
|
},
|
|
proxy: {
|
|
enabled: false,
|
|
port: ports.proxyPort,
|
|
systemProxy: false
|
|
},
|
|
routerEndpoint: `http://${host}:${ports.gatewayPort}`
|
|
}, null, 2));
|
|
|
|
const dataDir = path.join(testHome, "user-data");
|
|
mkdirSync(dataDir, { recursive: true });
|
|
createLegacyApiKeyDatabase(path.join(dataDir, "api-keys.sqlite"));
|
|
}
|
|
|
|
function createLegacyApiKeyDatabase(file: string): void {
|
|
runElectronNode(`
|
|
const Database = require("better-sqlite3");
|
|
const database = new Database(process.env.CCR_E2E_SQLITE_FILE);
|
|
database.exec(\`
|
|
CREATE TABLE api_keys (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
encrypted_key TEXT NOT NULL,
|
|
encryption TEXT NOT NULL DEFAULT 'plain',
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL DEFAULT '',
|
|
limits_json TEXT NOT NULL DEFAULT ''
|
|
)
|
|
\`);
|
|
database.prepare(\`
|
|
INSERT INTO api_keys (
|
|
id, name, encrypted_key, encryption, created_at, expires_at, limits_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
\`).run(
|
|
"legacy-electron-cdp",
|
|
"Legacy Electron CDP",
|
|
"sk-electron-cdp-legacy",
|
|
"plain",
|
|
new Date(0).toISOString(),
|
|
"",
|
|
""
|
|
);
|
|
database.close();
|
|
`, file);
|
|
}
|
|
|
|
function readGatewayRuntimeMarker(file: string): Record<string, unknown> | undefined {
|
|
const rows = readSqliteRows<{ value_json: string }>(
|
|
file,
|
|
"SELECT value_json FROM runtime_state WHERE key = 'gateway'"
|
|
);
|
|
return rows[0] ? JSON.parse(rows[0].value_json) as Record<string, unknown> : undefined;
|
|
}
|
|
|
|
function readSqliteRows<Row>(file: string, query: string): Row[] {
|
|
const output = runElectronNode(`
|
|
const Database = require("better-sqlite3");
|
|
const database = new Database(process.env.CCR_E2E_SQLITE_FILE, { readonly: true });
|
|
const rows = database.prepare(process.env.CCR_E2E_SQLITE_QUERY).all();
|
|
database.close();
|
|
process.stdout.write(JSON.stringify(rows));
|
|
`, file, query);
|
|
return JSON.parse(output || "[]") as Row[];
|
|
}
|
|
|
|
function workbuddyVirtualAuthFile(profileHome: string): string {
|
|
const virtualHome = path.join(profileHome, ".claude-code-router", "workbuddy-app-home");
|
|
const sharedAuthSegments = process.platform === "darwin"
|
|
? ["Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth"]
|
|
: process.platform === "win32"
|
|
? ["AppData", "Local", "CodeBuddyExtension", "Data", "Public", "auth"]
|
|
: [".local", "share", "CodeBuddyExtension", "Data", "Public", "auth"];
|
|
return path.join(virtualHome, ...sharedAuthSegments, "workbuddy-desktop-ai.info");
|
|
}
|
|
|
|
function runElectronNode(script: string, file: string, query = ""): string {
|
|
const result = spawnSync(electronExecutable, ["-e", script], {
|
|
cwd: projectRoot,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
CCR_E2E_SQLITE_FILE: file,
|
|
CCR_E2E_SQLITE_QUERY: query,
|
|
ELECTRON_RUN_AS_NODE: "1"
|
|
}
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(`Electron SQLite helper failed:\n${result.stderr || result.stdout}`);
|
|
}
|
|
return result.stdout;
|
|
}
|
|
|
|
async function waitForCdpUrl(child: ElectronChild, output: ProcessOutput, port: number): Promise<string> {
|
|
const cdpUrl = `http://${host}:${port}`;
|
|
const deadline = Date.now() + startupTimeoutMs;
|
|
let lastError = "";
|
|
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) {
|
|
throw electronStartupError(`Electron exited before CDP became available with code ${child.exitCode}.`, output, lastError);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${cdpUrl}/json/version`);
|
|
if (response.ok) {
|
|
return cdpUrl;
|
|
}
|
|
lastError = `HTTP ${response.status}`;
|
|
} catch (error) {
|
|
lastError = formatError(error);
|
|
}
|
|
|
|
await delay(100);
|
|
}
|
|
|
|
throw electronStartupError(`Electron CDP endpoint did not start within ${startupTimeoutMs}ms.`, output, lastError);
|
|
}
|
|
|
|
async function waitForMainPage(browser: Browser, child: ElectronChild, output: ProcessOutput): Promise<Page> {
|
|
const deadline = Date.now() + startupTimeoutMs;
|
|
let lastSeenUrl = "";
|
|
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) {
|
|
throw electronStartupError(`Electron exited before the main window was visible with code ${child.exitCode}.`, output, lastSeenUrl);
|
|
}
|
|
|
|
for (const page of browser.contexts().flatMap((context) => context.pages())) {
|
|
const url = page.url();
|
|
lastSeenUrl = url || lastSeenUrl;
|
|
if (url.includes("/pages/home/index.html")) {
|
|
await page.waitForLoadState("domcontentloaded", {
|
|
timeout: Math.max(1, deadline - Date.now())
|
|
});
|
|
return page;
|
|
}
|
|
}
|
|
|
|
await delay(100);
|
|
}
|
|
|
|
throw electronStartupError(`Electron main window was not exposed through CDP within ${startupTimeoutMs}ms.`, output, lastSeenUrl);
|
|
}
|
|
|
|
async function stopElectronProcess(child: ElectronChild): Promise<void> {
|
|
if (child.exitCode !== null) {
|
|
return;
|
|
}
|
|
|
|
child.kill("SIGTERM");
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGKILL");
|
|
resolve();
|
|
}, shutdownTimeoutMs);
|
|
child.once("exit", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function removeElectronTestHome(testHome: string): void {
|
|
try {
|
|
rmSync(testHome, { force: true, recursive: true });
|
|
} catch (error) {
|
|
console.warn(`Failed to remove Electron E2E home ${testHome}: ${formatError(error)}`);
|
|
}
|
|
}
|
|
|
|
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(): ElectronCdpRuntime {
|
|
if (!runtime) {
|
|
throw new Error("Electron CDP runtime was not started.");
|
|
}
|
|
return runtime;
|
|
}
|
|
|
|
function electronStartupError(message: string, output: ProcessOutput, detail: string): Error {
|
|
return new Error(`${message}\nlast detail:\n${detail || "(none)"}\nstdout:\n${output.stdout}\nstderr:\n${output.stderr}`);
|
|
}
|
|
|
|
function formatError(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|