268 lines
9.8 KiB
JavaScript
268 lines
9.8 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import net from "node:net";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(__dirname, "..", "..");
|
|
const imageName = process.env.CCR_DOCKER_TEST_IMAGE || "claude-code-router:local";
|
|
const token = process.env.CCR_DOCKER_TEST_WEB_AUTH_TOKEN || "token+/&=#?";
|
|
const testId = randomUUID().slice(0, 8);
|
|
const containerName = `ccr-docker-smoke-${testId}`;
|
|
const volumeName = `ccr-docker-smoke-${testId}`;
|
|
const startupTimeoutMs = 45_000;
|
|
|
|
let containerStarted = false;
|
|
let volumeCreated = false;
|
|
|
|
try {
|
|
if (process.env.CCR_DOCKER_TEST_SKIP_BUILD !== "1") {
|
|
run("docker", ["build", "-t", imageName, "."], { cwd: projectRoot });
|
|
}
|
|
|
|
const hostPort = await findAvailablePort();
|
|
run("docker", ["volume", "create", volumeName]);
|
|
volumeCreated = true;
|
|
seedLegacyDockerConfig(volumeName);
|
|
|
|
const containerId = run("docker", [
|
|
"run",
|
|
"-d",
|
|
"--name",
|
|
containerName,
|
|
"-p",
|
|
`${hostPort}:8080`,
|
|
"-e",
|
|
`CCR_WEB_AUTH_TOKEN=${token}`,
|
|
"-e",
|
|
"CCR_PUBLIC_HOST=127.0.0.1",
|
|
"-e",
|
|
`CCR_PUBLIC_PORT=${hostPort}`,
|
|
"-v",
|
|
`${volumeName}:/data`,
|
|
imageName
|
|
]).trim();
|
|
containerStarted = true;
|
|
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
|
|
|
await waitFor(async () => {
|
|
const response = await fetch(baseUrl, { redirect: "manual" }).catch(() => undefined);
|
|
return response?.status === 302;
|
|
}, "Docker Nginx entrypoint");
|
|
|
|
const ports = dockerPorts(containerId);
|
|
assert.ok(ports["8080/tcp"], "Docker container must publish only the Nginx port");
|
|
assert.equal(ports["3456/tcp"], undefined, "Docker container must not publish the internal gateway port");
|
|
|
|
const redirect = await fetch(baseUrl, { redirect: "manual" });
|
|
assert.equal(redirect.status, 302);
|
|
const redirectLocation = redirect.headers.get("location");
|
|
assert.equal(redirectLocation, `/pages/home/index.html?ccr_web_token=${encodeURIComponent(token)}`);
|
|
|
|
const page = await fetch(`${baseUrl}${redirectLocation}`);
|
|
assert.equal(page.status, 200);
|
|
const pageHtml = await page.text();
|
|
assert.match(pageHtml, /Claude Code Router/);
|
|
assert.match(pageHtml, /web-client-bridge\.js/);
|
|
|
|
const bridge = await fetch(`${baseUrl}/assets/web-client-bridge.js`);
|
|
assert.equal(bridge.status, 200);
|
|
assert.match(await bridge.text(), /getProviderPresets/);
|
|
|
|
await waitFor(async () => {
|
|
const response = await rpc(baseUrl, "getAppInfo", token).catch(() => undefined);
|
|
return response?.status === 200;
|
|
}, "Docker core management RPC");
|
|
|
|
const unauthorized = await rpc(baseUrl, "getAppInfo");
|
|
assert.equal(unauthorized.status, 401);
|
|
assert.equal((await unauthorized.json()).ok, false);
|
|
|
|
const appInfo = await rpc(baseUrl, "getAppInfo", token);
|
|
assert.equal(appInfo.status, 200);
|
|
const appInfoPayload = await appInfo.json();
|
|
assert.equal(appInfoPayload.ok, true);
|
|
assert.equal(appInfoPayload.value.configDir, "/data/.claude-code-router");
|
|
|
|
const presets = await rpc(baseUrl, "getProviderPresets", token);
|
|
assert.equal(presets.status, 200);
|
|
const presetsPayload = await presets.json();
|
|
assert.equal(presetsPayload.ok, true);
|
|
assert.ok(presetsPayload.value.some((preset) => preset.id === "openai"));
|
|
|
|
const usageStatsWithNullFilter = await rpc(baseUrl, "getUsageStats", token, ["7d", null]);
|
|
assert.equal(usageStatsWithNullFilter.status, 200);
|
|
const usageStatsPayload = await usageStatsWithNullFilter.json();
|
|
assert.equal(usageStatsPayload.ok, true);
|
|
assert.equal(usageStatsPayload.value.range, "7d");
|
|
assert.doesNotMatch(run("docker", ["logs", containerId]), /Failed to read usage stats/);
|
|
|
|
const config = await rpc(baseUrl, "getConfig", token);
|
|
assert.equal(config.status, 200);
|
|
const configPayload = await config.json();
|
|
assert.equal(configPayload.ok, true);
|
|
assert.equal(configPayload.value.routerEndpoint, baseUrl);
|
|
assert.equal(configPayload.value.HOST, "127.0.0.1");
|
|
assert.equal(configPayload.value.PORT, 3456);
|
|
assert.equal(configPayload.value.gateway.host, "127.0.0.1");
|
|
assert.equal(configPayload.value.gateway.port, 3456);
|
|
|
|
const gatewayHealth = await fetch(`${baseUrl}/health`);
|
|
assert.equal(gatewayHealth.status, 502, "Gateway health should be proxied by Nginx and fail while no models are configured");
|
|
|
|
const openRouterConfig = {
|
|
...configPayload.value,
|
|
Providers: [
|
|
{
|
|
apiKey: "test-openrouter-key",
|
|
baseUrl: "https://openrouter.ai/api/v1",
|
|
capabilities: [
|
|
{ baseUrl: "https://openrouter.ai/api/v1", source: "preset", type: "openai_chat_completions" },
|
|
{ baseUrl: "https://openrouter.ai/api/v1", source: "preset", type: "openai_responses" },
|
|
{ baseUrl: "https://openrouter.ai/api", endpoint: "https://openrouter.ai/api/v1/messages", source: "detected", type: "anthropic_messages" }
|
|
],
|
|
models: ["qwen/qwen3.5-122b-a10b"],
|
|
name: "OpenRouter",
|
|
type: "openai_responses"
|
|
}
|
|
],
|
|
preferredProvider: "OpenRouter"
|
|
};
|
|
const savedConfig = await rpc(baseUrl, "saveConfig", token, [openRouterConfig, { applyProfile: false }]);
|
|
assert.equal(savedConfig.status, 200);
|
|
assert.equal((await savedConfig.json()).ok, true);
|
|
|
|
const startedGateway = await rpc(baseUrl, "startGateway", token);
|
|
assert.equal(startedGateway.status, 200);
|
|
const startedGatewayPayload = await startedGateway.json();
|
|
assert.equal(startedGatewayPayload.ok, true);
|
|
assert.equal(startedGatewayPayload.value.state, "running");
|
|
|
|
const restartedGateway = await rpc(baseUrl, "startGateway", token);
|
|
assert.equal(restartedGateway.status, 200);
|
|
const restartedGatewayPayload = await restartedGateway.json();
|
|
assert.equal(restartedGatewayPayload.ok, true);
|
|
assert.equal(restartedGatewayPayload.value.state, "running");
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
const restartedGatewayStatus = await rpc(baseUrl, "getGatewayStatus", token);
|
|
assert.equal(restartedGatewayStatus.status, 200);
|
|
const restartedGatewayStatusPayload = await restartedGatewayStatus.json();
|
|
assert.equal(restartedGatewayStatusPayload.ok, true);
|
|
assert.equal(restartedGatewayStatusPayload.value.state, "running");
|
|
|
|
const runningGatewayHealth = await fetch(`${baseUrl}/health`);
|
|
assert.equal(runningGatewayHealth.status, 200);
|
|
assert.equal((await runningGatewayHealth.json()).status, "running");
|
|
|
|
console.log(`Docker smoke test passed for ${imageName} on ${baseUrl}`);
|
|
} finally {
|
|
if (containerStarted) {
|
|
run("docker", ["rm", "-f", containerName], { allowFailure: true });
|
|
}
|
|
if (volumeCreated) {
|
|
run("docker", ["volume", "rm", "-f", volumeName], { allowFailure: true });
|
|
}
|
|
}
|
|
|
|
function seedLegacyDockerConfig(volume) {
|
|
const script = `
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const Database = require("better-sqlite3");
|
|
const configDir = "/data/.claude-code-router";
|
|
const config = {
|
|
HOST: "0.0.0.0",
|
|
PORT: 3456,
|
|
Providers: [],
|
|
gateway: {
|
|
coreHost: "127.0.0.1",
|
|
corePort: 3457,
|
|
enabled: true,
|
|
host: "0.0.0.0",
|
|
port: 3456
|
|
},
|
|
routerEndpoint: "http://127.0.0.1:3456"
|
|
};
|
|
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
fs.mkdirSync(path.join(configDir, "app-data"), { recursive: true, mode: 0o700 });
|
|
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify(config, null, 2) + "\\n", { mode: 0o600 });
|
|
const db = new Database(path.join(configDir, "config.sqlite"));
|
|
db.exec("create table app_config (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at TEXT NOT NULL)");
|
|
db.prepare("insert into app_config (key, value_json, updated_at) values (?, ?, ?)").run("default", JSON.stringify(config), new Date().toISOString());
|
|
db.close();
|
|
`;
|
|
run("docker", ["run", "--rm", "-v", `${volume}:/data`, "--entrypoint", "node", imageName, "-e", script]);
|
|
}
|
|
|
|
async function rpc(baseUrl, method, authToken, args = []) {
|
|
return fetch(`${baseUrl}/api/ccr/rpc`, {
|
|
body: JSON.stringify({ args, method }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(authToken ? { "x-ccr-web-auth": authToken } : {})
|
|
},
|
|
method: "POST"
|
|
});
|
|
}
|
|
|
|
function dockerPorts(containerId) {
|
|
const raw = run("docker", ["inspect", "--format", "{{json .NetworkSettings.Ports}}", containerId]);
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
async function waitFor(check, label) {
|
|
const startedAt = Date.now();
|
|
let lastError;
|
|
while (Date.now() - startedAt < startupTimeoutMs) {
|
|
try {
|
|
if (await check()) {
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
const logs = containerStarted
|
|
? run("docker", ["logs", containerName], { allowFailure: true })
|
|
: "";
|
|
throw new Error(`${label} was not ready within ${startupTimeoutMs}ms.${lastError ? ` Last error: ${lastError}` : ""}\n${logs}`);
|
|
}
|
|
|
|
function findAvailablePort() {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.on("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : undefined;
|
|
server.close(() => {
|
|
if (!port) {
|
|
reject(new Error("Failed to allocate a Docker test port."));
|
|
return;
|
|
}
|
|
resolve(port);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd ?? projectRoot,
|
|
encoding: "utf8",
|
|
stdio: options.stdio ?? "pipe"
|
|
});
|
|
if (result.status !== 0 && !options.allowFailure) {
|
|
throw new Error([
|
|
`${command} ${args.join(" ")} failed with code ${result.status ?? "unknown"}`,
|
|
result.stdout,
|
|
result.stderr
|
|
].filter(Boolean).join("\n"));
|
|
}
|
|
return result.stdout.trim();
|
|
}
|