refactor(gateway): lazy-load server boundary for live tests

This commit is contained in:
Peter Steinberger
2026-04-07 07:33:57 +01:00
parent a3b2fdf7d6
commit c00cd4b414
5 changed files with 152 additions and 3 deletions

View File

@@ -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;

View File

@@ -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,

View 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" }]]);
});
});

View File

@@ -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();
}

View File

@@ -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 = [];