diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index cf176b44015..d6176c9b981 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; +import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.js"; /** * Test for issue #6070: @@ -27,27 +28,13 @@ vi.mock("../secrets/resolve.js", () => ({ resolveSecretRefValue: (...args: unknown[]) => mockResolveSecretRefValue(...args), })); -const mockLog = vi.fn(); -const mockError = vi.fn(); -const mockExit = vi.fn((code: number) => { - const errorMessages = mockError.mock.calls.map((c) => c.join(" ")).join("; "); - throw new Error(`__exit__:${code} - ${errorMessages}`); -}); +const { defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); +const mockLog = defaultRuntime.log; +const mockError = defaultRuntime.error; +const mockExit = defaultRuntime.exit; 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), - }, - }; + return mockRuntimeModule(importOriginal, defaultRuntime); }); function buildSnapshot(params: { @@ -139,6 +126,11 @@ describe("config cli", () => { beforeEach(() => { vi.clearAllMocks(); + resetRuntimeCapture(); + mockExit.mockImplementation((code: number) => { + const errorMessages = mockError.mock.calls.map((call) => call.join(" ")).join("; "); + throw new Error(`__exit__:${code} - ${errorMessages}`); + }); mockResolveSecretRefValue.mockResolvedValue("resolved-secret"); }); diff --git a/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts b/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts index e79df93173a..315d9ba02ab 100644 --- a/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts +++ b/src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts @@ -1,14 +1,11 @@ import { vi } from "vitest"; import type { GatewayService } from "../../../daemon/service.js"; -import type { OutputRuntimeEnv } from "../../../runtime.js"; import type { MockFn } from "../../../test-utils/vitest-mock-fn.js"; +import { createCliRuntimeCapture } from "../../test-runtime-capture.js"; -export const runtimeLogs: string[] = []; - -type LifecycleRuntimeHarness = OutputRuntimeEnv & { - error: MockFn; - exit: MockFn; -}; +const lifecycleRuntimeCapture = createCliRuntimeCapture(); +export const runtimeLogs = lifecycleRuntimeCapture.runtimeLogs; +type LifecycleRuntimeHarness = typeof lifecycleRuntimeCapture.defaultRuntime; type LifecycleServiceHarness = GatewayService & { install: MockFn; @@ -20,21 +17,7 @@ type LifecycleServiceHarness = GatewayService & { restart: MockFn; }; -export const defaultRuntime: LifecycleRuntimeHarness = { - log: (...args: unknown[]) => { - runtimeLogs.push(args.map((arg) => String(arg)).join(" ")); - }, - 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)); - }, - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`__exit__:${code}`); - }), -}; +export const defaultRuntime: LifecycleRuntimeHarness = lifecycleRuntimeCapture.defaultRuntime; export const service: LifecycleServiceHarness = { label: "TestService", @@ -50,7 +33,7 @@ export const service: LifecycleServiceHarness = { }; export function resetLifecycleRuntimeLogs() { - runtimeLogs.length = 0; + lifecycleRuntimeCapture.resetRuntimeCapture(); } export function resetLifecycleServiceMocks() { diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index 1bf9874854c..2a07682391e 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -3,6 +3,12 @@ import os from "node:os"; import path from "node:path"; import { Command } from "commander"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + firstWrittenJsonArg, + spyRuntimeErrors, + spyRuntimeJson, + spyRuntimeLogs, +} from "./test-runtime-capture.js"; const getMemorySearchManager = vi.hoisted(() => vi.fn()); const loadConfig = vi.hoisted(() => vi.fn(() => ({}))); @@ -58,26 +64,6 @@ afterEach(() => { }); describe("memory cli", () => { - function spyRuntimeLogs() { - const logSpy = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); - vi.spyOn(defaultRuntime, "writeJson").mockImplementation((value: unknown, space = 2) => { - logSpy(JSON.stringify(value, null, space > 0 ? space : undefined)); - }); - return logSpy; - } - - function spyRuntimeJson() { - return vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {}); - } - - function spyRuntimeErrors() { - return vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); - } - - function firstWrittenJson(writeJson: ReturnType) { - return (writeJson.mock.calls[0]?.[0] ?? null) as Record; - } - const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive"; // pragma: allowlist secret function expectCliSync(sync: ReturnType) { @@ -175,7 +161,7 @@ describe("memory cli", () => { }); mockManager({ ...params.manager, close }); - const error = spyRuntimeErrors(); + const error = spyRuntimeErrors(defaultRuntime); await runMemoryCli(params.args); params.beforeExpect?.(); @@ -206,7 +192,7 @@ describe("memory cli", () => { close, }); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["status"]); expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: ready")); @@ -255,7 +241,7 @@ describe("memory cli", () => { const close = vi.fn(async () => {}); setupMemoryStatusWithInactiveSecretDiagnostics(close); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["status"]); expect(hasLoggedInactiveSecretDiagnostic(log)).toBe(true); @@ -288,7 +274,7 @@ describe("memory cli", () => { close, }); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["status", "--agent", "main"]); expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: unavailable")); @@ -306,7 +292,7 @@ describe("memory cli", () => { close, }); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["status", "--deep"]); expect(probeEmbeddingAvailability).toHaveBeenCalled(); @@ -349,7 +335,7 @@ describe("memory cli", () => { close, }); - spyRuntimeLogs(); + spyRuntimeLogs(defaultRuntime); await runMemoryCli(["status", "--index"]); expectCliSync(sync); @@ -362,7 +348,7 @@ describe("memory cli", () => { const sync = vi.fn(async () => {}); mockManager({ sync, close }); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["index"]); expectCliSync(sync); @@ -376,7 +362,7 @@ describe("memory cli", () => { await withQmdIndexDb("sqlite-bytes", async (dbPath) => { mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["index"]); expectCliSync(sync); @@ -392,7 +378,7 @@ describe("memory cli", () => { await withQmdIndexDb("", async (dbPath) => { mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); - const error = spyRuntimeErrors(); + const error = spyRuntimeErrors(defaultRuntime); await runMemoryCli(["index"]); expectCliSync(sync); @@ -441,7 +427,7 @@ describe("memory cli", () => { }); mockManager({ search, close }); - const error = spyRuntimeErrors(); + const error = spyRuntimeErrors(defaultRuntime); await runMemoryCli(["search", "oops"]); expect(search).toHaveBeenCalled(); @@ -458,10 +444,14 @@ describe("memory cli", () => { close, }); - const writeJson = spyRuntimeJson(); + const writeJson = spyRuntimeJson(defaultRuntime); await runMemoryCli(["status", "--json"]); - const payload = firstWrittenJson(writeJson); + const payload = firstWrittenJsonArg(writeJson); + expect(payload).not.toBeNull(); + if (!payload) { + throw new Error("expected json payload"); + } expect(Array.isArray(payload)).toBe(true); expect((payload[0] as Record)?.agentId).toBe("main"); expect(close).toHaveBeenCalled(); @@ -471,11 +461,15 @@ describe("memory cli", () => { const close = vi.fn(async () => {}); setupMemoryStatusWithInactiveSecretDiagnostics(close); - const writeJson = spyRuntimeJson(); - const error = spyRuntimeErrors(); + const writeJson = spyRuntimeJson(defaultRuntime); + const error = spyRuntimeErrors(defaultRuntime); await runMemoryCli(["status", "--json"]); - const payload = firstWrittenJson(writeJson); + const payload = firstWrittenJsonArg(writeJson); + expect(payload).not.toBeNull(); + if (!payload) { + throw new Error("expected json payload"); + } expect(Array.isArray(payload)).toBe(true); expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true); }); @@ -483,7 +477,7 @@ describe("memory cli", () => { it("logs default message when memory manager is missing", async () => { getMemorySearchManager.mockResolvedValueOnce({ manager: null }); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["status"]); expect(log).toHaveBeenCalledWith("Memory search disabled."); @@ -496,7 +490,7 @@ describe("memory cli", () => { close, }); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["index"]); expect(log).toHaveBeenCalledWith("Memory backend does not support manual reindex."); @@ -508,7 +502,7 @@ describe("memory cli", () => { const search = vi.fn(async () => []); mockManager({ search, close }); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["search", "hello"]); expect(search).toHaveBeenCalledWith("hello", { @@ -524,7 +518,7 @@ describe("memory cli", () => { const search = vi.fn(async () => []); mockManager({ search, close }); - const log = spyRuntimeLogs(); + const log = spyRuntimeLogs(defaultRuntime); await runMemoryCli(["search", "--query", "deployment notes"]); expect(search).toHaveBeenCalledWith("deployment notes", { @@ -541,7 +535,7 @@ describe("memory cli", () => { const search = vi.fn(async () => []); mockManager({ search, close }); - spyRuntimeLogs(); + spyRuntimeLogs(defaultRuntime); await runMemoryCli(["search", "positional", "--query", "flagged"]); expect(search).toHaveBeenCalledWith("flagged", { @@ -552,7 +546,7 @@ describe("memory cli", () => { }); it("fails when neither positional query nor --query is provided", async () => { - const error = spyRuntimeErrors(); + const error = spyRuntimeErrors(defaultRuntime); await runMemoryCli(["search"]); expect(error).toHaveBeenCalledWith( @@ -575,12 +569,16 @@ describe("memory cli", () => { ]); mockManager({ search, close }); - const writeJson = spyRuntimeJson(); + const writeJson = spyRuntimeJson(defaultRuntime); await runMemoryCli(["search", "hello", "--json"]); - const payload = firstWrittenJson(writeJson); + const payload = firstWrittenJsonArg<{ results: unknown[] }>(writeJson); + expect(payload).not.toBeNull(); + if (!payload) { + throw new Error("expected json payload"); + } expect(Array.isArray(payload.results)).toBe(true); - expect(payload.results as unknown[]).toHaveLength(1); + expect(payload.results).toHaveLength(1); expect(close).toHaveBeenCalled(); }); }); diff --git a/src/cli/test-runtime-capture.ts b/src/cli/test-runtime-capture.ts index 02fbb63fdb1..555d46a7d5e 100644 --- a/src/cli/test-runtime-capture.ts +++ b/src/cli/test-runtime-capture.ts @@ -1,42 +1,94 @@ +import { vi } from "vitest"; import type { OutputRuntimeEnv } from "../runtime.js"; +import type { MockFn } from "../test-utils/vitest-mock-fn.js"; + +export type CliMockOutputRuntime = OutputRuntimeEnv & { + log: MockFn; + error: MockFn; + exit: MockFn; + writeJson: MockFn; + writeStdout: MockFn; +}; export type CliRuntimeCapture = { runtimeLogs: string[]; runtimeErrors: string[]; - defaultRuntime: Pick; + defaultRuntime: CliMockOutputRuntime; resetRuntimeCapture: () => void; }; +type MockCallsWithFirstArg = { + mock: { + calls: Array<[unknown, ...unknown[]]>; + }; +}; + +export function normalizeRuntimeStdout(value: string): string { + return value.endsWith("\n") ? value.slice(0, -1) : value; +} + +export function stringifyRuntimeJson(value: unknown, space = 2): string { + return JSON.stringify(value, null, space > 0 ? space : undefined); +} + export function createCliRuntimeCapture(): CliRuntimeCapture { const runtimeLogs: string[] = []; const runtimeErrors: string[] = []; const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" "); - const writeLine = (value: string) => { - runtimeLogs.push(value.endsWith("\n") ? value.slice(0, -1) : value); + const defaultRuntime: CliMockOutputRuntime = { + log: vi.fn((...args: unknown[]) => { + runtimeLogs.push(stringifyArgs(args)); + }), + error: vi.fn((...args: unknown[]) => { + runtimeErrors.push(stringifyArgs(args)); + }), + writeStdout: vi.fn((value: string) => { + defaultRuntime.log(normalizeRuntimeStdout(value)); + }), + writeJson: vi.fn((value: unknown, space = 2) => { + defaultRuntime.log(stringifyRuntimeJson(value, space)); + }), + exit: vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); + }), }; return { runtimeLogs, runtimeErrors, - defaultRuntime: { - log: (...args: unknown[]) => { - runtimeLogs.push(stringifyArgs(args)); - }, - error: (...args: unknown[]) => { - runtimeErrors.push(stringifyArgs(args)); - }, - writeStdout: (value: string) => { - writeLine(value); - }, - writeJson: (value: unknown, space = 2) => { - writeLine(JSON.stringify(value, null, space > 0 ? space : undefined)); - }, - exit: (code: number) => { - throw new Error(`__exit__:${code}`); - }, - }, + defaultRuntime, resetRuntimeCapture: () => { runtimeLogs.length = 0; runtimeErrors.length = 0; }, }; } + +export async function mockRuntimeModule( + importOriginal: () => Promise, + defaultRuntime: TModule["defaultRuntime"], +): Promise { + const actual = await importOriginal(); + return { + ...actual, + defaultRuntime: { + ...actual.defaultRuntime, + ...defaultRuntime, + }, + }; +} + +export function spyRuntimeLogs(runtime: Pick) { + return vi.spyOn(runtime, "log").mockImplementation(() => {}); +} + +export function spyRuntimeErrors(runtime: Pick) { + return vi.spyOn(runtime, "error").mockImplementation(() => {}); +} + +export function spyRuntimeJson(runtime: Pick) { + return vi.spyOn(runtime, "writeJson").mockImplementation(() => {}); +} + +export function firstWrittenJsonArg(writeJson: MockCallsWithFirstArg): T | null { + return (writeJson.mock.calls[0]?.[0] ?? null) as T | null; +}