mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
refactor(gateway): lazy-load server boundary for live tests
This commit is contained in:
@@ -4,9 +4,19 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect } from "vitest";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
type DeviceIdentity,
|
||||
} from "../infra/device-identity.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js";
|
||||
import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { GatewayClient } from "./client.js";
|
||||
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
|
||||
import { extractPayloadText } from "./test-helpers.agent-results.js";
|
||||
@@ -83,11 +93,13 @@ type CronListJob = NonNullable<CronListCliResult["jobs"]>[number];
|
||||
|
||||
export type CliBackendLiveEnvSnapshot = {
|
||||
configPath?: string;
|
||||
stateDir?: string;
|
||||
token?: string;
|
||||
skipChannels?: string;
|
||||
skipGmail?: string;
|
||||
skipCron?: string;
|
||||
skipCanvas?: string;
|
||||
skipBrowserControl?: string;
|
||||
anthropicApiKey?: string;
|
||||
anthropicApiKeyOld?: string;
|
||||
};
|
||||
@@ -228,6 +240,7 @@ function sleep(ms: number): Promise<void> {
|
||||
export async function connectTestGatewayClient(params: {
|
||||
url: string;
|
||||
token: string;
|
||||
deviceIdentity?: DeviceIdentity;
|
||||
}): Promise<GatewayClient> {
|
||||
const startedAt = Date.now();
|
||||
let attempt = 0;
|
||||
@@ -260,6 +273,7 @@ async function connectClientOnce(params: {
|
||||
url: string;
|
||||
token: string;
|
||||
timeoutMs: number;
|
||||
deviceIdentity?: DeviceIdentity;
|
||||
}): Promise<GatewayClient> {
|
||||
return await new Promise<GatewayClient>((resolve, reject) => {
|
||||
let done = false;
|
||||
@@ -291,6 +305,7 @@ async function connectClientOnce(params: {
|
||||
mode: "test",
|
||||
requestTimeoutMs: params.timeoutMs,
|
||||
connectChallengeTimeoutMs: params.timeoutMs,
|
||||
deviceIdentity: params.deviceIdentity,
|
||||
onHelloOk: () => finish({ client }),
|
||||
onConnectError: (error) => finish({ error }),
|
||||
onClose: failWithClose,
|
||||
@@ -319,11 +334,13 @@ function isRetryableGatewayConnectError(error: Error): boolean {
|
||||
export function snapshotCliBackendLiveEnv(): CliBackendLiveEnvSnapshot {
|
||||
return {
|
||||
configPath: process.env.OPENCLAW_CONFIG_PATH,
|
||||
stateDir: process.env.OPENCLAW_STATE_DIR,
|
||||
token: process.env.OPENCLAW_GATEWAY_TOKEN,
|
||||
skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
|
||||
skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
|
||||
skipCron: process.env.OPENCLAW_SKIP_CRON,
|
||||
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
|
||||
skipBrowserControl: process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
anthropicApiKeyOld: process.env.ANTHROPIC_API_KEY_OLD,
|
||||
};
|
||||
@@ -334,6 +351,7 @@ export function applyCliBackendLiveEnv(preservedEnv: ReadonlySet<string>): void
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.OPENCLAW_SKIP_CRON = "1";
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
||||
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
|
||||
if (!preservedEnv.has("ANTHROPIC_API_KEY")) {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
@@ -344,11 +362,13 @@ export function applyCliBackendLiveEnv(preservedEnv: ReadonlySet<string>): void
|
||||
|
||||
export function restoreCliBackendLiveEnv(snapshot: CliBackendLiveEnvSnapshot): void {
|
||||
restoreEnvVar("OPENCLAW_CONFIG_PATH", snapshot.configPath);
|
||||
restoreEnvVar("OPENCLAW_STATE_DIR", snapshot.stateDir);
|
||||
restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", snapshot.token);
|
||||
restoreEnvVar("OPENCLAW_SKIP_CHANNELS", snapshot.skipChannels);
|
||||
restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", snapshot.skipGmail);
|
||||
restoreEnvVar("OPENCLAW_SKIP_CRON", snapshot.skipCron);
|
||||
restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", snapshot.skipCanvas);
|
||||
restoreEnvVar("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", snapshot.skipBrowserControl);
|
||||
restoreEnvVar("ANTHROPIC_API_KEY", snapshot.anthropicApiKey);
|
||||
restoreEnvVar("ANTHROPIC_API_KEY_OLD", snapshot.anthropicApiKeyOld);
|
||||
}
|
||||
@@ -361,6 +381,44 @@ function restoreEnvVar(name: string, value: string | undefined): void {
|
||||
process.env[name] = value;
|
||||
}
|
||||
|
||||
export async function ensurePairedTestGatewayClientIdentity(): Promise<DeviceIdentity> {
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const requiredScopes = ["operator.admin"];
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
const pairedScopes = Array.isArray(paired?.approvedScopes)
|
||||
? paired.approvedScopes
|
||||
: Array.isArray(paired?.scopes)
|
||||
? paired.scopes
|
||||
: [];
|
||||
if (
|
||||
paired?.publicKey === publicKey &&
|
||||
requiredScopes.every((scope) => pairedScopes.includes(scope))
|
||||
) {
|
||||
return identity;
|
||||
}
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey,
|
||||
displayName: "vitest",
|
||||
platform: process.platform,
|
||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||
role: "operator",
|
||||
scopes: requiredScopes,
|
||||
silent: true,
|
||||
});
|
||||
const approved = await approveDevicePairing(pairing.request.requestId, {
|
||||
callerScopes: requiredScopes,
|
||||
});
|
||||
if (approved?.status !== "approved") {
|
||||
throw new Error(
|
||||
`failed to pre-pair live test device: ${approved?.status ?? "missing-approval-result"}`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
export async function verifyCliBackendImageProbe(params: {
|
||||
client: GatewayClient;
|
||||
providerId: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import {
|
||||
applyCliBackendLiveEnv,
|
||||
createBootstrapWorkspace,
|
||||
ensurePairedTestGatewayClientIdentity,
|
||||
DEFAULT_CLAUDE_ARGS,
|
||||
DEFAULT_CLEAR_ENV,
|
||||
DEFAULT_CODEX_ARGS,
|
||||
@@ -32,11 +33,20 @@ import { extractPayloadText } from "./test-helpers.agent-results.js";
|
||||
const LIVE = isLiveTestEnabled();
|
||||
const CLI_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND);
|
||||
const CLI_RESUME = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE);
|
||||
const CLI_DEBUG = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG);
|
||||
const describeLive = LIVE && CLI_LIVE ? describe : describe.skip;
|
||||
|
||||
const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-6";
|
||||
const CLI_BACKEND_LIVE_TIMEOUT_MS = 420_000;
|
||||
|
||||
function logCliBackendLiveStep(step: string, details?: Record<string, unknown>): void {
|
||||
if (!CLI_DEBUG) {
|
||||
return;
|
||||
}
|
||||
const suffix = details && Object.keys(details).length > 0 ? ` ${JSON.stringify(details)}` : "";
|
||||
console.error(`[gateway-cli-live] ${step}${suffix}`);
|
||||
}
|
||||
|
||||
describeLive("gateway live (cli backend)", () => {
|
||||
it(
|
||||
"runs the agent pipeline against the local CLI backend",
|
||||
@@ -55,6 +65,7 @@ describeLive("gateway live (cli backend)", () => {
|
||||
const token = `test-${randomUUID()}`;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = token;
|
||||
const port = await getFreeGatewayPort();
|
||||
logCliBackendLiveStep("env-ready", { port });
|
||||
|
||||
const rawModel = process.env.OPENCLAW_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
|
||||
const parsed = parseModelRef(rawModel, "claude-cli");
|
||||
@@ -67,6 +78,7 @@ describeLive("gateway live (cli backend)", () => {
|
||||
const providerId = parsed.provider;
|
||||
const modelKey = `${providerId}/${parsed.model}`;
|
||||
const enableCliImageProbe = shouldRunCliImageProbe(providerId);
|
||||
logCliBackendLiveStep("model-selected", { providerId, modelKey, enableCliImageProbe });
|
||||
const providerDefaults =
|
||||
providerId === "claude-cli"
|
||||
? {
|
||||
@@ -121,6 +133,9 @@ describeLive("gateway live (cli backend)", () => {
|
||||
}
|
||||
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-cli-"));
|
||||
const stateDir = path.join(tempDir, "state");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
const bootstrapWorkspace =
|
||||
providerId === "claude-cli" ? await createBootstrapWorkspace(tempDir) : null;
|
||||
const disableMcpConfig = process.env.OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0";
|
||||
@@ -172,20 +187,31 @@ describeLive("gateway live (cli backend)", () => {
|
||||
const tempConfigPath = path.join(tempDir, "openclaw.json");
|
||||
await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`);
|
||||
process.env.OPENCLAW_CONFIG_PATH = tempConfigPath;
|
||||
const deviceIdentity = await ensurePairedTestGatewayClientIdentity();
|
||||
logCliBackendLiveStep("config-written", {
|
||||
tempConfigPath,
|
||||
stateDir,
|
||||
cliCommand,
|
||||
cliArgs,
|
||||
});
|
||||
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
logCliBackendLiveStep("server-started");
|
||||
const client = await connectTestGatewayClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
deviceIdentity,
|
||||
});
|
||||
logCliBackendLiveStep("client-connected");
|
||||
|
||||
try {
|
||||
const sessionKey = "agent:dev:live-cli-backend";
|
||||
const nonce = randomBytes(3).toString("hex").toUpperCase();
|
||||
logCliBackendLiveStep("agent-request:start", { sessionKey, nonce });
|
||||
const payload = await client.request(
|
||||
"agent",
|
||||
{
|
||||
@@ -202,6 +228,7 @@ describeLive("gateway live (cli backend)", () => {
|
||||
if (payload?.status !== "ok") {
|
||||
throw new Error(`agent status=${String(payload?.status)}`);
|
||||
}
|
||||
logCliBackendLiveStep("agent-request:done", { status: payload?.status });
|
||||
|
||||
const text = extractPayloadText(payload?.result);
|
||||
if (providerId === "codex-cli") {
|
||||
@@ -220,6 +247,7 @@ describeLive("gateway live (cli backend)", () => {
|
||||
|
||||
if (CLI_RESUME) {
|
||||
const resumeNonce = randomBytes(3).toString("hex").toUpperCase();
|
||||
logCliBackendLiveStep("agent-resume:start", { sessionKey, resumeNonce });
|
||||
const resumePayload = await client.request(
|
||||
"agent",
|
||||
{
|
||||
@@ -236,6 +264,7 @@ describeLive("gateway live (cli backend)", () => {
|
||||
if (resumePayload?.status !== "ok") {
|
||||
throw new Error(`resume status=${String(resumePayload?.status)}`);
|
||||
}
|
||||
logCliBackendLiveStep("agent-resume:done", { status: resumePayload?.status });
|
||||
const resumeText = extractPayloadText(resumePayload?.result);
|
||||
if (providerId === "codex-cli") {
|
||||
expect(resumeText).toContain(`CLI-RESUME-${resumeNonce}`);
|
||||
@@ -247,6 +276,7 @@ describeLive("gateway live (cli backend)", () => {
|
||||
}
|
||||
|
||||
if (enableCliImageProbe) {
|
||||
logCliBackendLiveStep("image-probe:start", { sessionKey });
|
||||
await verifyCliBackendImageProbe({
|
||||
client,
|
||||
providerId,
|
||||
@@ -254,9 +284,11 @@ describeLive("gateway live (cli backend)", () => {
|
||||
tempDir,
|
||||
bootstrapWorkspace,
|
||||
});
|
||||
logCliBackendLiveStep("image-probe:done");
|
||||
}
|
||||
|
||||
if (providerId === "claude-cli") {
|
||||
logCliBackendLiveStep("cron-mcp-probe:start", { sessionKey });
|
||||
await verifyClaudeCliCronMcpProbe({
|
||||
client,
|
||||
sessionKey,
|
||||
@@ -264,13 +296,16 @@ describeLive("gateway live (cli backend)", () => {
|
||||
token,
|
||||
env: process.env,
|
||||
});
|
||||
logCliBackendLiveStep("cron-mcp-probe:done");
|
||||
}
|
||||
} finally {
|
||||
logCliBackendLiveStep("cleanup:start");
|
||||
clearRuntimeConfigSnapshot();
|
||||
await client.stopAndWait();
|
||||
await server.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
restoreCliBackendLiveEnv(previousEnv);
|
||||
logCliBackendLiveStep("cleanup:done");
|
||||
}
|
||||
},
|
||||
CLI_BACKEND_LIVE_TIMEOUT_MS,
|
||||
|
||||
42
src/gateway/server.lazy.test.ts
Normal file
42
src/gateway/server.lazy.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const lazyState = vi.hoisted(() => ({
|
||||
loads: 0,
|
||||
startCalls: [] as unknown[][],
|
||||
resetCalls: 0,
|
||||
}));
|
||||
|
||||
vi.mock("./server.impl.js", () => {
|
||||
lazyState.loads += 1;
|
||||
return {
|
||||
startGatewayServer: vi.fn(async (...args: unknown[]) => {
|
||||
lazyState.startCalls.push(args);
|
||||
return { close: vi.fn(async () => undefined) };
|
||||
}),
|
||||
__resetModelCatalogCacheForTest: vi.fn(() => {
|
||||
lazyState.resetCalls += 1;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("gateway server boundary", () => {
|
||||
beforeEach(() => {
|
||||
lazyState.loads = 0;
|
||||
lazyState.startCalls = [];
|
||||
lazyState.resetCalls = 0;
|
||||
});
|
||||
|
||||
it("lazy-loads server.impl on demand", async () => {
|
||||
const mod = await import("./server.js");
|
||||
|
||||
expect(lazyState.loads).toBe(0);
|
||||
|
||||
await mod.__resetModelCatalogCacheForTest();
|
||||
expect(lazyState.loads).toBe(1);
|
||||
expect(lazyState.resetCalls).toBe(1);
|
||||
|
||||
await mod.startGatewayServer(4321, { bind: "loopback" });
|
||||
expect(lazyState.loads).toBe(1);
|
||||
expect(lazyState.startCalls).toEqual([[4321, { bind: "loopback" }]]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,17 @@
|
||||
export { truncateCloseReason } from "./server/close-reason.js";
|
||||
export type { GatewayServer, GatewayServerOptions } from "./server.impl.js";
|
||||
export { __resetModelCatalogCacheForTest, startGatewayServer } from "./server.impl.js";
|
||||
async function loadServerImpl() {
|
||||
return await import("./server.impl.js");
|
||||
}
|
||||
|
||||
export async function startGatewayServer(
|
||||
...args: Parameters<typeof import("./server.impl.js").startGatewayServer>
|
||||
): ReturnType<typeof import("./server.impl.js").startGatewayServer> {
|
||||
const mod = await loadServerImpl();
|
||||
return await mod.startGatewayServer(...args);
|
||||
}
|
||||
|
||||
export async function __resetModelCatalogCacheForTest(): Promise<void> {
|
||||
const mod = await loadServerImpl();
|
||||
mod.__resetModelCatalogCacheForTest();
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
|
||||
}
|
||||
resetAgentRunContextForTest();
|
||||
const mod = await getServerModule();
|
||||
mod.__resetModelCatalogCacheForTest();
|
||||
await mod.__resetModelCatalogCacheForTest();
|
||||
piSdkMock.enabled = false;
|
||||
piSdkMock.discoverCalls = 0;
|
||||
piSdkMock.models = [];
|
||||
|
||||
Reference in New Issue
Block a user