From 54213b587fc1350dcbc85aaa5dfa478e0cc64004 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Mar 2026 01:42:00 +0000 Subject: [PATCH] refactor: reuse shared cli runtime test mocks --- src/cli/browser-cli-inspect.test.ts | 15 ++--- src/cli/browser-cli-manage.test.ts | 53 +++++++++--------- .../browser-cli-manage.timeout-option.test.ts | 39 ++++++------- ...rowser-cli-state.option-collisions.test.ts | 56 ++++++++++--------- src/cli/cron-cli.test.ts | 24 ++------ .../daemon-cli/install.integration.test.ts | 28 ++-------- src/cli/daemon-cli/install.test.ts | 17 ++---- src/cli/devices-cli.test.ts | 23 ++++---- src/cli/directory-cli.test.ts | 40 +++++++------ src/cli/mcp-cli.test.ts | 28 +++------- src/cli/program/register.agent.test.ts | 15 ++--- src/cli/program/register.backup.test.ts | 14 +---- .../register.status-health-sessions.test.ts | 15 ++--- src/cli/qr-cli.test.ts | 55 +++++++++--------- src/cli/update-cli.option-collisions.test.ts | 14 +---- src/cli/update-cli.test.ts | 23 +++----- 16 files changed, 176 insertions(+), 283 deletions(-) diff --git a/src/cli/browser-cli-inspect.test.ts b/src/cli/browser-cli-inspect.test.ts index a58d83614a4..7a76b128c57 100644 --- a/src/cli/browser-cli-inspect.test.ts +++ b/src/cli/browser-cli-inspect.test.ts @@ -1,5 +1,8 @@ import { Command } from "commander"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture(); const gatewayMocks = vi.hoisted(() => ({ callGatewayFromCli: vi.fn(async () => ({ @@ -47,17 +50,6 @@ vi.mock("./browser-cli-shared.js", () => ({ callBrowserRequest: sharedMocks.callBrowserRequest, })); -const runtime = { - log: vi.fn(), - error: vi.fn(), - writeStdout: vi.fn((value: string) => { - runtime.log(value.endsWith("\n") ? value.slice(0, -1) : value); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - runtime.log(JSON.stringify(value, null, space)); - }), - exit: vi.fn(), -}; vi.mock("../runtime.js", () => ({ defaultRuntime: runtime, })); @@ -91,6 +83,7 @@ describe("browser cli snapshot defaults", () => { afterEach(() => { vi.clearAllMocks(); + resetRuntimeCapture(); configMocks.loadConfig.mockReturnValue({ browser: {} }); }); diff --git a/src/cli/browser-cli-manage.test.ts b/src/cli/browser-cli-manage.test.ts index c4d7a36ffa3..c15db24dc01 100644 --- a/src/cli/browser-cli-manage.test.ts +++ b/src/cli/browser-cli-manage.test.ts @@ -1,11 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerBrowserManageCommands } from "./browser-cli-manage.js"; import { createBrowserProgram } from "./browser-cli-test-helpers.js"; +import type { CliRuntimeCapture } from "./test-runtime-capture.js"; + +const runtimeState = vi.hoisted(() => ({ capture: null as CliRuntimeCapture | null })); + +function getRuntimeCapture(): CliRuntimeCapture { + if (!runtimeState.capture) { + throw new Error("runtime capture not initialized"); + } + return runtimeState.capture; +} + +function getRuntime() { + return getRuntimeCapture().defaultRuntime; +} const mocks = vi.hoisted(() => { - const runtimeLog = vi.fn(); - const runtimeError = vi.fn(); - const runtimeExit = vi.fn(); return { callBrowserRequest: vi.fn< ( @@ -14,20 +25,6 @@ const mocks = vi.hoisted(() => { runtimeOpts?: { timeoutMs?: number }, ) => Promise> >(async () => ({})), - runtimeLog, - runtimeError, - runtimeExit, - runtime: { - log: runtimeLog, - error: runtimeError, - writeStdout: vi.fn((value: string) => - runtimeLog(value.endsWith("\n") ? value.slice(0, -1) : value), - ), - writeJson: vi.fn((value: unknown, space = 2) => - runtimeLog(JSON.stringify(value, null, space)), - ), - exit: runtimeExit, - }, }; }); @@ -43,9 +40,11 @@ vi.mock("./cli-utils.js", () => ({ ) => await action().catch(onError), })); -vi.mock("../runtime.js", () => ({ - defaultRuntime: mocks.runtime, -})); +vi.mock("../runtime.js", async () => { + const { createCliRuntimeCapture } = await import("./test-runtime-capture.js"); + runtimeState.capture ??= createCliRuntimeCapture(); + return { defaultRuntime: runtimeState.capture.defaultRuntime }; +}); function createProgram() { const { program, browser, parentOpts } = createBrowserProgram(); @@ -56,9 +55,7 @@ function createProgram() { describe("browser manage output", () => { beforeEach(() => { mocks.callBrowserRequest.mockClear(); - mocks.runtimeLog.mockClear(); - mocks.runtimeError.mockClear(); - mocks.runtimeExit.mockClear(); + getRuntimeCapture().resetRuntimeCapture(); }); it("shows chrome-mcp transport for existing-session status without fake CDP fields", async () => { @@ -91,7 +88,7 @@ describe("browser manage output", () => { from: "user", }); - const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string; + const output = getRuntime().log.mock.calls.at(-1)?.[0] as string; expect(output).toContain("transport: chrome-mcp"); expect(output).not.toContain("cdpPort:"); expect(output).not.toContain("cdpUrl:"); @@ -127,7 +124,7 @@ describe("browser manage output", () => { from: "user", }); - const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string; + const output = getRuntime().log.mock.calls.at(-1)?.[0] as string; expect(output).toContain( "userDataDir: /Users/test/Library/Application Support/BraveSoftware/Brave-Browser", ); @@ -158,7 +155,7 @@ describe("browser manage output", () => { const program = createProgram(); await program.parseAsync(["browser", "profiles"], { from: "user" }); - const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string; + const output = getRuntime().log.mock.calls.at(-1)?.[0] as string; expect(output).toContain("chrome-live: running (2 tabs) [existing-session]"); expect(output).toContain("transport: chrome-mcp"); expect(output).not.toContain("port: 0"); @@ -186,7 +183,7 @@ describe("browser manage output", () => { { from: "user" }, ); - const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string; + const output = getRuntime().log.mock.calls.at(-1)?.[0] as string; expect(output).toContain('Created profile "chrome-live"'); expect(output).toContain("transport: chrome-mcp"); expect(output).not.toContain("port: 0"); @@ -223,7 +220,7 @@ describe("browser manage output", () => { from: "user", }); - const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string; + const output = getRuntime().log.mock.calls.at(-1)?.[0] as string; expect(output).toContain("cdpUrl: https://example.com/chrome?token=supers…7890"); expect(output).not.toContain("alice"); expect(output).not.toContain("supersecretpasswordvalue1234"); diff --git a/src/cli/browser-cli-manage.timeout-option.test.ts b/src/cli/browser-cli-manage.timeout-option.test.ts index d52341a37da..0ba78cbb325 100644 --- a/src/cli/browser-cli-manage.timeout-option.test.ts +++ b/src/cli/browser-cli-manage.timeout-option.test.ts @@ -1,11 +1,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerBrowserManageCommands } from "./browser-cli-manage.js"; import { createBrowserProgram } from "./browser-cli-test-helpers.js"; +import type { CliRuntimeCapture } from "./test-runtime-capture.js"; + +const runtimeState = vi.hoisted(() => ({ capture: null as CliRuntimeCapture | null })); + +function getRuntimeCapture(): CliRuntimeCapture { + if (!runtimeState.capture) { + throw new Error("runtime capture not initialized"); + } + return runtimeState.capture; +} const mocks = vi.hoisted(() => { - const runtimeLog = vi.fn(); - const runtimeError = vi.fn(); - const runtimeExit = vi.fn(); return { callBrowserRequest: vi.fn(async (_opts: unknown, req: { path?: string }) => req.path === "/" @@ -22,20 +29,6 @@ const mocks = vi.hoisted(() => { } : {}, ), - runtimeLog, - runtimeError, - runtimeExit, - runtime: { - log: runtimeLog, - error: runtimeError, - writeStdout: vi.fn((value: string) => - runtimeLog(value.endsWith("\n") ? value.slice(0, -1) : value), - ), - writeJson: vi.fn((value: unknown, space = 2) => - runtimeLog(JSON.stringify(value, null, space)), - ), - exit: runtimeExit, - }, }; }); @@ -51,9 +44,11 @@ vi.mock("./cli-utils.js", () => ({ ) => await action().catch(onError), })); -vi.mock("../runtime.js", () => ({ - defaultRuntime: mocks.runtime, -})); +vi.mock("../runtime.js", async () => { + const { createCliRuntimeCapture } = await import("./test-runtime-capture.js"); + runtimeState.capture ??= createCliRuntimeCapture(); + return { defaultRuntime: runtimeState.capture.defaultRuntime }; +}); describe("browser manage start timeout option", () => { function createProgram() { @@ -65,9 +60,7 @@ describe("browser manage start timeout option", () => { beforeEach(() => { mocks.callBrowserRequest.mockClear(); - mocks.runtimeLog.mockClear(); - mocks.runtimeError.mockClear(); - mocks.runtimeExit.mockClear(); + getRuntimeCapture().resetRuntimeCapture(); }); it("uses parent --timeout for browser start instead of hardcoded 15s", async () => { diff --git a/src/cli/browser-cli-state.option-collisions.test.ts b/src/cli/browser-cli-state.option-collisions.test.ts index 4980abc2993..7caa482a786 100644 --- a/src/cli/browser-cli-state.option-collisions.test.ts +++ b/src/cli/browser-cli-state.option-collisions.test.ts @@ -1,21 +1,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerBrowserStateCommands } from "./browser-cli-state.js"; import { createBrowserProgram as createBrowserProgramShared } from "./browser-cli-test-helpers.js"; +import type { CliRuntimeCapture } from "./test-runtime-capture.js"; + +const runtimeState = vi.hoisted(() => ({ capture: null as CliRuntimeCapture | null })); + +function getRuntimeCapture(): CliRuntimeCapture { + if (!runtimeState.capture) { + throw new Error("runtime capture not initialized"); + } + return runtimeState.capture; +} + +function getRuntime() { + return getRuntimeCapture().defaultRuntime; +} const mocks = vi.hoisted(() => ({ callBrowserRequest: vi.fn(async (..._args: unknown[]) => ({ ok: true })), runBrowserResizeWithOutput: vi.fn(async (_params: unknown) => {}), - runtime: { - log: vi.fn(), - error: vi.fn(), - writeStdout: vi.fn((value: string) => { - mocks.runtime.log(value.endsWith("\n") ? value.slice(0, -1) : value); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - mocks.runtime.log(JSON.stringify(value, null, space)); - }), - exit: vi.fn(), - }, })); vi.mock("./browser-cli-shared.js", () => ({ @@ -26,9 +29,11 @@ vi.mock("./browser-cli-resize.js", () => ({ runBrowserResizeWithOutput: mocks.runBrowserResizeWithOutput, })); -vi.mock("../runtime.js", () => ({ - defaultRuntime: mocks.runtime, -})); +vi.mock("../runtime.js", async () => { + const { createCliRuntimeCapture } = await import("./test-runtime-capture.js"); + runtimeState.capture ??= createCliRuntimeCapture(); + return { defaultRuntime: runtimeState.capture.defaultRuntime }; +}); describe("browser state option collisions", () => { const createStateProgram = ({ withGatewayUrl = false } = {}) => { @@ -59,11 +64,8 @@ describe("browser state option collisions", () => { beforeEach(() => { mocks.callBrowserRequest.mockClear(); mocks.runBrowserResizeWithOutput.mockClear(); - mocks.runtime.log.mockClear(); - mocks.runtime.error.mockClear(); - mocks.runtime.writeStdout.mockClear(); - mocks.runtime.writeJson.mockClear(); - mocks.runtime.exit.mockClear(); + getRuntimeCapture().resetRuntimeCapture(); + getRuntime().exit.mockImplementation(() => {}); }); it("forwards parent-captured --target-id on `browser cookies set`", async () => { @@ -143,37 +145,37 @@ describe("browser state option collisions", () => { await runBrowserCommand(["set", "offline", "maybe"]); expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); - expect(mocks.runtime.error).toHaveBeenCalledWith(expect.stringContaining("Expected on|off")); - expect(mocks.runtime.exit).toHaveBeenCalledWith(1); + expect(getRuntime().error).toHaveBeenCalledWith(expect.stringContaining("Expected on|off")); + expect(getRuntime().exit).toHaveBeenCalledWith(1); }); it("errors when set media receives an invalid value", async () => { await runBrowserCommand(["set", "media", "sepia"]); expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); - expect(mocks.runtime.error).toHaveBeenCalledWith( + expect(getRuntime().error).toHaveBeenCalledWith( expect.stringContaining("Expected dark|light|none"), ); - expect(mocks.runtime.exit).toHaveBeenCalledWith(1); + expect(getRuntime().exit).toHaveBeenCalledWith(1); }); it("errors when headers JSON is missing", async () => { await runBrowserCommand(["set", "headers"]); expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); - expect(mocks.runtime.error).toHaveBeenCalledWith( + expect(getRuntime().error).toHaveBeenCalledWith( expect.stringContaining("Missing headers JSON"), ); - expect(mocks.runtime.exit).toHaveBeenCalledWith(1); + expect(getRuntime().exit).toHaveBeenCalledWith(1); }); it("errors when headers JSON is not an object", async () => { await runBrowserCommand(["set", "headers", "--json", "[]"]); expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); - expect(mocks.runtime.error).toHaveBeenCalledWith( + expect(getRuntime().error).toHaveBeenCalledWith( expect.stringContaining("Headers JSON must be a JSON object"), ); - expect(mocks.runtime.exit).toHaveBeenCalledWith(1); + expect(getRuntime().exit).toHaveBeenCalledWith(1); }); }); diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 9ecc104f5e2..f5565a88a74 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -1,7 +1,9 @@ import { Command } from "commander"; import { describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; const CRON_CLI_TEST_TIMEOUT_MS = 15_000; +const { defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); const defaultGatewayMock = async ( method: string, @@ -25,24 +27,9 @@ vi.mock("./gateway-rpc.js", async () => { }; }); -vi.mock("../runtime.js", async (importOriginal) => { - const actual = await importOriginal(); - const log = vi.fn(); - return { - ...actual, - defaultRuntime: { - ...actual.defaultRuntime, - log, - error: vi.fn(), - writeStdout: (value: string) => log(value.endsWith("\n") ? value.slice(0, -1) : value), - writeJson: (value: unknown, space = 2) => - log(JSON.stringify(value, null, space > 0 ? space : undefined)), - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }, - }; -}); +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); const { registerCronCli } = await import("./cron-cli.js"); @@ -85,6 +72,7 @@ function buildProgram() { function resetGatewayMock() { callGatewayFromCli.mockClear(); callGatewayFromCli.mockImplementation(defaultGatewayMock); + resetRuntimeCapture(); } async function runCronCommand(args: string[]): Promise { diff --git a/src/cli/daemon-cli/install.integration.test.ts b/src/cli/daemon-cli/install.integration.test.ts index 92e9aff2cad..5c0f7cc4b8f 100644 --- a/src/cli/daemon-cli/install.integration.test.ts +++ b/src/cli/daemon-cli/install.integration.test.ts @@ -3,9 +3,9 @@ import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { makeTempWorkspace } from "../../test-helpers/workspace.js"; import { captureEnv } from "../../test-utils/env.js"; +import { createCliRuntimeCapture } from "../test-runtime-capture.js"; -const runtimeLogs: string[] = []; -const runtimeErrors: string[] = []; +const { runtimeLogs, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); const serviceMock = vi.hoisted(() => ({ label: "Gateway", @@ -24,24 +24,9 @@ vi.mock("../../daemon/service.js", () => ({ resolveGatewayService: () => serviceMock, })); -vi.mock("../../runtime.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - defaultRuntime: { - ...actual.defaultRuntime, - log: (message: string) => runtimeLogs.push(message), - error: (message: string) => runtimeErrors.push(message), - writeStdout: (value: string) => - runtimeLogs.push(value.endsWith("\n") ? value.slice(0, -1) : value), - writeJson: (value: unknown, space = 2) => - runtimeLogs.push(JSON.stringify(value, null, space > 0 ? space : undefined)), - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }, - }; -}); +vi.mock("../../runtime.js", () => ({ + defaultRuntime, +})); const { runDaemonInstall } = await import("./install.js"); const { clearConfigCache } = await import("../../config/config.js"); @@ -78,9 +63,8 @@ describe("runDaemonInstall integration", () => { }); beforeEach(async () => { - runtimeLogs.length = 0; - runtimeErrors.length = 0; vi.clearAllMocks(); + resetRuntimeCapture(); // Keep these defined-but-empty so dotenv won't repopulate from local .env. process.env.OPENCLAW_GATEWAY_TOKEN = ""; process.env.CLAWDBOT_GATEWAY_TOKEN = ""; diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index ddb95a8cdf6..339788bc0df 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureFullEnv } from "../../test-utils/env.js"; +import { createCliRuntimeCapture } from "../test-runtime-capture.js"; import type { DaemonActionResponse } from "./response.js"; const resolveNodeStartupTlsEnvironmentMock = vi.hoisted(() => vi.fn()); @@ -125,19 +126,9 @@ vi.mock("./response.js", () => ({ installDaemonServiceAndEmit: installDaemonServiceAndEmitMock, })); -const runtimeLogs: string[] = []; +const { defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); vi.mock("../../runtime.js", () => ({ - defaultRuntime: { - log: (message: string) => runtimeLogs.push(message), - writeStdout: (value: string) => { - runtimeLogs.push(value.endsWith("\n") ? value.slice(0, -1) : value); - }, - writeJson: (value: unknown, space = 2) => { - runtimeLogs.push(JSON.stringify(value, null, space)); - }, - error: vi.fn(), - exit: vi.fn(), - }, + defaultRuntime, })); function expectFirstInstallPlanCallOmitsToken() { @@ -176,7 +167,7 @@ describe("runDaemonInstall", () => { isGatewayDaemonRuntimeMock.mockReset(); installDaemonServiceAndEmitMock.mockReset(); service.isLoaded.mockReset(); - runtimeLogs.length = 0; + resetRuntimeCapture(); actionState.warnings.length = 0; actionState.emitted.length = 0; actionState.failed.length = 0; diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index e3c58cfcee8..c32e06cd07b 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -1,6 +1,9 @@ import { Command } from "commander"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; +const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture(); +runtime.exit.mockImplementation(() => {}); const callGateway = vi.fn(); const buildGatewayConnectionDetails = vi.fn(() => ({ url: "ws://127.0.0.1:18789", @@ -11,18 +14,6 @@ const listDevicePairing = vi.fn(); const approveDevicePairing = vi.fn(); const summarizeDeviceTokens = vi.fn(); const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise) => await fn()); -const runtime = { - log: vi.fn(), - error: vi.fn(), - writeStdout: vi.fn((value: string) => { - runtime.log(value.endsWith("\n") ? value.slice(0, -1) : value); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - runtime.log(JSON.stringify(value, null, space > 0 ? space : undefined)); - }), - exit: vi.fn(), -}; - vi.mock("../gateway/call.js", () => ({ callGateway, buildGatewayConnectionDetails, @@ -59,6 +50,11 @@ async function runDevicesCommand(argv: string[]) { await program.parseAsync(["devices", ...argv], { from: "user" }); } +function readRuntimeCallText(call: unknown[] | undefined): string { + const value = call?.[0]; + return typeof value === "string" ? value : ""; +} + describe("devices cli approve", () => { it("approves an explicit request id without listing", async () => { callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } }); @@ -312,13 +308,14 @@ describe("devices cli list", () => { await runDevicesCommand(["list"]); - const output = runtime.log.mock.calls.map((entry) => String(entry[0] ?? "")).join("\n"); + const output = runtime.log.mock.calls.map((entry) => readRuntimeCallText(entry)).join("\n"); expect(output).toContain("Scopes"); expect(output).toContain("operator.admin, operator.read"); }); }); afterEach(() => { + resetRuntimeCapture(); callGateway.mockClear(); buildGatewayConnectionDetails.mockClear(); buildGatewayConnectionDetails.mockReturnValue({ diff --git a/src/cli/directory-cli.test.ts b/src/cli/directory-cli.test.ts index d1f32aa8207..ac742476d8d 100644 --- a/src/cli/directory-cli.test.ts +++ b/src/cli/directory-cli.test.ts @@ -1,6 +1,16 @@ import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerDirectoryCli } from "./directory-cli.js"; +import type { CliRuntimeCapture } from "./test-runtime-capture.js"; + +const runtimeState = vi.hoisted(() => ({ capture: null as CliRuntimeCapture | null })); + +function getRuntimeCapture(): CliRuntimeCapture { + if (!runtimeState.capture) { + throw new Error("runtime capture not initialized"); + } + return runtimeState.capture; +} const mocks = vi.hoisted(() => ({ loadConfig: vi.fn(), @@ -9,9 +19,6 @@ const mocks = vi.hoisted(() => ({ resolveMessageChannelSelection: vi.fn(), getChannelPlugin: vi.fn(), resolveChannelDefaultAccountId: vi.fn(), - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), })); vi.mock("../config/config.js", () => ({ @@ -35,25 +42,16 @@ vi.mock("../channels/plugins/helpers.js", () => ({ resolveChannelDefaultAccountId: mocks.resolveChannelDefaultAccountId, })); -vi.mock("../runtime.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - defaultRuntime: { - ...actual.defaultRuntime, - log: (...args: unknown[]) => mocks.log(...args), - error: (...args: unknown[]) => mocks.error(...args), - writeStdout: (value: string) => mocks.log(value.endsWith("\n") ? value.slice(0, -1) : value), - writeJson: (value: unknown, space = 2) => - mocks.log(JSON.stringify(value, null, space > 0 ? space : undefined)), - exit: (...args: unknown[]) => mocks.exit(...args), - }, - }; +vi.mock("../runtime.js", async () => { + const { createCliRuntimeCapture } = await import("./test-runtime-capture.js"); + runtimeState.capture ??= createCliRuntimeCapture(); + return { defaultRuntime: runtimeState.capture.defaultRuntime }; }); describe("registerDirectoryCli", () => { beforeEach(() => { vi.clearAllMocks(); + getRuntimeCapture().resetRuntimeCapture(); mocks.loadConfig.mockReturnValue({ channels: {} }); mocks.writeConfigFile.mockResolvedValue(undefined); mocks.resolveChannelDefaultAccountId.mockReturnValue("default"); @@ -62,8 +60,8 @@ describe("registerDirectoryCli", () => { configured: ["slack"], source: "explicit", }); - mocks.exit.mockImplementation((code?: number) => { - throw new Error(`exit:${code ?? 0}`); + getRuntimeCapture().defaultRuntime.exit.mockImplementation((code: number) => { + throw new Error(`exit:${code}`); }); }); @@ -105,9 +103,9 @@ describe("registerDirectoryCli", () => { accountId: "default", }), ); - expect(mocks.log).toHaveBeenCalledWith( + expect(getRuntimeCapture().defaultRuntime.log).toHaveBeenCalledWith( JSON.stringify({ id: "self-1", name: "Family Phone" }, null, 2), ); - expect(mocks.error).not.toHaveBeenCalled(); + expect(getRuntimeCapture().defaultRuntime.error).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/mcp-cli.test.ts b/src/cli/mcp-cli.test.ts index a7defd5c5ba..57e9f16e0d7 100644 --- a/src/cli/mcp-cli.test.ts +++ b/src/cli/mcp-cli.test.ts @@ -4,28 +4,15 @@ import path from "node:path"; import { Command } from "commander"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome } from "../config/home-env.test-harness.js"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; -const mockLog = vi.fn(); -const mockError = vi.fn(); -const mockExit = vi.fn((code: number) => { - throw new Error(`__exit__:${code}`); -}); +const { defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); +const mockLog = defaultRuntime.log; +const mockError = defaultRuntime.error; -vi.mock("../runtime.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - defaultRuntime: { - ...actual.defaultRuntime, - log: (...args: unknown[]) => mockLog(...args), - error: (...args: unknown[]) => mockError(...args), - writeStdout: (value: string) => mockLog(value.endsWith("\n") ? value.slice(0, -1) : value), - writeJson: (value: unknown, space = 2) => - mockLog(JSON.stringify(value, null, space > 0 ? space : undefined)), - exit: (code: number) => mockExit(code), - }, - }; -}); +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); const tempDirs: string[] = []; @@ -52,6 +39,7 @@ describe("mcp cli", () => { beforeEach(() => { vi.clearAllMocks(); + resetRuntimeCapture(); }); afterEach(async () => { diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index 1586bb355a9..0d0593f3e88 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "../test-runtime-capture.js"; const agentCliCommandMock = vi.fn(); const agentsAddCommandMock = vi.fn(); @@ -12,17 +13,7 @@ const agentsUnbindCommandMock = vi.fn(); const setVerboseMock = vi.fn(); const createDefaultDepsMock = vi.fn(() => ({ deps: true })); -const runtime = { - log: vi.fn(), - error: vi.fn(), - writeStdout: vi.fn((value: string) => { - runtime.log(value.endsWith("\n") ? value.slice(0, -1) : value); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - runtime.log(JSON.stringify(value, null, space)); - }), - exit: vi.fn(), -}; +const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture(); vi.mock("../../commands/agent-via-gateway.js", () => ({ agentCliCommand: agentCliCommandMock, @@ -80,6 +71,8 @@ describe("registerAgentCommands", () => { beforeEach(() => { vi.clearAllMocks(); + resetRuntimeCapture(); + runtime.exit.mockImplementation(() => {}); agentCliCommandMock.mockResolvedValue(undefined); agentsAddCommandMock.mockResolvedValue(undefined); agentsBindingsCommandMock.mockResolvedValue(undefined); diff --git a/src/cli/program/register.backup.test.ts b/src/cli/program/register.backup.test.ts index c1b224b6d65..1f80074a0af 100644 --- a/src/cli/program/register.backup.test.ts +++ b/src/cli/program/register.backup.test.ts @@ -1,20 +1,11 @@ import { Command } from "commander"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "../test-runtime-capture.js"; const backupCreateCommand = vi.fn(); const backupVerifyCommand = vi.fn(); -const runtime = { - log: vi.fn(), - error: vi.fn(), - writeStdout: vi.fn((value: string) => { - runtime.log(value.endsWith("\n") ? value.slice(0, -1) : value); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - runtime.log(JSON.stringify(value, null, space)); - }), - exit: vi.fn(), -}; +const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture(); vi.mock("../../commands/backup.js", () => ({ backupCreateCommand, @@ -56,6 +47,7 @@ describe("registerBackupCommand", () => { beforeEach(() => { vi.clearAllMocks(); + resetRuntimeCapture(); backupCreateCommand.mockResolvedValue(undefined); backupVerifyCommand.mockResolvedValue(undefined); }); diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index 86cbe1869ee..4d8e1d4d7ed 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "../test-runtime-capture.js"; const statusCommand = vi.fn(); const healthCommand = vi.fn(); @@ -7,17 +8,7 @@ const sessionsCommand = vi.fn(); const sessionsCleanupCommand = vi.fn(); const setVerbose = vi.fn(); -const runtime = { - log: vi.fn(), - error: vi.fn(), - writeStdout: vi.fn((value: string) => { - runtime.log(value.endsWith("\n") ? value.slice(0, -1) : value); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - runtime.log(JSON.stringify(value, null, space)); - }), - exit: vi.fn(), -}; +const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture(); vi.mock("../../commands/status.js", () => ({ statusCommand, @@ -58,6 +49,8 @@ describe("registerStatusHealthSessionsCommands", () => { beforeEach(() => { vi.clearAllMocks(); + resetRuntimeCapture(); + runtime.exit.mockImplementation(() => {}); statusCommand.mockResolvedValue(undefined); healthCommand.mockResolvedValue(undefined); sessionsCommand.mockResolvedValue(undefined); diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index ef0361fe242..7ca1b210cfc 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -1,21 +1,12 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { encodePairingSetupCode } from "../pairing/setup-code.js"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const runtimeCapture = createCliRuntimeCapture(); +const runtime = runtimeCapture.defaultRuntime; const mocks = vi.hoisted(() => ({ - runtime: { - log: vi.fn(), - error: vi.fn(), - writeStdout: vi.fn((value: string) => { - mocks.runtime.log(value.endsWith("\n") ? value.slice(0, -1) : value); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - mocks.runtime.log(JSON.stringify(value, null, space > 0 ? space : undefined)); - }), - exit: vi.fn(() => { - throw new Error("exit"); - }), - }, loadConfig: vi.fn(), runCommandWithTimeout: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({ @@ -27,10 +18,7 @@ const mocks = vi.hoisted(() => ({ }), })); -vi.mock("../runtime.js", async (importOriginal) => ({ - ...(await importOriginal()), - defaultRuntime: mocks.runtime, -})); +vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig })); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout })); vi.mock("./command-secret-gateway.js", () => ({ @@ -48,7 +36,6 @@ vi.mock("qrcode-terminal", () => ({ }, })); -const runtime = mocks.runtime; const loadConfig = mocks.loadConfig; const runCommandWithTimeout = mocks.runCommandWithTimeout; const resolveCommandSecretRefsViaGateway = mocks.resolveCommandSecretRefsViaGateway; @@ -135,8 +122,17 @@ describe("registerQrCli", () => { await expect(runQr(args)).rejects.toThrow("exit"); } + function readRuntimeCallText(call: unknown[] | undefined): string { + const value = call?.[0]; + if (typeof value === "string") { + return value; + } + return value === undefined ? "" : JSON.stringify(value); + } + function parseLastLoggedQrJson() { - return JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { + const raw = runtime.log.mock.calls.at(-1)?.[0]; + return JSON.parse(typeof raw === "string" ? raw : "{}") as { setupCode?: string; gatewayUrl?: string; auth?: string; @@ -166,10 +162,14 @@ describe("registerQrCli", () => { beforeEach(() => { vi.clearAllMocks(); + runtimeCapture.resetRuntimeCapture(); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", ""); vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", ""); vi.stubEnv("CLAWDBOT_GATEWAY_PASSWORD", ""); + runtime.exit.mockImplementation(() => { + throw new Error("exit"); + }); }); afterEach(() => { @@ -208,7 +208,7 @@ describe("registerQrCli", () => { await runQr([]); expect(qrGenerate).toHaveBeenCalledTimes(1); - const output = runtime.log.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + const output = runtime.log.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); expect(output).toContain("Pairing QR"); expect(output).toContain("ASCII-QR"); expect(output).toContain("Gateway:"); @@ -316,7 +316,7 @@ describe("registerQrCli", () => { }); await expectQrExit(["--setup-code-only"]); - const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); expect(output).toContain("gateway.auth.mode is unset"); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -331,7 +331,7 @@ describe("registerQrCli", () => { await expectQrExit([]); - const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); expect(output).toContain("only bound to loopback"); }); @@ -363,7 +363,7 @@ describe("registerQrCli", () => { expect( runtime.log.mock.calls.some((call) => - String(call[0] ?? "").includes("gateway.remote.token inactive"), + readRuntimeCallText(call).includes("gateway.remote.token inactive"), ), ).toBe(true); }); @@ -379,7 +379,7 @@ describe("registerQrCli", () => { expect( runtime.error.mock.calls.some((call) => - String(call[0] ?? "").includes("gateway.remote.token inactive"), + readRuntimeCallText(call).includes("gateway.remote.token inactive"), ), ).toBe(true); const expected = encodePairingSetupCode({ @@ -419,7 +419,7 @@ describe("registerQrCli", () => { expect(payload.gatewayUrl).toBe("wss://remote.example.com:444"); expect( runtime.error.mock.calls.some((call) => - String(call[0] ?? "").includes("gateway.remote.password inactive"), + readRuntimeCallText(call).includes("gateway.remote.password inactive"), ), ).toBe(true); }); @@ -434,7 +434,7 @@ describe("registerQrCli", () => { }); await expectQrExit(["--remote"]); - const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n"); expect(output).toContain("qr --remote requires"); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); }); @@ -461,7 +461,8 @@ describe("registerQrCli", () => { await runQr(["--json", "--remote"]); - const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { + const raw = runtime.log.mock.calls.at(-1)?.[0]; + const payload = JSON.parse(typeof raw === "string" ? raw : "{}") as { gatewayUrl?: string; auth?: string; urlSource?: string; diff --git a/src/cli/update-cli.option-collisions.test.ts b/src/cli/update-cli.option-collisions.test.ts index e38b38184aa..f455e83cf42 100644 --- a/src/cli/update-cli.option-collisions.test.ts +++ b/src/cli/update-cli.option-collisions.test.ts @@ -1,22 +1,13 @@ import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runRegisteredCli } from "../test-utils/command-runner.js"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; const updateCommand = vi.fn(async (_opts: unknown) => {}); const updateStatusCommand = vi.fn(async (_opts: unknown) => {}); const updateWizardCommand = vi.fn(async (_opts: unknown) => {}); -const defaultRuntime = { - log: vi.fn(), - error: vi.fn(), - writeStdout: vi.fn((value: string) => { - defaultRuntime.log(value.endsWith("\n") ? value.slice(0, -1) : value); - }), - writeJson: vi.fn((value: unknown, space = 2) => { - defaultRuntime.log(JSON.stringify(value, null, space)); - }), - exit: vi.fn(), -}; +const { defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); vi.mock("./update-cli/update-command.js", () => ({ updateCommand: (opts: unknown) => updateCommand(opts), @@ -45,6 +36,7 @@ describe("update cli option collisions", () => { updateCommand.mockClear(); updateStatusCommand.mockClear(); updateWizardCommand.mockClear(); + resetRuntimeCapture(); defaultRuntime.log.mockClear(); defaultRuntime.error.mockClear(); defaultRuntime.writeStdout.mockClear(); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index c57082f1b39..c2e6983e1f8 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js"; import type { UpdateRunResult } from "../infra/update-runner.js"; import { withEnvAsync } from "../test-utils/env.js"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; const confirm = vi.fn(); const select = vi.fn(); @@ -25,6 +26,7 @@ const formatPortDiagnostics = vi.fn(); const pathExists = vi.fn(); const syncPluginsForUpdateChannel = vi.fn(); const updateNpmInstalledPlugins = vi.fn(); +const { defaultRuntime: runtimeCapture, resetRuntimeCapture } = createCliRuntimeCapture(); vi.mock("@clack/prompts", () => ({ confirm, @@ -129,22 +131,9 @@ vi.mock("./daemon-cli.js", () => ({ })); // Mock the runtime -vi.mock("../runtime.js", async (importOriginal) => { - const actual = await importOriginal(); - const log = vi.fn(); - return { - ...actual, - defaultRuntime: { - ...actual.defaultRuntime, - log, - error: vi.fn(), - writeStdout: (value: string) => log(value.endsWith("\n") ? value.slice(0, -1) : value), - writeJson: (value: unknown, space = 2) => - log(JSON.stringify(value, null, space > 0 ? space : undefined)), - exit: vi.fn(), - }, - }; -}); +vi.mock("../runtime.js", () => ({ + defaultRuntime: runtimeCapture, +})); const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); @@ -298,6 +287,8 @@ describe("update-cli", () => { beforeEach(() => { vi.clearAllMocks(); + resetRuntimeCapture(); + vi.mocked(defaultRuntime.exit).mockImplementation(() => {}); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot); vi.mocked(fetchNpmTagVersion).mockResolvedValue({