From a1cb700a0582f93e0cf5b576efbab4774037f1c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:18:50 +0000 Subject: [PATCH] test: dedupe and optimize test suites --- src/agents/cli-credentials.test.ts | 31 +- ...agent-registry.announce-loop-guard.test.ts | 111 ++-- src/agents/subagent-registry.nested.test.ts | 26 +- src/auto-reply/reply/commands.test.ts | 4 +- src/browser/profiles.test.ts | 52 +- ...s-core.interactions.evaluate.abort.test.ts | 83 ++- ...server-context.hot-reload-profiles.test.ts | 16 +- src/cli/acp-cli.option-collisions.test.ts | 18 +- src/cli/argv.test.ts | 304 +++++++---- src/cli/browser-cli-extension.test.ts | 4 +- src/cli/browser-cli-inspect.test.ts | 87 ++-- ...rowser-cli-state.option-collisions.test.ts | 45 +- src/cli/browser-cli.test.ts | 84 ++- src/cli/command-options.test.ts | 73 ++- src/cli/cron-cli.test.ts | 490 ++++++++---------- src/cli/daemon-cli.coverage.e2e.test.ts | 141 +++-- src/cli/daemon-cli/lifecycle-core.test.ts | 12 +- src/cli/devices-cli.test.ts | 162 +++--- src/cli/exec-approvals-cli.test.ts | 38 +- src/cli/gateway-cli.coverage.e2e.test.ts | 197 +++---- .../register.option-collisions.test.ts | 27 +- .../gateway-cli/run.option-collisions.test.ts | 56 +- src/cli/logs-cli.test.ts | 54 +- src/cli/memory-cli.test.ts | 169 +++--- src/cli/models-cli.test.ts | 32 +- src/cli/nodes-cli.coverage.test.ts | 148 +++--- ....invoke.nodes-run-approval-timeout.test.ts | 16 +- src/cli/pairing-cli.test.ts | 194 ++++--- src/cli/profile.test.ts | 83 +-- src/cli/program.nodes-basic.e2e.test.ts | 194 ++++--- src/cli/program.nodes-media.e2e.test.ts | 283 +++++----- src/cli/program.smoke.e2e.test.ts | 235 +++++---- src/cli/program/command-registry.test.ts | 33 +- src/cli/program/config-guard.test.ts | 32 +- src/cli/program/register.maintenance.test.ts | 26 +- src/cli/program/register.subclis.e2e.test.ts | 28 +- src/cli/qr-cli.test.ts | 86 ++- src/cli/update-cli.option-collisions.test.ts | 27 +- src/cli/update-cli.test.ts | 274 ++++------ src/commands/health.snapshot.e2e.test.ts | 14 +- src/commands/models.list.test.ts | 109 ++-- src/commands/onboard-interactive.e2e.test.ts | 56 -- src/commands/onboard-interactive.test.ts | 13 + src/discord/monitor/message-utils.test.ts | 50 +- src/discord/monitor/provider.proxy.test.ts | 10 +- src/gateway/boot.test.ts | 9 +- src/gateway/call.test.ts | 258 ++++----- .../server-methods/server-methods.test.ts | 42 +- src/infra/control-ui-assets.test.ts | 46 +- src/infra/openclaw-root.test.ts | 20 +- .../outbound/message-action-runner.test.ts | 191 ++++--- .../message-action-runner.threading.test.ts | 116 ++--- src/infra/ssh-config.test.ts | 14 +- src/infra/transport-ready.test.ts | 10 +- src/infra/update-startup.test.ts | 21 +- src/line/template-messages.test.ts | 62 +-- src/logging/console-settings.test.ts | 24 +- src/media/input-files.fetch-guard.test.ts | 14 +- src/media/store.redirect.test.ts | 36 -- src/memory/batch-voyage.test.ts | 12 +- src/plugins/wired-hooks-compaction.test.ts | 25 +- src/process/child-process-bridge.test.ts | 4 +- src/process/exec.test.ts | 8 +- src/process/kill-tree.test.ts | 6 +- src/process/supervisor/adapters/child.test.ts | 9 +- src/process/supervisor/adapters/pty.test.ts | 18 +- .../supervisor/supervisor.pty-command.test.ts | 10 +- src/process/supervisor/supervisor.test.ts | 6 +- src/telegram/audit.test.ts | 13 +- src/telegram/bot.create-telegram-bot.test.ts | 167 +++--- src/test-utils/command-runner.ts | 10 + src/tui/gateway-chat.test.ts | 33 +- src/tui/tui-input-history.test.ts | 20 +- src/utils/run-with-concurrency.test.ts | 24 +- src/web/auto-reply/deliver-reply.test.ts | 7 +- .../auto-reply/web-auto-reply-utils.test.ts | 59 ++- src/web/inbound.media.test.ts | 22 +- src/web/login-qr.test.ts | 4 +- src/web/login.coverage.test.ts | 13 +- src/web/media.test.ts | 29 +- 80 files changed, 2627 insertions(+), 2962 deletions(-) delete mode 100644 src/commands/onboard-interactive.e2e.test.ts create mode 100644 src/test-utils/command-runner.ts diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index 909d69ff38..ec9dc90b2c 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -1,11 +1,16 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const execSyncMock = vi.fn(); const execFileSyncMock = vi.fn(); const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000; +let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached; +let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").resetCliCredentialCachesForTest; +let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials; +let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials; +let readCodexCliCredentials: typeof import("./cli-credentials.js").readCodexCliCredentials; function mockExistingClaudeKeychainItem() { execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { @@ -33,7 +38,6 @@ function getAddGenericPasswordCall() { } async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) { - const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js"); return readClaudeCliCredentialsCached({ allowKeychainPrompt, ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS, @@ -43,24 +47,31 @@ async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) { } describe("cli credentials", () => { + beforeAll(async () => { + ({ + readClaudeCliCredentialsCached, + resetCliCredentialCachesForTest, + writeClaudeCliKeychainCredentials, + writeClaudeCliCredentials, + readCodexCliCredentials, + } = await import("./cli-credentials.js")); + }); + beforeEach(() => { vi.useFakeTimers(); }); - afterEach(async () => { + afterEach(() => { vi.useRealTimers(); execSyncMock.mockReset(); execFileSyncMock.mockReset(); delete process.env.CODEX_HOME; - const { resetCliCredentialCachesForTest } = await import("./cli-credentials.js"); resetCliCredentialCachesForTest(); }); it("updates the Claude Code keychain item in place", async () => { mockExistingClaudeKeychainItem(); - const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); - const ok = writeClaudeCliKeychainCredentials( { access: "new-access", @@ -84,8 +95,6 @@ describe("cli credentials", () => { mockExistingClaudeKeychainItem(); - const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); - const ok = writeClaudeCliKeychainCredentials( { access: maliciousToken, @@ -112,8 +121,6 @@ describe("cli credentials", () => { mockExistingClaudeKeychainItem(); - const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js"); - const ok = writeClaudeCliKeychainCredentials( { access: "safe-access", @@ -156,8 +163,6 @@ describe("cli credentials", () => { const writeKeychain = vi.fn(() => false); - const { writeClaudeCliCredentials } = await import("./cli-credentials.js"); - const ok = writeClaudeCliCredentials( { access: "new-access", @@ -251,7 +256,6 @@ describe("cli credentials", () => { }); }); - const { readCodexCliCredentials } = await import("./cli-credentials.js"); const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock }); expect(creds).toMatchObject({ @@ -281,7 +285,6 @@ describe("cli credentials", () => { "utf8", ); - const { readCodexCliCredentials } = await import("./cli-credentials.js"); const creds = readCodexCliCredentials({ execSync: execSyncMock }); expect(creds).toMatchObject({ diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index cfeef00269..9c2545228e 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; /** * Regression test for #18264: Gateway announcement delivery loop. @@ -55,6 +55,15 @@ vi.mock("./timeout.js", () => ({ })); describe("announce loop guard (#18264)", () => { + let registry: typeof import("./subagent-registry.js"); + let announceFn: ReturnType; + + beforeAll(async () => { + registry = await import("./subagent-registry.js"); + const subagentAnnounce = await import("./subagent-announce.js"); + announceFn = vi.mocked(subagentAnnounce.runSubagentAnnounceFlow); + }); + beforeEach(() => { vi.useFakeTimers(); }); @@ -67,8 +76,7 @@ describe("announce loop guard (#18264)", () => { vi.clearAllMocks(); }); - test("SubagentRunRecord has announceRetryCount and lastAnnounceRetryAt fields", async () => { - const registry = await import("./subagent-registry.js"); + test("SubagentRunRecord has announceRetryCount and lastAnnounceRetryAt fields", () => { registry.resetSubagentRegistryForTests(); const now = Date.now(); @@ -94,74 +102,51 @@ describe("announce loop guard (#18264)", () => { expect(entry!.lastAnnounceRetryAt).toBeDefined(); }); - test("expired entries with high retry count are skipped by resumeSubagentRun", async () => { - const registry = await import("./subagent-registry.js"); - const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); - const announceFn = vi.mocked(runSubagentAnnounceFlow); + test.each([ + { + name: "expired entries with high retry count are skipped by resumeSubagentRun", + createEntry: (now: number) => ({ + // Ended 10 minutes ago (well past ANNOUNCE_EXPIRY_MS of 5 min). + runId: "test-expired-loop", + childSessionKey: "agent:main:subagent:expired-child", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "agent:main:main", + task: "expired test task", + cleanup: "keep" as const, + createdAt: now - 15 * 60_000, + startedAt: now - 14 * 60_000, + endedAt: now - 10 * 60_000, + announceRetryCount: 3, + lastAnnounceRetryAt: now - 9 * 60_000, + }), + }, + { + name: "entries over retry budget are marked completed without announcing", + createEntry: (now: number) => ({ + runId: "test-retry-budget", + childSessionKey: "agent:main:subagent:retry-budget", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "agent:main:main", + task: "retry budget test", + cleanup: "keep" as const, + createdAt: now - 2 * 60_000, + startedAt: now - 90_000, + endedAt: now - 60_000, + announceRetryCount: 3, + lastAnnounceRetryAt: now - 30_000, + }), + }, + ])("$name", ({ createEntry }) => { announceFn.mockClear(); - registry.resetSubagentRegistryForTests(); - const now = Date.now(); - // Add a run that ended 10 minutes ago (well past ANNOUNCE_EXPIRY_MS of 5 min) - // with 3 retries already attempted - const entry = { - runId: "test-expired-loop", - childSessionKey: "agent:main:subagent:expired-child", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "agent:main:main", - task: "expired test task", - cleanup: "keep", - createdAt: now - 15 * 60_000, - startedAt: now - 14 * 60_000, - endedAt: now - 10 * 60_000, // 10 minutes ago - announceRetryCount: 3, - lastAnnounceRetryAt: now - 9 * 60_000, - }; - - loadSubagentRegistryFromDisk.mockReturnValue(new Map([[entry.runId, entry]])); - - // Initialize the registry — this triggers resumeSubagentRun for persisted entries - registry.initSubagentRegistry(); - - // The announce flow should NOT be called because the entry has exceeded - // both the retry count and the expiry window. - expect(announceFn).not.toHaveBeenCalled(); - - const runs = registry.listSubagentRunsForRequester("agent:main:main"); - const stored = runs.find((run) => run.runId === entry.runId); - expect(stored?.cleanupCompletedAt).toBeDefined(); - }); - - test("entries over retry budget are marked completed without announcing", async () => { - const registry = await import("./subagent-registry.js"); - const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); - const announceFn = vi.mocked(runSubagentAnnounceFlow); - announceFn.mockClear(); - - registry.resetSubagentRegistryForTests(); - - const now = Date.now(); - const entry = { - runId: "test-retry-budget", - childSessionKey: "agent:main:subagent:retry-budget", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "agent:main:main", - task: "retry budget test", - cleanup: "keep", - createdAt: now - 2 * 60_000, - startedAt: now - 90_000, - endedAt: now - 60_000, - announceRetryCount: 3, - lastAnnounceRetryAt: now - 30_000, - }; - + const entry = createEntry(Date.now()); loadSubagentRegistryFromDisk.mockReturnValue(new Map([[entry.runId, entry]])); + // Initialization attempts resume once, then gives up for exhausted entries. registry.initSubagentRegistry(); expect(announceFn).not.toHaveBeenCalled(); - const runs = registry.listSubagentRunsForRequester("agent:main:main"); const stored = runs.find((run) => run.runId === entry.runId); expect(stored?.cleanupCompletedAt).toBeDefined(); diff --git a/src/agents/subagent-registry.nested.test.ts b/src/agents/subagent-registry.nested.test.ts index 3ab18ff328..9724d1bf78 100644 --- a/src/agents/subagent-registry.nested.test.ts +++ b/src/agents/subagent-registry.nested.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import "./subagent-registry.mocks.shared.js"; vi.mock("../config/config.js", () => ({ @@ -17,15 +17,19 @@ vi.mock("./subagent-registry.store.js", () => ({ saveSubagentRegistryToDisk: vi.fn(() => {}), })); +let subagentRegistry: typeof import("./subagent-registry.js"); + describe("subagent registry nested agent tracking", () => { - afterEach(async () => { - const mod = await import("./subagent-registry.js"); - mod.resetSubagentRegistryForTests({ persist: false }); + beforeAll(async () => { + subagentRegistry = await import("./subagent-registry.js"); + }); + + afterEach(() => { + subagentRegistry.resetSubagentRegistryForTests({ persist: false }); }); it("listSubagentRunsForRequester returns children of the requesting session", async () => { - const { registerSubagentRun, listSubagentRunsForRequester } = - await import("./subagent-registry.js"); + const { registerSubagentRun, listSubagentRunsForRequester } = subagentRegistry; // Main agent spawns a depth-1 orchestrator registerSubagentRun({ @@ -67,7 +71,7 @@ describe("subagent registry nested agent tracking", () => { }); it("announce uses requesterSessionKey to route to the correct parent", async () => { - const { registerSubagentRun } = await import("./subagent-registry.js"); + const { registerSubagentRun } = subagentRegistry; // Register a sub-sub-agent whose parent is a sub-agent registerSubagentRun({ runId: "run-subsub", @@ -82,7 +86,7 @@ describe("subagent registry nested agent tracking", () => { // When announce fires for the sub-sub-agent, it should target the sub-agent (depth-1), // NOT the main session. The registry entry's requesterSessionKey ensures this. // We verify the registry entry has the correct requesterSessionKey. - const { listSubagentRunsForRequester } = await import("./subagent-registry.js"); + const { listSubagentRunsForRequester } = subagentRegistry; const orchRuns = listSubagentRunsForRequester("agent:main:subagent:orch"); expect(orchRuns).toHaveLength(1); expect(orchRuns[0].requesterSessionKey).toBe("agent:main:subagent:orch"); @@ -90,8 +94,7 @@ describe("subagent registry nested agent tracking", () => { }); it("countActiveRunsForSession only counts active children of the specific session", async () => { - const { registerSubagentRun, countActiveRunsForSession } = - await import("./subagent-registry.js"); + const { registerSubagentRun, countActiveRunsForSession } = subagentRegistry; // Main spawns orchestrator (active) registerSubagentRun({ @@ -130,8 +133,7 @@ describe("subagent registry nested agent tracking", () => { }); it("countActiveDescendantRuns traverses through ended parents", async () => { - const { addSubagentRunForTests, countActiveDescendantRuns } = - await import("./subagent-registry.js"); + const { addSubagentRunForTests, countActiveDescendantRuns } = subagentRegistry; addSubagentRunForTests({ runId: "run-parent-ended", diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 018bbfd237..af10d54847 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; import { addSubagentRunForTests, listSubagentRunsForRequester, @@ -294,7 +295,6 @@ describe("/compact command", () => { }); it("returns null when command is not /compact", async () => { - const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -313,7 +313,6 @@ describe("/compact command", () => { }); it("rejects unauthorized /compact commands", async () => { - const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -337,7 +336,6 @@ describe("/compact command", () => { }); it("routes manual compaction with explicit trigger and context metadata", async () => { - const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); const cfg = { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, diff --git a/src/browser/profiles.test.ts b/src/browser/profiles.test.ts index cf706aab36..765bda58d5 100644 --- a/src/browser/profiles.test.ts +++ b/src/browser/profiles.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { resolveBrowserConfig } from "./config.js"; import { allocateCdpPort, allocateColor, @@ -11,15 +12,12 @@ import { } from "./profiles.js"; describe("profile name validation", () => { - it("accepts valid lowercase names", () => { - expect(isValidProfileName("openclaw")).toBe(true); - expect(isValidProfileName("work")).toBe(true); - expect(isValidProfileName("my-profile")).toBe(true); - expect(isValidProfileName("test123")).toBe(true); - expect(isValidProfileName("a")).toBe(true); - expect(isValidProfileName("a-b-c-1-2-3")).toBe(true); - expect(isValidProfileName("1test")).toBe(true); - }); + it.each(["openclaw", "work", "my-profile", "test123", "a", "a-b-c-1-2-3", "1test"])( + "accepts valid lowercase name: %s", + (name) => { + expect(isValidProfileName(name)).toBe(true); + }, + ); it("rejects empty or missing names", () => { expect(isValidProfileName("")).toBe(false); @@ -37,23 +35,19 @@ describe("profile name validation", () => { expect(isValidProfileName(maxName)).toBe(true); }); - it("rejects uppercase letters", () => { - expect(isValidProfileName("MyProfile")).toBe(false); - expect(isValidProfileName("PROFILE")).toBe(false); - expect(isValidProfileName("Work")).toBe(false); - }); - - it("rejects spaces and special characters", () => { - expect(isValidProfileName("my profile")).toBe(false); - expect(isValidProfileName("my_profile")).toBe(false); - expect(isValidProfileName("my.profile")).toBe(false); - expect(isValidProfileName("my/profile")).toBe(false); - expect(isValidProfileName("my@profile")).toBe(false); - }); - - it("rejects names starting with hyphen", () => { - expect(isValidProfileName("-invalid")).toBe(false); - expect(isValidProfileName("--double")).toBe(false); + it.each([ + "MyProfile", + "PROFILE", + "Work", + "my profile", + "my_profile", + "my.profile", + "my/profile", + "my@profile", + "-invalid", + "--double", + ])("rejects invalid name: %s", (name) => { + expect(isValidProfileName(name)).toBe(false); }); }); @@ -131,9 +125,8 @@ describe("getUsedPorts", () => { }); describe("port collision prevention", () => { - it("raw config vs resolved config - shows the data source difference", async () => { + it("raw config vs resolved config - shows the data source difference", () => { // This demonstrates WHY the route handler must use resolved config - const { resolveBrowserConfig } = await import("./config.js"); // Fresh config with no profiles defined (like a new install) const rawConfigProfiles = undefined; @@ -148,9 +141,8 @@ describe("port collision prevention", () => { expect(usedFromResolved.has(CDP_PORT_RANGE_START)).toBe(true); }); - it("create-profile must use resolved config to avoid port collision", async () => { + it("create-profile must use resolved config to avoid port collision", () => { // The route handler must use state.resolved.profiles, not raw config - const { resolveBrowserConfig } = await import("./config.js"); // Simulate what happens with raw config (empty) vs resolved config const rawConfig: { browser: { profiles?: Record } } = { diff --git a/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts b/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts index dcada002db..36df7def9f 100644 --- a/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts +++ b/src/browser/pw-tools-core.interactions.evaluate.abort.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; let page: { evaluate: ReturnType } | null = null; let locator: { evaluate: ReturnType } | null = null; @@ -29,64 +29,61 @@ vi.mock("./pw-session.js", () => { }; }); -describe("evaluateViaPlaywright (abort)", () => { - it("rejects when aborted after page.evaluate starts", async () => { - vi.clearAllMocks(); - const ctrl = new AbortController(); +let evaluateViaPlaywright: typeof import("./pw-tools-core.interactions.js").evaluateViaPlaywright; - let evalCalled!: () => void; - const evalCalledPromise = new Promise((resolve) => { - evalCalled = resolve; - }); +function createPendingEval() { + let evalCalled!: () => void; + const evalCalledPromise = new Promise((resolve) => { + evalCalled = resolve; + }); + return { + evalCalledPromise, + resolveEvalCalled: evalCalled, + }; +} + +describe("evaluateViaPlaywright (abort)", () => { + beforeAll(async () => { + ({ evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { label: "page.evaluate", fn: "() => 1" }, + { label: "locator.evaluate", fn: "(el) => el.textContent", ref: "e1" }, + ])("rejects when aborted after $label starts", async ({ fn, ref }) => { + const ctrl = new AbortController(); + const pending = createPendingEval(); + const pendingPromise = new Promise(() => {}); page = { evaluate: vi.fn(() => { - evalCalled(); - return new Promise(() => {}); + if (!ref) { + pending.resolveEvalCalled(); + } + return pendingPromise; }), }; - locator = { evaluate: vi.fn() }; - - const { evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js"); - const p = evaluateViaPlaywright({ - cdpUrl: "http://127.0.0.1:9222", - fn: "() => 1", - signal: ctrl.signal, - }); - - await evalCalledPromise; - ctrl.abort(new Error("aborted by test")); - - await expect(p).rejects.toThrow("aborted by test"); - expect(forceDisconnectPlaywrightForTarget).toHaveBeenCalled(); - }); - - it("rejects when aborted after locator.evaluate starts", async () => { - vi.clearAllMocks(); - const ctrl = new AbortController(); - - let evalCalled!: () => void; - const evalCalledPromise = new Promise((resolve) => { - evalCalled = resolve; - }); - - page = { evaluate: vi.fn() }; locator = { evaluate: vi.fn(() => { - evalCalled(); - return new Promise(() => {}); + if (ref) { + pending.resolveEvalCalled(); + } + return pendingPromise; }), }; - const { evaluateViaPlaywright } = await import("./pw-tools-core.interactions.js"); const p = evaluateViaPlaywright({ cdpUrl: "http://127.0.0.1:9222", - fn: "(el) => el.textContent", - ref: "e1", + fn, + ref, signal: ctrl.signal, }); - await evalCalledPromise; + await pending.evalCalledPromise; ctrl.abort(new Error("aborted by test")); await expect(p).rejects.toThrow("aborted by test"); diff --git a/src/browser/server-context.hot-reload-profiles.test.ts b/src/browser/server-context.hot-reload-profiles.test.ts index 6a0e416f5e..7145dff517 100644 --- a/src/browser/server-context.hot-reload-profiles.test.ts +++ b/src/browser/server-context.hot-reload-profiles.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveBrowserConfig } from "./config.js"; import { refreshResolvedBrowserConfigFromDisk, @@ -40,6 +40,12 @@ vi.mock("../config/config.js", () => ({ })); describe("server-context hot-reload profiles", () => { + let loadConfig: typeof import("../config/config.js").loadConfig; + + beforeAll(async () => { + ({ loadConfig } = await import("../config/config.js")); + }); + beforeEach(() => { vi.clearAllMocks(); cfgProfiles = { @@ -49,8 +55,6 @@ describe("server-context hot-reload profiles", () => { }); it("forProfile hot-reloads newly added profiles from config", async () => { - const { loadConfig } = await import("../config/config.js"); - // Start with only openclaw profile // 1. Prime the cache by calling loadConfig() first const cfg = loadConfig(); @@ -101,8 +105,6 @@ describe("server-context hot-reload profiles", () => { }); it("forProfile still throws for profiles that don't exist in fresh config", async () => { - const { loadConfig } = await import("../config/config.js"); - const cfg = loadConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const state = { @@ -123,8 +125,6 @@ describe("server-context hot-reload profiles", () => { }); it("forProfile refreshes existing profile config after loadConfig cache updates", async () => { - const { loadConfig } = await import("../config/config.js"); - const cfg = loadConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const state = { @@ -147,8 +147,6 @@ describe("server-context hot-reload profiles", () => { }); it("listProfiles refreshes config before enumerating profiles", async () => { - const { loadConfig } = await import("../config/config.js"); - const cfg = loadConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const state = { diff --git a/src/cli/acp-cli.option-collisions.test.ts b/src/cli/acp-cli.option-collisions.test.ts index a891dabbfa..851e521e3a 100644 --- a/src/cli/acp-cli.option-collisions.test.ts +++ b/src/cli/acp-cli.option-collisions.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runRegisteredCli } from "../test-utils/command-runner.js"; const runAcpClientInteractive = vi.fn(async (_opts: unknown) => {}); const serveAcpGateway = vi.fn(async (_opts: unknown) => {}); @@ -25,6 +26,12 @@ vi.mock("../runtime.js", () => ({ })); describe("acp cli option collisions", () => { + let registerAcpCli: typeof import("./acp-cli.js").registerAcpCli; + + beforeAll(async () => { + ({ registerAcpCli } = await import("./acp-cli.js")); + }); + beforeEach(() => { runAcpClientInteractive.mockClear(); serveAcpGateway.mockClear(); @@ -33,11 +40,10 @@ describe("acp cli option collisions", () => { }); it("forwards --verbose to `acp client` when parent and child option names collide", async () => { - const { registerAcpCli } = await import("./acp-cli.js"); - const program = new Command(); - registerAcpCli(program); - - await program.parseAsync(["acp", "client", "--verbose"], { from: "user" }); + await runRegisteredCli({ + register: registerAcpCli as (program: Command) => void, + argv: ["acp", "client", "--verbose"], + }); expect(runAcpClientInteractive).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index a43c6d2e2b..0b7a9d6693 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -13,40 +13,106 @@ import { } from "./argv.js"; describe("argv helpers", () => { - it("detects help/version flags", () => { - expect(hasHelpOrVersion(["node", "openclaw", "--help"])).toBe(true); - expect(hasHelpOrVersion(["node", "openclaw", "-V"])).toBe(true); - expect(hasHelpOrVersion(["node", "openclaw", "status"])).toBe(false); + it.each([ + { + name: "help flag", + argv: ["node", "openclaw", "--help"], + expected: true, + }, + { + name: "version flag", + argv: ["node", "openclaw", "-V"], + expected: true, + }, + { + name: "normal command", + argv: ["node", "openclaw", "status"], + expected: false, + }, + ])("detects help/version flags: $name", ({ argv, expected }) => { + expect(hasHelpOrVersion(argv)).toBe(expected); }); - it("extracts command path ignoring flags and terminator", () => { - expect(getCommandPath(["node", "openclaw", "status", "--json"], 2)).toEqual(["status"]); - expect(getCommandPath(["node", "openclaw", "agents", "list"], 2)).toEqual(["agents", "list"]); - expect(getCommandPath(["node", "openclaw", "status", "--", "ignored"], 2)).toEqual(["status"]); + it.each([ + { + name: "single command with trailing flag", + argv: ["node", "openclaw", "status", "--json"], + expected: ["status"], + }, + { + name: "two-part command", + argv: ["node", "openclaw", "agents", "list"], + expected: ["agents", "list"], + }, + { + name: "terminator cuts parsing", + argv: ["node", "openclaw", "status", "--", "ignored"], + expected: ["status"], + }, + ])("extracts command path: $name", ({ argv, expected }) => { + expect(getCommandPath(argv, 2)).toEqual(expected); }); - it("returns primary command", () => { - expect(getPrimaryCommand(["node", "openclaw", "agents", "list"])).toBe("agents"); - expect(getPrimaryCommand(["node", "openclaw"])).toBeNull(); + it.each([ + { + name: "returns first command token", + argv: ["node", "openclaw", "agents", "list"], + expected: "agents", + }, + { + name: "returns null when no command exists", + argv: ["node", "openclaw"], + expected: null, + }, + ])("returns primary command: $name", ({ argv, expected }) => { + expect(getPrimaryCommand(argv)).toBe(expected); }); - it("parses boolean flags and ignores terminator", () => { - expect(hasFlag(["node", "openclaw", "status", "--json"], "--json")).toBe(true); - expect(hasFlag(["node", "openclaw", "--", "--json"], "--json")).toBe(false); + it.each([ + { + name: "detects flag before terminator", + argv: ["node", "openclaw", "status", "--json"], + flag: "--json", + expected: true, + }, + { + name: "ignores flag after terminator", + argv: ["node", "openclaw", "--", "--json"], + flag: "--json", + expected: false, + }, + ])("parses boolean flags: $name", ({ argv, flag, expected }) => { + expect(hasFlag(argv, flag)).toBe(expected); }); - it("extracts flag values with equals and missing values", () => { - expect(getFlagValue(["node", "openclaw", "status", "--timeout", "5000"], "--timeout")).toBe( - "5000", - ); - expect(getFlagValue(["node", "openclaw", "status", "--timeout=2500"], "--timeout")).toBe( - "2500", - ); - expect(getFlagValue(["node", "openclaw", "status", "--timeout"], "--timeout")).toBeNull(); - expect(getFlagValue(["node", "openclaw", "status", "--timeout", "--json"], "--timeout")).toBe( - null, - ); - expect(getFlagValue(["node", "openclaw", "--", "--timeout=99"], "--timeout")).toBeUndefined(); + it.each([ + { + name: "value in next token", + argv: ["node", "openclaw", "status", "--timeout", "5000"], + expected: "5000", + }, + { + name: "value in equals form", + argv: ["node", "openclaw", "status", "--timeout=2500"], + expected: "2500", + }, + { + name: "missing value", + argv: ["node", "openclaw", "status", "--timeout"], + expected: null, + }, + { + name: "next token is another flag", + argv: ["node", "openclaw", "status", "--timeout", "--json"], + expected: null, + }, + { + name: "flag appears after terminator", + argv: ["node", "openclaw", "--", "--timeout=99"], + expected: undefined, + }, + ])("extracts flag values: $name", ({ argv, expected }) => { + expect(getFlagValue(argv, "--timeout")).toBe(expected); }); it("parses verbose flags", () => { @@ -57,79 +123,82 @@ describe("argv helpers", () => { ); }); - it("parses positive integer flag values", () => { - expect(getPositiveIntFlagValue(["node", "openclaw", "status"], "--timeout")).toBeUndefined(); - expect( - getPositiveIntFlagValue(["node", "openclaw", "status", "--timeout"], "--timeout"), - ).toBeNull(); - expect( - getPositiveIntFlagValue(["node", "openclaw", "status", "--timeout", "5000"], "--timeout"), - ).toBe(5000); - expect( - getPositiveIntFlagValue(["node", "openclaw", "status", "--timeout", "nope"], "--timeout"), - ).toBeUndefined(); + it.each([ + { + name: "missing flag", + argv: ["node", "openclaw", "status"], + expected: undefined, + }, + { + name: "missing value", + argv: ["node", "openclaw", "status", "--timeout"], + expected: null, + }, + { + name: "valid positive integer", + argv: ["node", "openclaw", "status", "--timeout", "5000"], + expected: 5000, + }, + { + name: "invalid integer", + argv: ["node", "openclaw", "status", "--timeout", "nope"], + expected: undefined, + }, + ])("parses positive integer flag values: $name", ({ argv, expected }) => { + expect(getPositiveIntFlagValue(argv, "--timeout")).toBe(expected); }); it("builds parse argv from raw args", () => { - const nodeArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["node", "openclaw", "status"], - }); - expect(nodeArgv).toEqual(["node", "openclaw", "status"]); + const cases = [ + { + rawArgs: ["node", "openclaw", "status"], + expected: ["node", "openclaw", "status"], + }, + { + rawArgs: ["node-22", "openclaw", "status"], + expected: ["node-22", "openclaw", "status"], + }, + { + rawArgs: ["node-22.2.0.exe", "openclaw", "status"], + expected: ["node-22.2.0.exe", "openclaw", "status"], + }, + { + rawArgs: ["node-22.2", "openclaw", "status"], + expected: ["node-22.2", "openclaw", "status"], + }, + { + rawArgs: ["node-22.2.exe", "openclaw", "status"], + expected: ["node-22.2.exe", "openclaw", "status"], + }, + { + rawArgs: ["/usr/bin/node-22.2.0", "openclaw", "status"], + expected: ["/usr/bin/node-22.2.0", "openclaw", "status"], + }, + { + rawArgs: ["nodejs", "openclaw", "status"], + expected: ["nodejs", "openclaw", "status"], + }, + { + rawArgs: ["node-dev", "openclaw", "status"], + expected: ["node", "openclaw", "node-dev", "openclaw", "status"], + }, + { + rawArgs: ["openclaw", "status"], + expected: ["node", "openclaw", "status"], + }, + { + rawArgs: ["bun", "src/entry.ts", "status"], + expected: ["bun", "src/entry.ts", "status"], + }, + ] as const; - const versionedNodeArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["node-22", "openclaw", "status"], - }); - expect(versionedNodeArgv).toEqual(["node-22", "openclaw", "status"]); - - const versionedNodeWindowsArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["node-22.2.0.exe", "openclaw", "status"], - }); - expect(versionedNodeWindowsArgv).toEqual(["node-22.2.0.exe", "openclaw", "status"]); - - const versionedNodePatchlessArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["node-22.2", "openclaw", "status"], - }); - expect(versionedNodePatchlessArgv).toEqual(["node-22.2", "openclaw", "status"]); - - const versionedNodeWindowsPatchlessArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["node-22.2.exe", "openclaw", "status"], - }); - expect(versionedNodeWindowsPatchlessArgv).toEqual(["node-22.2.exe", "openclaw", "status"]); - - const versionedNodeWithPathArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["/usr/bin/node-22.2.0", "openclaw", "status"], - }); - expect(versionedNodeWithPathArgv).toEqual(["/usr/bin/node-22.2.0", "openclaw", "status"]); - - const nodejsArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["nodejs", "openclaw", "status"], - }); - expect(nodejsArgv).toEqual(["nodejs", "openclaw", "status"]); - - const nonVersionedNodeArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["node-dev", "openclaw", "status"], - }); - expect(nonVersionedNodeArgv).toEqual(["node", "openclaw", "node-dev", "openclaw", "status"]); - - const directArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["openclaw", "status"], - }); - expect(directArgv).toEqual(["node", "openclaw", "status"]); - - const bunArgv = buildParseArgv({ - programName: "openclaw", - rawArgs: ["bun", "src/entry.ts", "status"], - }); - expect(bunArgv).toEqual(["bun", "src/entry.ts", "status"]); + for (const testCase of cases) { + const parsed = buildParseArgv({ + programName: "openclaw", + rawArgs: [...testCase.rawArgs], + }); + expect(parsed).toEqual([...testCase.expected]); + } }); it("builds parse argv from fallback args", () => { @@ -141,23 +210,36 @@ describe("argv helpers", () => { }); it("decides when to migrate state", () => { - expect(shouldMigrateState(["node", "openclaw", "status"])).toBe(false); - expect(shouldMigrateState(["node", "openclaw", "health"])).toBe(false); - expect(shouldMigrateState(["node", "openclaw", "sessions"])).toBe(false); - expect(shouldMigrateState(["node", "openclaw", "config", "get", "update"])).toBe(false); - expect(shouldMigrateState(["node", "openclaw", "config", "unset", "update"])).toBe(false); - expect(shouldMigrateState(["node", "openclaw", "models", "list"])).toBe(false); - expect(shouldMigrateState(["node", "openclaw", "models", "status"])).toBe(false); - expect(shouldMigrateState(["node", "openclaw", "memory", "status"])).toBe(false); - expect(shouldMigrateState(["node", "openclaw", "agent", "--message", "hi"])).toBe(false); - expect(shouldMigrateState(["node", "openclaw", "agents", "list"])).toBe(true); - expect(shouldMigrateState(["node", "openclaw", "message", "send"])).toBe(true); + const nonMutatingArgv = [ + ["node", "openclaw", "status"], + ["node", "openclaw", "health"], + ["node", "openclaw", "sessions"], + ["node", "openclaw", "config", "get", "update"], + ["node", "openclaw", "config", "unset", "update"], + ["node", "openclaw", "models", "list"], + ["node", "openclaw", "models", "status"], + ["node", "openclaw", "memory", "status"], + ["node", "openclaw", "agent", "--message", "hi"], + ] as const; + const mutatingArgv = [ + ["node", "openclaw", "agents", "list"], + ["node", "openclaw", "message", "send"], + ] as const; + + for (const argv of nonMutatingArgv) { + expect(shouldMigrateState([...argv])).toBe(false); + } + for (const argv of mutatingArgv) { + expect(shouldMigrateState([...argv])).toBe(true); + } }); - it("reuses command path for migrate state decisions", () => { - expect(shouldMigrateStateFromPath(["status"])).toBe(false); - expect(shouldMigrateStateFromPath(["config", "get"])).toBe(false); - expect(shouldMigrateStateFromPath(["models", "status"])).toBe(false); - expect(shouldMigrateStateFromPath(["agents", "list"])).toBe(true); + it.each([ + { path: ["status"], expected: false }, + { path: ["config", "get"], expected: false }, + { path: ["models", "status"], expected: false }, + { path: ["agents", "list"], expected: true }, + ])("reuses command path for migrate state decisions: $path", ({ path, expected }) => { + expect(shouldMigrateStateFromPath(path)).toBe(expected); }); }); diff --git a/src/cli/browser-cli-extension.test.ts b/src/cli/browser-cli-extension.test.ts index d5232b6361..581813aa29 100644 --- a/src/cli/browser-cli-extension.test.ts +++ b/src/cli/browser-cli-extension.test.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const copyToClipboard = vi.fn(); @@ -117,7 +118,6 @@ beforeEach(() => { runtime.log.mockReset(); runtime.error.mockReset(); runtime.exit.mockReset(); - vi.clearAllMocks(); }); function writeManifest(dir: string) { @@ -177,8 +177,6 @@ describe("browser extension install (fs-mocked)", () => { const dir = path.join(tmp, "browser", "chrome-extension"); writeManifest(dir); - const { Command } = await import("commander"); - const program = new Command(); const browser = program.command("browser").option("--json", "JSON output", false); registerBrowserExtensionCommands( diff --git a/src/cli/browser-cli-inspect.test.ts b/src/cli/browser-cli-inspect.test.ts index 6dff715191..a392b06963 100644 --- a/src/cli/browser-cli-inspect.test.ts +++ b/src/cli/browser-cli-inspect.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const gatewayMocks = vi.hoisted(() => ({ callGatewayFromCli: vi.fn(async () => ({ @@ -56,56 +56,63 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: runtime, })); +let registerBrowserInspectCommands: typeof import("./browser-cli-inspect.js").registerBrowserInspectCommands; + describe("browser cli snapshot defaults", () => { + const runSnapshot = async (args: string[]) => { + const program = new Command(); + const browser = program.command("browser").option("--json", "JSON output", false); + registerBrowserInspectCommands(browser, () => ({})); + await program.parseAsync(["browser", "snapshot", ...args], { from: "user" }); + + const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? []; + return params as { path?: string; query?: Record } | undefined; + }; + + beforeAll(async () => { + ({ registerBrowserInspectCommands } = await import("./browser-cli-inspect.js")); + }); + afterEach(() => { vi.clearAllMocks(); configMocks.loadConfig.mockReturnValue({ browser: {} }); }); - it("uses config snapshot defaults when mode is not provided", async () => { + it.each([ + { + label: "uses config snapshot defaults when mode is not provided", + args: [], + expectMode: "efficient", + }, + { + label: "does not apply config snapshot defaults to aria snapshots", + args: ["--format", "aria"], + expectMode: undefined, + }, + ])("$label", async ({ args, expectMode }) => { configMocks.loadConfig.mockReturnValue({ browser: { snapshotDefaults: { mode: "efficient" } }, }); - const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js"); - const program = new Command(); - const browser = program.command("browser").option("--json", "JSON output", false); - registerBrowserInspectCommands(browser, () => ({})); + if (args.includes("--format")) { + gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({ + ok: true, + format: "aria", + targetId: "t1", + url: "https://example.com", + snapshot: "ok", + }); + } - await program.parseAsync(["browser", "snapshot"], { from: "user" }); - - expect(sharedMocks.callBrowserRequest).toHaveBeenCalled(); - const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? []; + const params = await runSnapshot(args); expect(params?.path).toBe("/snapshot"); - expect(params?.query).toMatchObject({ - format: "ai", - mode: "efficient", - }); - }); - - it("does not apply config snapshot defaults to aria snapshots", async () => { - configMocks.loadConfig.mockReturnValue({ - browser: { snapshotDefaults: { mode: "efficient" } }, - }); - - gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({ - ok: true, - format: "aria", - targetId: "t1", - url: "https://example.com", - snapshot: "ok", - }); - - const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js"); - const program = new Command(); - const browser = program.command("browser").option("--json", "JSON output", false); - registerBrowserInspectCommands(browser, () => ({})); - - await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" }); - - expect(sharedMocks.callBrowserRequest).toHaveBeenCalled(); - const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? []; - expect(params?.path).toBe("/snapshot"); - expect((params?.query as { mode?: unknown } | undefined)?.mode).toBeUndefined(); + if (expectMode === undefined) { + expect((params?.query as { mode?: unknown } | undefined)?.mode).toBeUndefined(); + } else { + expect(params?.query).toMatchObject({ + format: "ai", + mode: expectMode, + }); + } }); }); diff --git a/src/cli/browser-cli-state.option-collisions.test.ts b/src/cli/browser-cli-state.option-collisions.test.ts index 935355b671..a4ff8a301c 100644 --- a/src/cli/browser-cli-state.option-collisions.test.ts +++ b/src/cli/browser-cli-state.option-collisions.test.ts @@ -46,6 +46,12 @@ describe("browser state option collisions", () => { return call[1] as { body?: Record }; }; + const runBrowserCommand = async (argv: string[]) => { + const program = createBrowserProgram(); + await program.parseAsync(["browser", ...argv], { from: "user" }); + return getLastRequest(); + }; + beforeEach(() => { mocks.callBrowserRequest.mockClear(); mocks.runBrowserResizeWithOutput.mockClear(); @@ -55,35 +61,24 @@ describe("browser state option collisions", () => { }); it("forwards parent-captured --target-id on `browser cookies set`", async () => { - const program = createBrowserProgram(); + const request = await runBrowserCommand([ + "cookies", + "set", + "session", + "abc", + "--url", + "https://example.com", + "--target-id", + "tab-1", + ]); - await program.parseAsync( - [ - "browser", - "cookies", - "set", - "session", - "abc", - "--url", - "https://example.com", - "--target-id", - "tab-1", - ], - { from: "user" }, - ); - - const request = getLastRequest() as { body?: { targetId?: string } }; - expect(request.body?.targetId).toBe("tab-1"); + expect((request as { body?: { targetId?: string } }).body?.targetId).toBe("tab-1"); }); it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => { - const program = createBrowserProgram(); - - await program.parseAsync(["browser", "set", "headers", "--json", '{"x-auth":"ok"}'], { - from: "user", - }); - - const request = getLastRequest() as { body?: { headers?: Record } }; + const request = (await runBrowserCommand(["set", "headers", "--json", '{"x-auth":"ok"}'])) as { + body?: { headers?: Record }; + }; expect(request.body?.headers).toEqual({ "x-auth": "ok" }); }); }); diff --git a/src/cli/browser-cli.test.ts b/src/cli/browser-cli.test.ts index 622e1c3026..56bcbce2bd 100644 --- a/src/cli/browser-cli.test.ts +++ b/src/cli/browser-cli.test.ts @@ -1,70 +1,50 @@ import { Command } from "commander"; import { describe, expect, it } from "vitest"; -describe("browser CLI --browser-profile flag", () => { - it("parses --browser-profile from parent command options", () => { - const program = new Command(); - program.name("test"); +function runBrowserStatus(argv: string[]) { + const program = new Command(); + program.name("test"); + program.option("--profile ", "Global config profile"); - const browser = program - .command("browser") - .option("--browser-profile ", "Browser profile name"); + const browser = program + .command("browser") + .option("--browser-profile ", "Browser profile name"); - let capturedProfile: string | undefined; + let globalProfile: string | undefined; + let browserProfile: string | undefined = "should-be-undefined"; - browser.command("status").action((_opts, cmd) => { - const parent = cmd.parent?.opts?.() as { browserProfile?: string }; - capturedProfile = parent?.browserProfile; - }); - - program.parse(["node", "test", "browser", "--browser-profile", "onasset", "status"]); - - expect(capturedProfile).toBe("onasset"); + browser.command("status").action((_opts, cmd) => { + const parent = cmd.parent?.opts?.() as { browserProfile?: string }; + browserProfile = parent?.browserProfile; + globalProfile = program.opts().profile; }); - it("defaults to undefined when --browser-profile not provided", () => { - const program = new Command(); - program.name("test"); + program.parse(["node", "test", ...argv]); - const browser = program - .command("browser") - .option("--browser-profile ", "Browser profile name"); + return { globalProfile, browserProfile }; +} - let capturedProfile: string | undefined = "should-be-undefined"; - - browser.command("status").action((_opts, cmd) => { - const parent = cmd.parent?.opts?.() as { browserProfile?: string }; - capturedProfile = parent?.browserProfile; - }); - - program.parse(["node", "test", "browser", "status"]); - - expect(capturedProfile).toBeUndefined(); +describe("browser CLI --browser-profile flag", () => { + it.each([ + { + label: "parses --browser-profile from parent command options", + argv: ["browser", "--browser-profile", "onasset", "status"], + expectedBrowserProfile: "onasset", + }, + { + label: "defaults to undefined when --browser-profile not provided", + argv: ["browser", "status"], + expectedBrowserProfile: undefined, + }, + ])("$label", ({ argv, expectedBrowserProfile }) => { + const { browserProfile } = runBrowserStatus(argv); + expect(browserProfile).toBe(expectedBrowserProfile); }); it("does not conflict with global --profile flag", () => { // The global --profile flag is handled by /entry.js before Commander // This test verifies --browser-profile is a separate option - const program = new Command(); - program.name("test"); - program.option("--profile ", "Global config profile"); - - const browser = program - .command("browser") - .option("--browser-profile ", "Browser profile name"); - - let globalProfile: string | undefined; - let browserProfile: string | undefined; - - browser.command("status").action((_opts, cmd) => { - const parent = cmd.parent?.opts?.() as { browserProfile?: string }; - browserProfile = parent?.browserProfile; - globalProfile = program.opts().profile; - }); - - program.parse([ - "node", - "test", + const { globalProfile, browserProfile } = runBrowserStatus([ "--profile", "dev", "browser", diff --git a/src/cli/command-options.test.ts b/src/cli/command-options.test.ts index 89d40ab716..5abccd6bc3 100644 --- a/src/cli/command-options.test.ts +++ b/src/cli/command-options.test.ts @@ -2,40 +2,40 @@ import { Command } from "commander"; import { describe, expect, it } from "vitest"; import { inheritOptionFromParent } from "./command-options.js"; +function attachRunCommandAndCaptureInheritedToken(command: Command) { + let inherited: string | undefined; + command + .command("run") + .option("--token ", "Run token") + .action((_opts, childCommand) => { + inherited = inheritOptionFromParent(childCommand, "token"); + }); + return () => inherited; +} + describe("inheritOptionFromParent", () => { - it("inherits from grandparent when parent does not define the option", async () => { + it.each([ + { + label: "inherits from grandparent when parent does not define the option", + parentHasTokenOption: false, + argv: ["--token", "root-token", "gateway", "run"], + expected: "root-token", + }, + { + label: "prefers nearest ancestor value when multiple ancestors set the same option", + parentHasTokenOption: true, + argv: ["--token", "root-token", "gateway", "--token", "gateway-token", "run"], + expected: "gateway-token", + }, + ])("$label", async ({ parentHasTokenOption, argv, expected }) => { const program = new Command().option("--token ", "Root token"); - const gateway = program.command("gateway"); - let inherited: string | undefined; + const gateway = parentHasTokenOption + ? program.command("gateway").option("--token ", "Gateway token") + : program.command("gateway"); + const getInherited = attachRunCommandAndCaptureInheritedToken(gateway); - gateway - .command("run") - .option("--token ", "Run token") - .action((_opts, command) => { - inherited = inheritOptionFromParent(command, "token"); - }); - - await program.parseAsync(["--token", "root-token", "gateway", "run"], { from: "user" }); - expect(inherited).toBe("root-token"); - }); - - it("prefers nearest ancestor value when multiple ancestors set the same option", async () => { - const program = new Command().option("--token ", "Root token"); - const gateway = program.command("gateway").option("--token ", "Gateway token"); - let inherited: string | undefined; - - gateway - .command("run") - .option("--token ", "Run token") - .action((_opts, command) => { - inherited = inheritOptionFromParent(command, "token"); - }); - - await program.parseAsync( - ["--token", "root-token", "gateway", "--token", "gateway-token", "run"], - { from: "user" }, - ); - expect(inherited).toBe("gateway-token"); + await program.parseAsync(argv, { from: "user" }); + expect(getInherited()).toBe(expected); }); it("does not inherit when the child option was set explicitly", async () => { @@ -54,18 +54,11 @@ describe("inheritOptionFromParent", () => { const program = new Command().option("--token ", "Root token"); const level1 = program.command("level1"); const level2 = level1.command("level2"); - let inherited: string | undefined; - - level2 - .command("run") - .option("--token ", "Run token") - .action((_opts, command) => { - inherited = inheritOptionFromParent(command, "token"); - }); + const getInherited = attachRunCommandAndCaptureInheritedToken(level2); await program.parseAsync(["--token", "root-token", "level1", "level2", "run"], { from: "user", }); - expect(inherited).toBeUndefined(); + expect(getInherited()).toBeUndefined(); }); }); diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 86bc0b01c6..2635ffe154 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -63,18 +63,24 @@ function resetGatewayMock() { callGatewayFromCli.mockImplementation(defaultGatewayMock); } -async function runCronEditAndGetPatch(editArgs: string[]): Promise { +async function runCronCommand(args: string[]): Promise { resetGatewayMock(); const program = buildProgram(); - await program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" }); + await program.parseAsync(args, { from: "user" }); +} + +async function expectCronCommandExit(args: string[]): Promise { + await expect(runCronCommand(args)).rejects.toThrow("__exit__:1"); +} + +async function runCronEditAndGetPatch(editArgs: string[]): Promise { + await runCronCommand(["cron", "edit", "job-1", ...editArgs]); const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); return (updateCall?.[2] ?? {}) as CronUpdatePatch; } async function runCronAddAndGetParams(addArgs: string[]): Promise { - resetGatewayMock(); - const program = buildProgram(); - await program.parseAsync(["cron", "add", ...addArgs], { from: "user" }); + await runCronCommand(["cron", "add", ...addArgs]); const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add"); return (addCall?.[2] ?? {}) as CronAddParams; } @@ -82,9 +88,7 @@ async function runCronAddAndGetParams(addArgs: string[]): Promise async function runCronSimpleAndGetUpdatePatch( command: "enable" | "disable", ): Promise<{ enabled?: boolean }> { - resetGatewayMock(); - const program = buildProgram(); - await program.parseAsync(["cron", command, "job-1"], { from: "user" }); + await runCronCommand(["cron", command, "job-1"]); const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); return ((updateCall?.[2] as { patch?: { enabled?: boolean } } | undefined)?.patch ?? {}) as { enabled?: boolean; @@ -109,31 +113,52 @@ function mockCronEditJobLookup(schedule: unknown): void { ); } +function getGatewayCallParams(method: string): T { + const call = callGatewayFromCli.mock.calls.find((entry) => entry[0] === method); + return (call?.[2] ?? {}) as T; +} + +async function runCronEditWithScheduleLookup( + schedule: unknown, + editArgs: string[], +): Promise { + resetGatewayMock(); + mockCronEditJobLookup(schedule); + const program = buildProgram(); + await program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" }); + return getGatewayCallParams("cron.update"); +} + +async function expectCronEditWithScheduleLookupExit( + schedule: unknown, + editArgs: string[], +): Promise { + resetGatewayMock(); + mockCronEditJobLookup(schedule); + const program = buildProgram(); + await expect( + program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" }), + ).rejects.toThrow("__exit__:1"); +} + describe("cron cli", () => { it("trims model and thinking on cron add", { timeout: 60_000 }, async () => { - resetGatewayMock(); - - const program = buildProgram(); - - await program.parseAsync( - [ - "cron", - "add", - "--name", - "Daily", - "--cron", - "* * * * *", - "--session", - "isolated", - "--message", - "hello", - "--model", - " opus ", - "--thinking", - " low ", - ], - { from: "user" }, - ); + await runCronCommand([ + "cron", + "add", + "--name", + "Daily", + "--cron", + "* * * * *", + "--session", + "isolated", + "--message", + "hello", + "--model", + " opus ", + "--thinking", + " low ", + ]); const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add"); const params = addCall?.[2] as { @@ -145,25 +170,18 @@ describe("cron cli", () => { }); it("defaults isolated cron add to announce delivery", async () => { - resetGatewayMock(); - - const program = buildProgram(); - - await program.parseAsync( - [ - "cron", - "add", - "--name", - "Daily", - "--cron", - "* * * * *", - "--session", - "isolated", - "--message", - "hello", - ], - { from: "user" }, - ); + await runCronCommand([ + "cron", + "add", + "--name", + "Daily", + "--cron", + "* * * * *", + "--session", + "isolated", + "--message", + "hello", + ]); const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add"); const params = addCall?.[2] as { delivery?: { mode?: string } }; @@ -172,26 +190,32 @@ describe("cron cli", () => { }); it("infers sessionTarget from payload when --session is omitted", async () => { - resetGatewayMock(); - - const program = buildProgram(); - - await program.parseAsync( - ["cron", "add", "--name", "Main reminder", "--cron", "* * * * *", "--system-event", "hi"], - { from: "user" }, - ); + await runCronCommand([ + "cron", + "add", + "--name", + "Main reminder", + "--cron", + "* * * * *", + "--system-event", + "hi", + ]); let addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add"); let params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } }; expect(params?.sessionTarget).toBe("main"); expect(params?.payload?.kind).toBe("systemEvent"); - resetGatewayMock(); - - await program.parseAsync( - ["cron", "add", "--name", "Isolated task", "--cron", "* * * * *", "--message", "hello"], - { from: "user" }, - ); + await runCronCommand([ + "cron", + "add", + "--name", + "Isolated task", + "--cron", + "* * * * *", + "--message", + "hello", + ]); addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add"); params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } }; @@ -200,133 +224,90 @@ describe("cron cli", () => { }); it("supports --keep-after-run on cron add", async () => { - resetGatewayMock(); - - const program = buildProgram(); - - await program.parseAsync( - [ - "cron", - "add", - "--name", - "Keep me", - "--at", - "20m", - "--session", - "main", - "--system-event", - "hello", - "--keep-after-run", - ], - { from: "user" }, - ); + await runCronCommand([ + "cron", + "add", + "--name", + "Keep me", + "--at", + "20m", + "--session", + "main", + "--system-event", + "hello", + "--keep-after-run", + ]); const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add"); const params = addCall?.[2] as { deleteAfterRun?: boolean }; expect(params?.deleteAfterRun).toBe(false); }); - it("cron enable sets enabled=true patch", async () => { - const patch = await runCronSimpleAndGetUpdatePatch("enable"); - expect(patch.enabled).toBe(true); - }); - - it("cron disable sets enabled=false patch", async () => { - const patch = await runCronSimpleAndGetUpdatePatch("disable"); - expect(patch.enabled).toBe(false); + it.each([ + { command: "enable" as const, expectedEnabled: true }, + { command: "disable" as const, expectedEnabled: false }, + ])("cron $command sets enabled=$expectedEnabled patch", async ({ command, expectedEnabled }) => { + const patch = await runCronSimpleAndGetUpdatePatch(command); + expect(patch.enabled).toBe(expectedEnabled); }); it("sends agent id on cron add", async () => { - resetGatewayMock(); - - const program = buildProgram(); - - await program.parseAsync( - [ - "cron", - "add", - "--name", - "Agent pinned", - "--cron", - "* * * * *", - "--session", - "isolated", - "--message", - "hi", - "--agent", - "ops", - ], - { from: "user" }, - ); + await runCronCommand([ + "cron", + "add", + "--name", + "Agent pinned", + "--cron", + "* * * * *", + "--session", + "isolated", + "--message", + "hi", + "--agent", + "ops", + ]); const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add"); const params = addCall?.[2] as { agentId?: string }; expect(params?.agentId).toBe("ops"); }); - it("omits empty model and thinking on cron edit", async () => { - const patch = await runCronEditAndGetPatch([ - "--message", - "hello", - "--model", - " ", - "--thinking", - " ", - ]); - - expect(patch?.patch?.payload?.model).toBeUndefined(); - expect(patch?.patch?.payload?.thinking).toBeUndefined(); - }); - - it("trims model and thinking on cron edit", async () => { - const patch = await runCronEditAndGetPatch([ - "--message", - "hello", - "--model", - " opus ", - "--thinking", - " high ", - ]); - - expect(patch?.patch?.payload?.model).toBe("opus"); - expect(patch?.patch?.payload?.thinking).toBe("high"); + it.each([ + { + label: "omits empty model and thinking", + args: ["--message", "hello", "--model", " ", "--thinking", " "], + expectedModel: undefined, + expectedThinking: undefined, + }, + { + label: "trims model and thinking", + args: ["--message", "hello", "--model", " opus ", "--thinking", " high "], + expectedModel: "opus", + expectedThinking: "high", + }, + ])("cron edit $label", async ({ args, expectedModel, expectedThinking }) => { + const patch = await runCronEditAndGetPatch(args); + expect(patch?.patch?.payload?.model).toBe(expectedModel); + expect(patch?.patch?.payload?.thinking).toBe(expectedThinking); }); it("sets and clears agent id on cron edit", async () => { - resetGatewayMock(); + await runCronCommand(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"]); - const program = buildProgram(); - - await program.parseAsync(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"], { - from: "user", - }); - - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { patch?: { agentId?: unknown } }; + const patch = getGatewayCallParams<{ patch?: { agentId?: unknown } }>("cron.update"); expect(patch?.patch?.agentId).toBe("ops"); - resetGatewayMock(); - await program.parseAsync(["cron", "edit", "job-2", "--clear-agent"], { - from: "user", - }); - const clearCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const clearPatch = clearCall?.[2] as { patch?: { agentId?: unknown } }; + await runCronCommand(["cron", "edit", "job-2", "--clear-agent"]); + const clearPatch = getGatewayCallParams<{ patch?: { agentId?: unknown } }>("cron.update"); expect(clearPatch?.patch?.agentId).toBeNull(); }); it("allows model/thinking updates without --message", async () => { - resetGatewayMock(); + await runCronCommand(["cron", "edit", "job-1", "--model", "opus", "--thinking", "low"]); - const program = buildProgram(); - - await program.parseAsync(["cron", "edit", "job-1", "--model", "opus", "--thinking", "low"], { - from: "user", - }); - - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { + const patch = getGatewayCallParams<{ patch?: { payload?: { kind?: string; model?: string; thinking?: string } }; - }; + }>("cron.update"); expect(patch?.patch?.payload?.kind).toBe("agentTurn"); expect(patch?.patch?.payload?.model).toBe("opus"); @@ -334,22 +315,23 @@ describe("cron cli", () => { }); it("updates delivery settings without requiring --message", async () => { - resetGatewayMock(); + await runCronCommand([ + "cron", + "edit", + "job-1", + "--deliver", + "--channel", + "telegram", + "--to", + "19098680", + ]); - const program = buildProgram(); - - await program.parseAsync( - ["cron", "edit", "job-1", "--deliver", "--channel", "telegram", "--to", "19098680"], - { from: "user" }, - ); - - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { + const patch = getGatewayCallParams<{ patch?: { payload?: { kind?: string; message?: string }; delivery?: { mode?: string; channel?: string; to?: string }; }; - }; + }>("cron.update"); expect(patch?.patch?.payload?.kind).toBe("agentTurn"); expect(patch?.patch?.delivery?.mode).toBe("announce"); @@ -359,33 +341,21 @@ describe("cron cli", () => { }); it("supports --no-deliver on cron edit", async () => { - resetGatewayMock(); + await runCronCommand(["cron", "edit", "job-1", "--no-deliver"]); - const program = buildProgram(); - - await program.parseAsync(["cron", "edit", "job-1", "--no-deliver"], { from: "user" }); - - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { + const patch = getGatewayCallParams<{ patch?: { payload?: { kind?: string }; delivery?: { mode?: string } }; - }; + }>("cron.update"); expect(patch?.patch?.payload?.kind).toBe("agentTurn"); expect(patch?.patch?.delivery?.mode).toBe("none"); }); it("does not include undefined delivery fields when updating message", async () => { - resetGatewayMock(); - - const program = buildProgram(); - // Update message without delivery flags - should NOT include undefined delivery fields - await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], { - from: "user", - }); + await runCronCommand(["cron", "edit", "job-1", "--message", "Updated message"]); - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { + const patch = getGatewayCallParams<{ patch?: { payload?: { message?: string; @@ -396,7 +366,7 @@ describe("cron cli", () => { }; delivery?: unknown; }; - }; + }>("cron.update"); // Should include the new message expect(patch?.patch?.payload?.message).toBe("Updated message"); @@ -427,28 +397,14 @@ describe("cron cli", () => { expect(patch?.patch?.delivery?.to).toBe("19098680"); }); - it("includes best-effort delivery when provided with message", async () => { - const patch = await runCronEditAndGetPatch([ - "--message", - "Updated message", - "--best-effort-deliver", - ]); - + it.each([ + { flag: "--best-effort-deliver", expectedBestEffort: true }, + { flag: "--no-best-effort-deliver", expectedBestEffort: false }, + ])("applies $flag on cron edit message updates", async ({ flag, expectedBestEffort }) => { + const patch = await runCronEditAndGetPatch(["--message", "Updated message", flag]); expect(patch?.patch?.payload?.message).toBe("Updated message"); expect(patch?.patch?.delivery?.mode).toBe("announce"); - expect(patch?.patch?.delivery?.bestEffort).toBe(true); - }); - - it("includes no-best-effort delivery when provided with message", async () => { - const patch = await runCronEditAndGetPatch([ - "--message", - "Updated message", - "--no-best-effort-deliver", - ]); - - expect(patch?.patch?.payload?.message).toBe("Updated message"); - expect(patch?.patch?.delivery?.mode).toBe("announce"); - expect(patch?.patch?.delivery?.bestEffort).toBe(false); + expect(patch?.patch?.delivery?.bestEffort).toBe(expectedBestEffort); }); it("sets explicit stagger for cron add", async () => { @@ -485,83 +441,55 @@ describe("cron cli", () => { }); it("rejects --stagger with --exact on add", async () => { - resetGatewayMock(); - const program = buildProgram(); - - await expect( - program.parseAsync( - [ - "cron", - "add", - "--name", - "invalid", - "--cron", - "0 * * * *", - "--stagger", - "1m", - "--exact", - "--session", - "main", - "--system-event", - "tick", - ], - { from: "user" }, - ), - ).rejects.toThrow("__exit__:1"); + await expectCronCommandExit([ + "cron", + "add", + "--name", + "invalid", + "--cron", + "0 * * * *", + "--stagger", + "1m", + "--exact", + "--session", + "main", + "--system-event", + "tick", + ]); }); it("rejects --stagger when schedule is not cron", async () => { - resetGatewayMock(); - const program = buildProgram(); - - await expect( - program.parseAsync( - [ - "cron", - "add", - "--name", - "invalid", - "--every", - "10m", - "--stagger", - "30s", - "--session", - "main", - "--system-event", - "tick", - ], - { from: "user" }, - ), - ).rejects.toThrow("__exit__:1"); + await expectCronCommandExit([ + "cron", + "add", + "--name", + "invalid", + "--every", + "10m", + "--stagger", + "30s", + "--session", + "main", + "--system-event", + "tick", + ]); }); it("sets explicit stagger for cron edit", async () => { - resetGatewayMock(); - const program = buildProgram(); + await runCronCommand(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"]); - await program.parseAsync(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"], { - from: "user", - }); - - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { + const patch = getGatewayCallParams<{ patch?: { schedule?: { kind?: string; staggerMs?: number } }; - }; + }>("cron.update"); expect(patch?.patch?.schedule?.kind).toBe("cron"); expect(patch?.patch?.schedule?.staggerMs).toBe(30_000); }); it("applies --exact to existing cron job without requiring --cron on edit", async () => { - resetGatewayMock(); - mockCronEditJobLookup({ kind: "cron", expr: "0 */2 * * *", tz: "UTC", staggerMs: 300_000 }); - const program = buildProgram(); - - await program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" }); - - const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update"); - const patch = updateCall?.[2] as { - patch?: { schedule?: { kind?: string; expr?: string; tz?: string; staggerMs?: number } }; - }; + const patch = await runCronEditWithScheduleLookup( + { kind: "cron", expr: "0 */2 * * *", tz: "UTC", staggerMs: 300_000 }, + ["--exact"], + ); expect(patch?.patch?.schedule).toEqual({ kind: "cron", expr: "0 */2 * * *", @@ -571,12 +499,6 @@ describe("cron cli", () => { }); it("rejects --exact on edit when existing job is not cron", async () => { - resetGatewayMock(); - mockCronEditJobLookup({ kind: "every", everyMs: 60_000 }); - const program = buildProgram(); - - await expect( - program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" }), - ).rejects.toThrow("__exit__:1"); + await expectCronEditWithScheduleLookupExit({ kind: "every", everyMs: 60_000 }, ["--exact"]); }); }); diff --git a/src/cli/daemon-cli.coverage.e2e.test.ts b/src/cli/daemon-cli.coverage.e2e.test.ts index 83d1bf4249..63caad7596 100644 --- a/src/cli/daemon-cli.coverage.e2e.test.ts +++ b/src/cli/daemon-cli.coverage.e2e.test.ts @@ -72,6 +72,25 @@ vi.mock("./progress.js", () => ({ withProgress: async (_opts: unknown, fn: () => Promise) => await fn(), })); +const { registerDaemonCli } = await import("./daemon-cli.js"); + +function createDaemonProgram() { + const program = new Command(); + program.exitOverride(); + registerDaemonCli(program); + return program; +} + +async function runDaemonCommand(args: string[]) { + const program = createDaemonProgram(); + await program.parseAsync(args, { from: "user" }); +} + +function parseFirstJsonRuntimeLine() { + const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); + return JSON.parse(jsonLine ?? "{}") as T; +} + describe("daemon-cli coverage", () => { const originalEnv = { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, @@ -118,12 +137,7 @@ describe("daemon-cli coverage", () => { resetRuntimeCapture(); callGateway.mockClear(); - const { registerDaemonCli } = await import("./daemon-cli.js"); - const program = new Command(); - program.exitOverride(); - registerDaemonCli(program); - - await program.parseAsync(["daemon", "status"], { from: "user" }); + await runDaemonCommand(["daemon", "status"]); expect(callGateway).toHaveBeenCalledTimes(1); expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "status" })); @@ -147,12 +161,7 @@ describe("daemon-cli coverage", () => { sourcePath: "/tmp/bot.molt.gateway.plist", }); - const { registerDaemonCli } = await import("./daemon-cli.js"); - const program = new Command(); - program.exitOverride(); - registerDaemonCli(program); - - await program.parseAsync(["daemon", "status", "--json"], { from: "user" }); + await runDaemonCommand(["daemon", "status", "--json"]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ @@ -162,12 +171,11 @@ describe("daemon-cli coverage", () => { ); expect(inspectPortUsage).toHaveBeenCalledWith(19001); - const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); - const parsed = JSON.parse(jsonLine ?? "{}") as { + const parsed = parseFirstJsonRuntimeLine<{ gateway?: { port?: number; portSource?: string; probeUrl?: string }; config?: { mismatch?: boolean }; rpc?: { url?: string; ok?: boolean }; - }; + }>(); expect(parsed.gateway?.port).toBe(19001); expect(parsed.gateway?.portSource).toBe("service args"); expect(parsed.gateway?.probeUrl).toBe("ws://127.0.0.1:19001"); @@ -179,12 +187,7 @@ describe("daemon-cli coverage", () => { it("passes deep scan flag for daemon status", async () => { findExtraGatewayServices.mockClear(); - const { registerDaemonCli } = await import("./daemon-cli.js"); - const program = new Command(); - program.exitOverride(); - registerDaemonCli(program); - - await program.parseAsync(["daemon", "status", "--deep"], { from: "user" }); + await runDaemonCommand(["daemon", "status", "--deep"]); expect(findExtraGatewayServices).toHaveBeenCalledWith( expect.anything(), @@ -192,81 +195,53 @@ describe("daemon-cli coverage", () => { ); }); - it("installs the daemon when requested", async () => { - serviceIsLoaded.mockResolvedValueOnce(false); - serviceInstall.mockClear(); - - const { registerDaemonCli } = await import("./daemon-cli.js"); - const program = new Command(); - program.exitOverride(); - registerDaemonCli(program); - - await program.parseAsync(["daemon", "install", "--port", "18789"], { - from: "user", - }); - - expect(serviceInstall).toHaveBeenCalledTimes(1); - }); - - it("installs the daemon with json output", async () => { + it.each([ + { label: "plain output", includeJsonFlag: false }, + { label: "json output", includeJsonFlag: true }, + ])("installs the daemon ($label)", async ({ includeJsonFlag }) => { resetRuntimeCapture(); serviceIsLoaded.mockResolvedValueOnce(false); serviceInstall.mockClear(); - const { registerDaemonCli } = await import("./daemon-cli.js"); - const program = new Command(); - program.exitOverride(); - registerDaemonCli(program); + const args = includeJsonFlag + ? ["daemon", "install", "--port", "18789", "--json"] + : ["daemon", "install", "--port", "18789"]; + await runDaemonCommand(args); - await program.parseAsync(["daemon", "install", "--port", "18789", "--json"], { - from: "user", - }); - - const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); - const parsed = JSON.parse(jsonLine ?? "{}") as { - ok?: boolean; - action?: string; - result?: string; - }; - expect(parsed.ok).toBe(true); - expect(parsed.action).toBe("install"); - expect(parsed.result).toBe("installed"); + expect(serviceInstall).toHaveBeenCalledTimes(1); + if (includeJsonFlag) { + const parsed = parseFirstJsonRuntimeLine<{ + ok?: boolean; + action?: string; + result?: string; + }>(); + expect(parsed.ok).toBe(true); + expect(parsed.action).toBe("install"); + expect(parsed.result).toBe("installed"); + } }); - it("starts and stops the daemon via service helpers", async () => { + it.each([ + { label: "plain output", includeJsonFlag: false }, + { label: "json output", includeJsonFlag: true }, + ])("starts and stops daemon ($label)", async ({ includeJsonFlag }) => { + resetRuntimeCapture(); serviceRestart.mockClear(); serviceStop.mockClear(); serviceIsLoaded.mockResolvedValue(true); - const { registerDaemonCli } = await import("./daemon-cli.js"); - const program = new Command(); - program.exitOverride(); - registerDaemonCli(program); - - await program.parseAsync(["daemon", "start"], { from: "user" }); - await program.parseAsync(["daemon", "stop"], { from: "user" }); + const startArgs = includeJsonFlag ? ["daemon", "start", "--json"] : ["daemon", "start"]; + const stopArgs = includeJsonFlag ? ["daemon", "stop", "--json"] : ["daemon", "stop"]; + await runDaemonCommand(startArgs); + await runDaemonCommand(stopArgs); expect(serviceRestart).toHaveBeenCalledTimes(1); expect(serviceStop).toHaveBeenCalledTimes(1); - }); - - it("emits json for daemon start/stop", async () => { - resetRuntimeCapture(); - serviceRestart.mockClear(); - serviceStop.mockClear(); - serviceIsLoaded.mockResolvedValue(true); - - const { registerDaemonCli } = await import("./daemon-cli.js"); - const program = new Command(); - program.exitOverride(); - registerDaemonCli(program); - - await program.parseAsync(["daemon", "start", "--json"], { from: "user" }); - await program.parseAsync(["daemon", "stop", "--json"], { from: "user" }); - - const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); - const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); - expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true); - expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true); + if (includeJsonFlag) { + const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); + const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); + expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true); + expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true); + } }); }); diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 3f13e2b253..7d0214e768 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const loadConfig = vi.fn(() => ({ gateway: { @@ -38,7 +38,13 @@ vi.mock("../../runtime.js", () => ({ defaultRuntime, })); +let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart; + describe("runServiceRestart token drift", () => { + beforeAll(async () => { + ({ runServiceRestart } = await import("./lifecycle-core.js")); + }); + beforeEach(() => { runtimeLogs.length = 0; loadConfig.mockClear(); @@ -56,8 +62,6 @@ describe("runServiceRestart token drift", () => { }); it("emits drift warning when enabled", async () => { - const { runServiceRestart } = await import("./lifecycle-core.js"); - await runServiceRestart({ serviceNoun: "Gateway", service, @@ -73,8 +77,6 @@ describe("runServiceRestart token drift", () => { }); it("skips drift warning when disabled", async () => { - const { runServiceRestart } = await import("./lifecycle-core.js"); - await runServiceRestart({ serviceNoun: "Node", service, diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index 334c50a906..7c6ac6a7ee 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const callGateway = vi.fn(); const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise) => await fn()); @@ -21,29 +21,23 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: runtime, })); +let registerDevicesCli: typeof import("./devices-cli.js").registerDevicesCli; + +beforeAll(async () => { + ({ registerDevicesCli } = await import("./devices-cli.js")); +}); + async function runDevicesApprove(argv: string[]) { - const { registerDevicesCli } = await import("./devices-cli.js"); - const program = new Command(); - registerDevicesCli(program); - await program.parseAsync(["devices", "approve", ...argv], { from: "user" }); + await runDevicesCommand(["approve", ...argv]); } async function runDevicesCommand(argv: string[]) { - const { registerDevicesCli } = await import("./devices-cli.js"); const program = new Command(); registerDevicesCli(program); await program.parseAsync(["devices", ...argv], { from: "user" }); } describe("devices cli approve", () => { - afterEach(() => { - callGateway.mockReset(); - withProgress.mockClear(); - runtime.log.mockReset(); - runtime.error.mockReset(); - runtime.exit.mockReset(); - }); - it("approves an explicit request id without listing", async () => { callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } }); @@ -58,17 +52,33 @@ describe("devices cli approve", () => { ); }); - it("auto-approves the latest pending request when id is omitted", async () => { + it.each([ + { + name: "id is omitted", + args: [] as string[], + pending: [ + { requestId: "req-1", ts: 1000 }, + { requestId: "req-2", ts: 2000 }, + ], + expectedRequestId: "req-2", + }, + { + name: "--latest is passed", + args: ["req-old", "--latest"] as string[], + pending: [ + { requestId: "req-2", ts: 2000 }, + { requestId: "req-3", ts: 3000 }, + ], + expectedRequestId: "req-3", + }, + ])("uses latest pending request when $name", async ({ args, pending, expectedRequestId }) => { callGateway .mockResolvedValueOnce({ - pending: [ - { requestId: "req-1", ts: 1000 }, - { requestId: "req-2", ts: 2000 }, - ], + pending, }) .mockResolvedValueOnce({ device: { deviceId: "device-2" } }); - await runDevicesApprove([]); + await runDevicesApprove(args); expect(callGateway).toHaveBeenNthCalledWith( 1, @@ -78,28 +88,7 @@ describe("devices cli approve", () => { 2, expect.objectContaining({ method: "device.pair.approve", - params: { requestId: "req-2" }, - }), - ); - }); - - it("uses latest pending request when --latest is passed", async () => { - callGateway - .mockResolvedValueOnce({ - pending: [ - { requestId: "req-2", ts: 2000 }, - { requestId: "req-3", ts: 3000 }, - ], - }) - .mockResolvedValueOnce({ device: { deviceId: "device-3" } }); - - await runDevicesApprove(["req-old", "--latest"]); - - expect(callGateway).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - method: "device.pair.approve", - params: { requestId: "req-3" }, + params: { requestId: expectedRequestId }, }), ); }); @@ -122,14 +111,6 @@ describe("devices cli approve", () => { }); describe("devices cli remove", () => { - afterEach(() => { - callGateway.mockReset(); - withProgress.mockClear(); - runtime.log.mockReset(); - runtime.error.mockReset(); - runtime.exit.mockReset(); - }); - it("removes a paired device by id", async () => { callGateway.mockResolvedValueOnce({ deviceId: "device-1" }); @@ -146,14 +127,6 @@ describe("devices cli remove", () => { }); describe("devices cli clear", () => { - afterEach(() => { - callGateway.mockReset(); - withProgress.mockClear(); - runtime.log.mockReset(); - runtime.error.mockReset(); - runtime.exit.mockReset(); - }); - it("requires --yes before clearing", async () => { await runDevicesCommand(["clear"]); @@ -194,55 +167,44 @@ describe("devices cli clear", () => { }); describe("devices cli tokens", () => { - afterEach(() => { - callGateway.mockReset(); - withProgress.mockClear(); - runtime.log.mockReset(); - runtime.error.mockReset(); - runtime.exit.mockReset(); - }); - - it("rotates a token for a device role", async () => { - callGateway.mockResolvedValueOnce({ ok: true }); - - await runDevicesCommand([ - "rotate", - "--device", - "device-1", - "--role", - "main", - "--scope", - "messages:send", - "--scope", - "messages:read", - ]); - - expect(callGateway).toHaveBeenCalledWith( - expect.objectContaining({ + it.each([ + { + label: "rotates a token for a device role", + argv: [ + "rotate", + "--device", + "device-1", + "--role", + "main", + "--scope", + "messages:send", + "--scope", + "messages:read", + ], + expectedCall: { method: "device.token.rotate", params: { deviceId: "device-1", role: "main", scopes: ["messages:send", "messages:read"], }, - }), - ); - }); - - it("revokes a token for a device role", async () => { - callGateway.mockResolvedValueOnce({ ok: true }); - - await runDevicesCommand(["revoke", "--device", "device-1", "--role", "main"]); - - expect(callGateway).toHaveBeenCalledWith( - expect.objectContaining({ + }, + }, + { + label: "revokes a token for a device role", + argv: ["revoke", "--device", "device-1", "--role", "main"], + expectedCall: { method: "device.token.revoke", params: { deviceId: "device-1", role: "main", }, - }), - ); + }, + }, + ])("$label", async ({ argv, expectedCall }) => { + callGateway.mockResolvedValueOnce({ ok: true }); + await runDevicesCommand(argv); + expect(callGateway).toHaveBeenCalledWith(expect.objectContaining(expectedCall)); }); it("rejects blank device or role values", async () => { @@ -253,3 +215,11 @@ describe("devices cli tokens", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); }); + +afterEach(() => { + callGateway.mockReset(); + withProgress.mockClear(); + runtime.log.mockReset(); + runtime.error.mockReset(); + runtime.exit.mockReset(); +}); diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index 73d511da05..c11deec8bd 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createCliRuntimeCapture } from "./test-runtime-capture.js"; const callGatewayFromCli = vi.fn(async (method: string, _opts: unknown, params?: unknown) => { @@ -67,27 +67,31 @@ describe("exec approvals CLI", () => { return program; }; - it("routes get command to local, gateway, and node modes", async () => { + const runApprovalsCommand = async (args: string[]) => { + const program = createProgram(); + await program.parseAsync(args, { from: "user" }); + }; + + beforeEach(() => { resetLocalSnapshot(); resetRuntimeCapture(); callGatewayFromCli.mockClear(); + }); - const localProgram = createProgram(); - await localProgram.parseAsync(["approvals", "get"], { from: "user" }); + it("routes get command to local, gateway, and node modes", async () => { + await runApprovalsCommand(["approvals", "get"]); expect(callGatewayFromCli).not.toHaveBeenCalled(); expect(runtimeErrors).toHaveLength(0); callGatewayFromCli.mockClear(); - const gatewayProgram = createProgram(); - await gatewayProgram.parseAsync(["approvals", "get", "--gateway"], { from: "user" }); + await runApprovalsCommand(["approvals", "get", "--gateway"]); expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {}); expect(runtimeErrors).toHaveLength(0); callGatewayFromCli.mockClear(); - const nodeProgram = createProgram(); - await nodeProgram.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" }); + await runApprovalsCommand(["approvals", "get", "--node", "macbook"]); expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), { nodeId: "node-1", @@ -96,18 +100,10 @@ describe("exec approvals CLI", () => { }); it("defaults allowlist add to wildcard agent", async () => { - resetLocalSnapshot(); - resetRuntimeCapture(); - callGatewayFromCli.mockClear(); - const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals); saveExecApprovals.mockClear(); - const program = new Command(); - program.exitOverride(); - registerExecApprovalsCli(program); - - await program.parseAsync(["approvals", "allowlist", "add", "/usr/bin/uname"], { from: "user" }); + await runApprovalsCommand(["approvals", "allowlist", "add", "/usr/bin/uname"]); expect(callGatewayFromCli).not.toHaveBeenCalledWith( "exec.approvals.set", @@ -124,7 +120,6 @@ describe("exec approvals CLI", () => { }); it("removes wildcard allowlist entry and prunes empty agent", async () => { - resetLocalSnapshot(); localSnapshot.file = { version: 1, agents: { @@ -133,16 +128,11 @@ describe("exec approvals CLI", () => { }, }, }; - resetRuntimeCapture(); - callGatewayFromCli.mockClear(); const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals); saveExecApprovals.mockClear(); - const program = createProgram(); - await program.parseAsync(["approvals", "allowlist", "remove", "/usr/bin/uname"], { - from: "user", - }); + await runApprovalsCommand(["approvals", "allowlist", "remove", "/usr/bin/uname"]); expect(saveExecApprovals).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/cli/gateway-cli.coverage.e2e.test.ts b/src/cli/gateway-cli.coverage.e2e.test.ts index 3466da7ea7..b1bba73376 100644 --- a/src/cli/gateway-cli.coverage.e2e.test.ts +++ b/src/cli/gateway-cli.coverage.e2e.test.ts @@ -85,19 +85,30 @@ vi.mock("../commands/gateway-status.js", () => ({ gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts), })); +const { registerGatewayCli } = await import("./gateway-cli.js"); + +function createGatewayProgram() { + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + return program; +} + +async function runGatewayCommand(args: string[]) { + const program = createGatewayProgram(); + await program.parseAsync(args, { from: "user" }); +} + +async function expectGatewayExit(args: string[]) { + await expect(runGatewayCommand(args)).rejects.toThrow("__exit__:1"); +} + describe("gateway-cli coverage", () => { it("registers call/health commands and routes to callGateway", async () => { resetRuntimeCapture(); callGateway.mockClear(); - const { registerGatewayCli } = await import("./gateway-cli.js"); - const program = new Command(); - program.exitOverride(); - registerGatewayCli(program); - - await program.parseAsync(["gateway", "call", "health", "--params", '{"x":1}', "--json"], { - from: "user", - }); + await runGatewayCommand(["gateway", "call", "health", "--params", '{"x":1}', "--json"]); expect(callGateway).toHaveBeenCalledTimes(1); expect(runtimeLogs.join("\n")).toContain('"ok": true'); @@ -107,48 +118,30 @@ describe("gateway-cli coverage", () => { resetRuntimeCapture(); gatewayStatusCommand.mockClear(); - const { registerGatewayCli } = await import("./gateway-cli.js"); - const program = new Command(); - program.exitOverride(); - registerGatewayCli(program); - - await program.parseAsync(["gateway", "probe", "--json"], { from: "user" }); + await runGatewayCommand(["gateway", "probe", "--json"]); expect(gatewayStatusCommand).toHaveBeenCalledTimes(1); }, 60_000); - it("registers gateway discover and prints JSON", async () => { - resetRuntimeCapture(); - discoverGatewayBeacons.mockReset(); - discoverGatewayBeacons.mockResolvedValueOnce([ - { - instanceName: "Studio (OpenClaw)", - displayName: "Studio", - domain: "local.", - host: "studio.local", - lanHost: "studio.local", - tailnetDns: "studio.tailnet.ts.net", - gatewayPort: 18789, - sshPort: 22, - }, - ]); - - const { registerGatewayCli } = await import("./gateway-cli.js"); - const program = new Command(); - program.exitOverride(); - registerGatewayCli(program); - - await program.parseAsync(["gateway", "discover", "--json"], { - from: "user", - }); - - expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1); - expect(runtimeLogs.join("\n")).toContain('"beacons"'); - expect(runtimeLogs.join("\n")).toContain('"wsUrl"'); - expect(runtimeLogs.join("\n")).toContain("ws://"); - }); - - it("registers gateway discover and prints human output with details on new lines", async () => { + it.each([ + { + label: "json output", + args: ["gateway", "discover", "--json"], + expectedOutput: ['"beacons"', '"wsUrl"', "ws://"], + }, + { + label: "human output", + args: ["gateway", "discover", "--timeout", "1"], + expectedOutput: [ + "Gateway Discovery", + "Found 1 gateway(s)", + "- Studio openclaw.internal.", + " tailnet: studio.tailnet.ts.net", + " host: studio.openclaw.internal", + " ws: ws://studio.openclaw.internal:18789", + ], + }, + ])("registers gateway discover and prints $label", async ({ args, expectedOutput }) => { resetRuntimeCapture(); discoverGatewayBeacons.mockReset(); discoverGatewayBeacons.mockResolvedValueOnce([ @@ -164,38 +157,19 @@ describe("gateway-cli coverage", () => { }, ]); - const { registerGatewayCli } = await import("./gateway-cli.js"); - const program = new Command(); - program.exitOverride(); - registerGatewayCli(program); - - await program.parseAsync(["gateway", "discover", "--timeout", "1"], { - from: "user", - }); + await runGatewayCommand(args); + expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1); const out = runtimeLogs.join("\n"); - expect(out).toContain("Gateway Discovery"); - expect(out).toContain("Found 1 gateway(s)"); - expect(out).toContain("- Studio openclaw.internal."); - expect(out).toContain(" tailnet: studio.tailnet.ts.net"); - expect(out).toContain(" host: studio.openclaw.internal"); - expect(out).toContain(" ws: ws://studio.openclaw.internal:18789"); + for (const text of expectedOutput) { + expect(out).toContain(text); + } }); it("validates gateway discover timeout", async () => { resetRuntimeCapture(); discoverGatewayBeacons.mockReset(); - - const { registerGatewayCli } = await import("./gateway-cli.js"); - const program = new Command(); - program.exitOverride(); - registerGatewayCli(program); - - await expect( - program.parseAsync(["gateway", "discover", "--timeout", "0"], { - from: "user", - }), - ).rejects.toThrow("__exit__:1"); + await expectGatewayExit(["gateway", "discover", "--timeout", "0"]); expect(runtimeErrors.join("\n")).toContain("gateway discover failed:"); expect(discoverGatewayBeacons).not.toHaveBeenCalled(); @@ -204,15 +178,7 @@ describe("gateway-cli coverage", () => { it("fails gateway call on invalid params JSON", async () => { resetRuntimeCapture(); callGateway.mockClear(); - - const { registerGatewayCli } = await import("./gateway-cli.js"); - const program = new Command(); - program.exitOverride(); - registerGatewayCli(program); - - await expect( - program.parseAsync(["gateway", "call", "status", "--params", "not-json"], { from: "user" }), - ).rejects.toThrow("__exit__:1"); + await expectGatewayExit(["gateway", "call", "status", "--params", "not-json"]); expect(callGateway).not.toHaveBeenCalled(); expect(runtimeErrors.join("\n")).toContain("Gateway call failed:"); @@ -221,47 +187,35 @@ describe("gateway-cli coverage", () => { it("validates gateway ports and handles force/start errors", async () => { resetRuntimeCapture(); - const { registerGatewayCli } = await import("./gateway-cli.js"); - // Invalid port - const programInvalidPort = new Command(); - programInvalidPort.exitOverride(); - registerGatewayCli(programInvalidPort); - await expect( - programInvalidPort.parseAsync(["gateway", "--port", "0", "--token", "test-token"], { - from: "user", - }), - ).rejects.toThrow("__exit__:1"); + await expectGatewayExit(["gateway", "--port", "0", "--token", "test-token"]); // Force free failure forceFreePortAndWait.mockImplementationOnce(async () => { throw new Error("boom"); }); - const programForceFail = new Command(); - programForceFail.exitOverride(); - registerGatewayCli(programForceFail); - await expect( - programForceFail.parseAsync( - ["gateway", "--port", "18789", "--token", "test-token", "--force", "--allow-unconfigured"], - { from: "user" }, - ), - ).rejects.toThrow("__exit__:1"); + await expectGatewayExit([ + "gateway", + "--port", + "18789", + "--token", + "test-token", + "--force", + "--allow-unconfigured", + ]); // Start failure (generic) startGatewayServer.mockRejectedValueOnce(new Error("nope")); - const programStartFail = new Command(); - programStartFail.exitOverride(); - registerGatewayCli(programStartFail); const beforeSigterm = new Set(process.listeners("SIGTERM")); const beforeSigint = new Set(process.listeners("SIGINT")); - await expect( - programStartFail.parseAsync( - ["gateway", "--port", "18789", "--token", "test-token", "--allow-unconfigured"], - { - from: "user", - }, - ), - ).rejects.toThrow("__exit__:1"); + await expectGatewayExit([ + "gateway", + "--port", + "18789", + "--token", + "test-token", + "--allow-unconfigured", + ]); for (const listener of process.listeners("SIGTERM")) { if (!beforeSigterm.has(listener)) { process.removeListener("SIGTERM", listener); @@ -282,17 +236,7 @@ describe("gateway-cli coverage", () => { startGatewayServer.mockRejectedValueOnce( new GatewayLockError("another gateway instance is already listening"), ); - - const { registerGatewayCli } = await import("./gateway-cli.js"); - const program = new Command(); - program.exitOverride(); - registerGatewayCli(program); - - await expect( - program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], { - from: "user", - }), - ).rejects.toThrow("__exit__:1"); + await expectGatewayExit(["gateway", "--token", "test-token", "--allow-unconfigured"]); expect(startGatewayServer).toHaveBeenCalled(); expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:"); @@ -304,17 +248,8 @@ describe("gateway-cli coverage", () => { resetRuntimeCapture(); startGatewayServer.mockClear(); - const { registerGatewayCli } = await import("./gateway-cli.js"); - const program = new Command(); - program.exitOverride(); - registerGatewayCli(program); - startGatewayServer.mockRejectedValueOnce(new Error("nope")); - await expect( - program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], { - from: "user", - }), - ).rejects.toThrow("__exit__:1"); + await expectGatewayExit(["gateway", "--token", "test-token", "--allow-unconfigured"]); expect(startGatewayServer).toHaveBeenCalledWith(19001, expect.anything()); }); diff --git a/src/cli/gateway-cli/register.option-collisions.test.ts b/src/cli/gateway-cli/register.option-collisions.test.ts index 6d053956b2..a59c53ab16 100644 --- a/src/cli/gateway-cli/register.option-collisions.test.ts +++ b/src/cli/gateway-cli/register.option-collisions.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +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 callGatewayCli = vi.fn(async (_method: string, _opts: unknown, _params?: unknown) => ({ @@ -111,6 +112,12 @@ vi.mock("./discover.js", () => ({ })); describe("gateway register option collisions", () => { + let registerGatewayCli: typeof import("./register.js").registerGatewayCli; + + beforeAll(async () => { + ({ registerGatewayCli } = await import("./register.js")); + }); + beforeEach(() => { resetRuntimeCapture(); callGatewayCli.mockClear(); @@ -118,12 +125,9 @@ describe("gateway register option collisions", () => { }); it("forwards --token to gateway call when parent and child option names collide", async () => { - const { registerGatewayCli } = await import("./register.js"); - const program = new Command(); - registerGatewayCli(program); - - await program.parseAsync(["gateway", "call", "health", "--token", "tok_call", "--json"], { - from: "user", + await runRegisteredCli({ + register: registerGatewayCli as (program: Command) => void, + argv: ["gateway", "call", "health", "--token", "tok_call", "--json"], }); expect(callGatewayCli).toHaveBeenCalledWith( @@ -136,12 +140,9 @@ describe("gateway register option collisions", () => { }); it("forwards --token to gateway probe when parent and child option names collide", async () => { - const { registerGatewayCli } = await import("./register.js"); - const program = new Command(); - registerGatewayCli(program); - - await program.parseAsync(["gateway", "probe", "--token", "tok_probe", "--json"], { - from: "user", + await runRegisteredCli({ + register: registerGatewayCli as (program: Command) => void, + argv: ["gateway", "probe", "--token", "tok_probe", "--json"], }); expect(gatewayStatusCommand).toHaveBeenCalledWith( diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 132962609e..343b740fce 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +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 startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({ @@ -91,6 +92,12 @@ vi.mock("./run-loop.js", () => ({ })); describe("gateway run option collisions", () => { + let addGatewayRunCommand: typeof import("./run.js").addGatewayRunCommand; + + beforeAll(async () => { + ({ addGatewayRunCommand } = await import("./run.js")); + }); + beforeEach(() => { resetRuntimeCapture(); startGatewayServer.mockClear(); @@ -101,25 +108,27 @@ describe("gateway run option collisions", () => { runGatewayLoop.mockClear(); }); - it("forwards parent-captured options to `gateway run` subcommand", async () => { - const { addGatewayRunCommand } = await import("./run.js"); - const program = new Command(); - const gateway = addGatewayRunCommand(program.command("gateway")); - addGatewayRunCommand(gateway.command("run")); + async function runGatewayCli(argv: string[]) { + await runRegisteredCli({ + register: ((program: Command) => { + const gateway = addGatewayRunCommand(program.command("gateway")); + addGatewayRunCommand(gateway.command("run")); + }) as (program: Command) => void, + argv, + }); + } - await program.parseAsync( - [ - "gateway", - "run", - "--token", - "tok_run", - "--allow-unconfigured", - "--ws-log", - "full", - "--force", - ], - { from: "user" }, - ); + it("forwards parent-captured options to `gateway run` subcommand", async () => { + await runGatewayCli([ + "gateway", + "run", + "--token", + "tok_run", + "--allow-unconfigured", + "--ws-log", + "full", + "--force", + ]); expect(forceFreePortAndWait).toHaveBeenCalledWith(18789, expect.anything()); expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full"); @@ -134,14 +143,7 @@ describe("gateway run option collisions", () => { }); it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => { - const { addGatewayRunCommand } = await import("./run.js"); - const program = new Command(); - const gateway = addGatewayRunCommand(program.command("gateway")); - addGatewayRunCommand(gateway.command("run")); - - await program.parseAsync(["gateway", "run", "--allow-unconfigured"], { - from: "user", - }); + await runGatewayCli(["gateway", "run", "--allow-unconfigured"]); expect(startGatewayServer).toHaveBeenCalledWith( 18789, diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts index eb16dcca57..3645b542f4 100644 --- a/src/cli/logs-cli.test.ts +++ b/src/cli/logs-cli.test.ts @@ -1,5 +1,5 @@ -import { Command } from "commander"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { runRegisteredCli } from "../test-utils/command-runner.js"; import { formatLogTimestamp } from "./logs-cli.js"; const callGatewayFromCli = vi.fn(); @@ -12,17 +12,23 @@ vi.mock("./gateway-rpc.js", async () => { }; }); +let registerLogsCli: typeof import("./logs-cli.js").registerLogsCli; + +beforeAll(async () => { + ({ registerLogsCli } = await import("./logs-cli.js")); +}); + async function runLogsCli(argv: string[]) { - const { registerLogsCli } = await import("./logs-cli.js"); - const program = new Command(); - program.exitOverride(); - registerLogsCli(program); - await program.parseAsync(argv, { from: "user" }); + await runRegisteredCli({ + register: registerLogsCli as (program: import("commander").Command) => void, + argv, + }); } describe("logs cli", () => { afterEach(() => { callGatewayFromCli.mockReset(); + vi.restoreAllMocks(); }); it("writes output directly to stdout/stderr", async () => { @@ -37,20 +43,17 @@ describe("logs cli", () => { const stdoutWrites: string[] = []; const stderrWrites: string[] = []; - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { stdoutWrites.push(String(chunk)); return true; }); - const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { stderrWrites.push(String(chunk)); return true; }); await runLogsCli(["logs"]); - stdoutSpy.mockRestore(); - stderrSpy.mockRestore(); - expect(stdoutWrites.join("")).toContain("Log file:"); expect(stdoutWrites.join("")).toContain("raw line"); expect(stderrWrites.join("")).toContain("Log tail truncated"); @@ -70,15 +73,13 @@ describe("logs cli", () => { }); const stdoutWrites: string[] = []; - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { stdoutWrites.push(String(chunk)); return true; }); await runLogsCli(["logs", "--local-time", "--plain"]); - stdoutSpy.mockRestore(); - const output = stdoutWrites.join(""); expect(output).toContain("line one"); const timestamp = output.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z?/u)?.[0]; @@ -93,21 +94,18 @@ describe("logs cli", () => { }); const stderrWrites: string[] = []; - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => { + vi.spyOn(process.stdout, "write").mockImplementation(() => { const err = new Error("EPIPE") as NodeJS.ErrnoException; err.code = "EPIPE"; throw err; }); - const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { stderrWrites.push(String(chunk)); return true; }); await runLogsCli(["logs"]); - stdoutSpy.mockRestore(); - stderrSpy.mockRestore(); - expect(stderrWrites.join("")).toContain("output stdout closed"); }); @@ -143,15 +141,13 @@ describe("logs cli", () => { } }); - it("handles empty or invalid timestamps", () => { - expect(formatLogTimestamp(undefined)).toBe(""); - expect(formatLogTimestamp("")).toBe(""); - expect(formatLogTimestamp("invalid-date")).toBe("invalid-date"); - }); - - it("preserves original value for invalid dates", () => { - const result = formatLogTimestamp("not-a-date"); - expect(result).toBe("not-a-date"); + it.each([ + { input: undefined, expected: "" }, + { input: "", expected: "" }, + { input: "invalid-date", expected: "invalid-date" }, + { input: "not-a-date", expected: "not-a-date" }, + ])("preserves timestamp fallback for $input", ({ input, expected }) => { + expect(formatLogTimestamp(input)).toBe(expected); }); }); }); diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index 1da9f96de1..cfa82d0fd4 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Command } from "commander"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const getMemorySearchManager = vi.fn(); const loadConfig = vi.fn(() => ({})); @@ -20,11 +20,21 @@ vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentId, })); -afterEach(async () => { +let registerMemoryCli: typeof import("./memory-cli.js").registerMemoryCli; +let defaultRuntime: typeof import("../runtime.js").defaultRuntime; +let isVerbose: typeof import("../globals.js").isVerbose; +let setVerbose: typeof import("../globals.js").setVerbose; + +beforeAll(async () => { + ({ registerMemoryCli } = await import("./memory-cli.js")); + ({ defaultRuntime } = await import("../runtime.js")); + ({ isVerbose, setVerbose } = await import("../globals.js")); +}); + +afterEach(() => { vi.restoreAllMocks(); getMemorySearchManager.mockReset(); process.exitCode = undefined; - const { setVerbose } = await import("../globals.js"); setVerbose(false); }); @@ -55,15 +65,45 @@ describe("memory cli", () => { } async function runMemoryCli(args: string[]) { - const { registerMemoryCli } = await import("./memory-cli.js"); const program = new Command(); program.name("test"); registerMemoryCli(program); await program.parseAsync(["memory", ...args], { from: "user" }); } + async function withQmdIndexDb(content: string, run: (dbPath: string) => Promise) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-")); + const dbPath = path.join(tmpDir, "index.sqlite"); + try { + await fs.writeFile(dbPath, content, "utf-8"); + await run(dbPath); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + } + + async function expectCloseFailureAfterCommand(params: { + args: string[]; + manager: Record; + beforeExpect?: () => void; + }) { + const close = vi.fn(async () => { + throw new Error("close boom"); + }); + mockManager({ ...params.manager, close }); + + const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); + await runMemoryCli(params.args); + + params.beforeExpect?.(); + expect(close).toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("Memory manager close failed: close boom"), + ); + expect(process.exitCode).toBeUndefined(); + } + it("prints vector status when available", async () => { - const { defaultRuntime } = await import("../runtime.js"); const close = vi.fn(async () => {}); mockManager({ probeVectorAvailability: vi.fn(async () => true), @@ -97,7 +137,6 @@ describe("memory cli", () => { }); it("prints vector error when unavailable", async () => { - const { defaultRuntime } = await import("../runtime.js"); const close = vi.fn(async () => {}); mockManager({ probeVectorAvailability: vi.fn(async () => false), @@ -122,7 +161,6 @@ describe("memory cli", () => { }); it("prints embeddings status when deep", async () => { - const { defaultRuntime } = await import("../runtime.js"); const close = vi.fn(async () => {}); const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true })); mockManager({ @@ -141,7 +179,6 @@ describe("memory cli", () => { }); it("enables verbose logging with --verbose", async () => { - const { isVerbose } = await import("../globals.js"); const close = vi.fn(async () => {}); mockManager({ probeVectorAvailability: vi.fn(async () => true), @@ -155,28 +192,16 @@ describe("memory cli", () => { }); it("logs close failure after status", async () => { - const { defaultRuntime } = await import("../runtime.js"); - const close = vi.fn(async () => { - throw new Error("close boom"); + await expectCloseFailureAfterCommand({ + args: ["status"], + manager: { + probeVectorAvailability: vi.fn(async () => true), + status: () => makeMemoryStatus({ files: 1, chunks: 1 }), + }, }); - mockManager({ - probeVectorAvailability: vi.fn(async () => true), - status: () => makeMemoryStatus({ files: 1, chunks: 1 }), - close, - }); - - const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); - await runMemoryCli(["status"]); - - expect(close).toHaveBeenCalled(); - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Memory manager close failed: close boom"), - ); - expect(process.exitCode).toBeUndefined(); }); it("reindexes on status --index", async () => { - const { defaultRuntime } = await import("../runtime.js"); const close = vi.fn(async () => {}); const sync = vi.fn(async () => {}); const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true })); @@ -197,7 +222,6 @@ describe("memory cli", () => { }); it("closes manager after index", async () => { - const { defaultRuntime } = await import("../runtime.js"); const close = vi.fn(async () => {}); const sync = vi.fn(async () => {}); mockManager({ sync, close }); @@ -211,69 +235,51 @@ describe("memory cli", () => { }); it("logs qmd index file path and size after index", async () => { - const { defaultRuntime } = await import("../runtime.js"); const close = vi.fn(async () => {}); const sync = vi.fn(async () => {}); - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-")); - const dbPath = path.join(tmpDir, "index.sqlite"); - await fs.writeFile(dbPath, "sqlite-bytes", "utf-8"); - mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); + await withQmdIndexDb("sqlite-bytes", async (dbPath) => { + mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); - const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); - await runMemoryCli(["index"]); + const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {}); + await runMemoryCli(["index"]); - expectCliSync(sync); - expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: ")); - expect(log).toHaveBeenCalledWith("Memory index updated (main)."); - expect(close).toHaveBeenCalled(); - await fs.rm(tmpDir, { recursive: true, force: true }); + expectCliSync(sync); + expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: ")); + expect(log).toHaveBeenCalledWith("Memory index updated (main)."); + expect(close).toHaveBeenCalled(); + }); }); it("fails index when qmd db file is empty", async () => { - const { defaultRuntime } = await import("../runtime.js"); const close = vi.fn(async () => {}); const sync = vi.fn(async () => {}); - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-")); - const dbPath = path.join(tmpDir, "index.sqlite"); - await fs.writeFile(dbPath, "", "utf-8"); - mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); + await withQmdIndexDb("", async (dbPath) => { + mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close }); - const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); - await runMemoryCli(["index"]); + const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); + await runMemoryCli(["index"]); - expectCliSync(sync); - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Memory index failed (main): QMD index file is empty"), - ); - expect(close).toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - await fs.rm(tmpDir, { recursive: true, force: true }); + expectCliSync(sync); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("Memory index failed (main): QMD index file is empty"), + ); + expect(close).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); }); it("logs close failures without failing the command", async () => { - const { defaultRuntime } = await import("../runtime.js"); - const close = vi.fn(async () => { - throw new Error("close boom"); - }); const sync = vi.fn(async () => {}); - mockManager({ sync, close }); - - const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); - await runMemoryCli(["index"]); - - expectCliSync(sync); - expect(close).toHaveBeenCalled(); - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Memory manager close failed: close boom"), - ); - expect(process.exitCode).toBeUndefined(); + await expectCloseFailureAfterCommand({ + args: ["index"], + manager: { sync }, + beforeExpect: () => { + expectCliSync(sync); + }, + }); }); it("logs close failure after search", async () => { - const { defaultRuntime } = await import("../runtime.js"); - const close = vi.fn(async () => { - throw new Error("close boom"); - }); const search = vi.fn(async () => [ { path: "memory/2026-01-12.md", @@ -283,21 +289,16 @@ describe("memory cli", () => { snippet: "Hello", }, ]); - mockManager({ search, close }); - - const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); - await runMemoryCli(["search", "hello"]); - - expect(search).toHaveBeenCalled(); - expect(close).toHaveBeenCalled(); - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Memory manager close failed: close boom"), - ); - expect(process.exitCode).toBeUndefined(); + await expectCloseFailureAfterCommand({ + args: ["search", "hello"], + manager: { search }, + beforeExpect: () => { + expect(search).toHaveBeenCalled(); + }, + }); }); it("closes manager after search error", async () => { - const { defaultRuntime } = await import("../runtime.js"); const close = vi.fn(async () => {}); const search = vi.fn(async () => { throw new Error("boom"); diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index fb34e421fd..7386988a1f 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -1,4 +1,6 @@ +import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runRegisteredCli } from "../test-utils/command-runner.js"; const githubCopilotLoginCommand = vi.fn(); const modelsStatusCommand = vi.fn().mockResolvedValue(undefined); @@ -32,12 +34,10 @@ vi.mock("../commands/models.js", () => ({ })); describe("models cli", () => { - let Command: typeof import("commander").Command; let registerModelsCli: (typeof import("./models-cli.js"))["registerModelsCli"]; beforeAll(async () => { // Load once; vi.mock above ensures command handlers are already mocked. - ({ Command } = await import("commander")); ({ registerModelsCli } = await import("./models-cli.js")); }); @@ -52,6 +52,13 @@ describe("models cli", () => { return program; } + async function runModelsCommand(args: string[]) { + await runRegisteredCli({ + register: registerModelsCli as (program: Command) => void, + argv: args, + }); + } + it("registers github-copilot login command", async () => { const program = createProgram(); const models = program.commands.find((cmd) => cmd.name() === "models"); @@ -74,22 +81,11 @@ describe("models cli", () => { ); }); - it("passes --agent to models status", async () => { - const program = createProgram(); - - await program.parseAsync(["models", "status", "--agent", "poe"], { from: "user" }); - - expect(modelsStatusCommand).toHaveBeenCalledWith( - expect.objectContaining({ agent: "poe" }), - expect.any(Object), - ); - }); - - it("passes parent --agent to models status", async () => { - const program = createProgram(); - - await program.parseAsync(["models", "--agent", "poe", "status"], { from: "user" }); - + it.each([ + { label: "status flag", args: ["models", "status", "--agent", "poe"] }, + { label: "parent flag", args: ["models", "--agent", "poe", "status"] }, + ])("passes --agent to models status ($label)", async ({ args }) => { + await runModelsCommand(args); expect(modelsStatusCommand).toHaveBeenCalledWith( expect.objectContaining({ agent: "poe" }), expect.any(Object), diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts index c267c31396..b8ddc75308 100644 --- a/src/cli/nodes-cli.coverage.test.ts +++ b/src/cli/nodes-cli.coverage.test.ts @@ -83,6 +83,19 @@ describe("nodes-cli coverage", () => { const getNodeInvokeCall = () => callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0] as NodeInvokeCall; + const createNodesProgram = () => { + const program = new Command(); + program.exitOverride(); + registerNodesCli(program); + return program; + }; + + const runNodesCommand = async (args: string[]) => { + const program = createNodesProgram(); + await program.parseAsync(args, { from: "user" }); + return getNodeInvokeCall(); + }; + beforeAll(async () => { ({ registerNodesCli } = await import("./nodes-cli.js")); }); @@ -94,32 +107,23 @@ describe("nodes-cli coverage", () => { }); it("invokes system.run with parsed params", async () => { - const program = new Command(); - program.exitOverride(); - registerNodesCli(program); - - await program.parseAsync( - [ - "nodes", - "run", - "--node", - "mac-1", - "--cwd", - "/tmp", - "--env", - "FOO=bar", - "--command-timeout", - "1200", - "--needs-screen-recording", - "--invoke-timeout", - "5000", - "echo", - "hi", - ], - { from: "user" }, - ); - - const invoke = getNodeInvokeCall(); + const invoke = await runNodesCommand([ + "nodes", + "run", + "--node", + "mac-1", + "--cwd", + "/tmp", + "--env", + "FOO=bar", + "--command-timeout", + "1200", + "--needs-screen-recording", + "--invoke-timeout", + "5000", + "echo", + "hi", + ]); expect(invoke).toBeTruthy(); expect(invoke?.params?.idempotencyKey).toBe("rk_test"); @@ -139,16 +143,16 @@ describe("nodes-cli coverage", () => { }); it("invokes system.run with raw command", async () => { - const program = new Command(); - program.exitOverride(); - registerNodesCli(program); - - await program.parseAsync( - ["nodes", "run", "--agent", "main", "--node", "mac-1", "--raw", "echo hi"], - { from: "user" }, - ); - - const invoke = getNodeInvokeCall(); + const invoke = await runNodesCommand([ + "nodes", + "run", + "--agent", + "main", + "--node", + "mac-1", + "--raw", + "echo hi", + ]); expect(invoke).toBeTruthy(); expect(invoke?.params?.idempotencyKey).toBe("rk_test"); @@ -164,27 +168,18 @@ describe("nodes-cli coverage", () => { }); it("invokes system.notify with provided fields", async () => { - const program = new Command(); - program.exitOverride(); - registerNodesCli(program); - - await program.parseAsync( - [ - "nodes", - "notify", - "--node", - "mac-1", - "--title", - "Ping", - "--body", - "Gateway ready", - "--delivery", - "overlay", - ], - { from: "user" }, - ); - - const invoke = getNodeInvokeCall(); + const invoke = await runNodesCommand([ + "nodes", + "notify", + "--node", + "mac-1", + "--title", + "Ping", + "--body", + "Gateway ready", + "--delivery", + "overlay", + ]); expect(invoke).toBeTruthy(); expect(invoke?.params?.command).toBe("system.notify"); @@ -198,30 +193,21 @@ describe("nodes-cli coverage", () => { }); it("invokes location.get with params", async () => { - const program = new Command(); - program.exitOverride(); - registerNodesCli(program); - - await program.parseAsync( - [ - "nodes", - "location", - "get", - "--node", - "mac-1", - "--accuracy", - "precise", - "--max-age", - "1000", - "--location-timeout", - "5000", - "--invoke-timeout", - "6000", - ], - { from: "user" }, - ); - - const invoke = getNodeInvokeCall(); + const invoke = await runNodesCommand([ + "nodes", + "location", + "get", + "--node", + "mac-1", + "--accuracy", + "precise", + "--max-age", + "1000", + "--location-timeout", + "5000", + "--invoke-timeout", + "6000", + ]); expect(invoke).toBeTruthy(); expect(invoke?.params?.command).toBe("location.get"); diff --git a/src/cli/nodes-cli/register.invoke.nodes-run-approval-timeout.test.ts b/src/cli/nodes-cli/register.invoke.nodes-run-approval-timeout.test.ts index 81d365e850..c8c870a313 100644 --- a/src/cli/nodes-cli/register.invoke.nodes-run-approval-timeout.test.ts +++ b/src/cli/nodes-cli/register.invoke.nodes-run-approval-timeout.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS } from "../../infra/exec-approvals.js"; import { parseTimeoutMs } from "../nodes-run.js"; @@ -33,14 +33,18 @@ vi.mock("../progress.js", () => ({ })); describe("nodes run: approval transport timeout (#12098)", () => { + let callGatewayCli: typeof import("./rpc.js").callGatewayCli; + + beforeAll(async () => { + ({ callGatewayCli } = await import("./rpc.js")); + }); + beforeEach(() => { callGatewaySpy.mockReset(); callGatewaySpy.mockResolvedValue({ decision: "allow-once" }); }); it("callGatewayCli forwards opts.timeout as the transport timeoutMs", async () => { - const { callGatewayCli } = await import("./rpc.js"); - await callGatewayCli("exec.approval.request", { timeout: "35000" } as never, { timeoutMs: 120_000, }); @@ -52,8 +56,6 @@ describe("nodes run: approval transport timeout (#12098)", () => { }); it("fix: overriding transportTimeoutMs gives the approval enough transport time", async () => { - const { callGatewayCli } = await import("./rpc.js"); - const approvalTimeoutMs = 120_000; // Mirror the production code: parseTimeoutMs(opts.timeout) ?? 0 const transportTimeoutMs = Math.max(parseTimeoutMs("35000") ?? 0, approvalTimeoutMs + 10_000); @@ -73,8 +75,6 @@ describe("nodes run: approval transport timeout (#12098)", () => { }); it("fix: user-specified timeout larger than approval is preserved", async () => { - const { callGatewayCli } = await import("./rpc.js"); - const approvalTimeoutMs = 120_000; const userTimeout = 200_000; // Mirror the production code: parseTimeoutMs preserves valid large values @@ -96,8 +96,6 @@ describe("nodes run: approval transport timeout (#12098)", () => { }); it("fix: non-numeric timeout falls back to approval floor", async () => { - const { callGatewayCli } = await import("./rpc.js"); - const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS; // parseTimeoutMs returns undefined for garbage input, ?? 0 ensures // Math.max picks the approval floor instead of producing NaN diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index 73b81386a0..424ca84d8e 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const listChannelPairingRequests = vi.fn(); const approveChannelPairingCode = vi.fn(); @@ -45,167 +45,153 @@ vi.mock("../config/config.js", () => ({ })); describe("pairing cli", () => { - it("evaluates pairing channels when registering the CLI (not at import)", async () => { + let registerPairingCli: typeof import("./pairing-cli.js").registerPairingCli; + + beforeAll(async () => { + ({ registerPairingCli } = await import("./pairing-cli.js")); + }); + + beforeEach(() => { + listChannelPairingRequests.mockReset(); + approveChannelPairingCode.mockReset(); + notifyPairingApproved.mockReset(); + normalizeChannelId.mockClear(); + getPairingAdapter.mockClear(); listPairingChannels.mockClear(); + }); - const { registerPairingCli } = await import("./pairing-cli.js"); - expect(listPairingChannels).not.toHaveBeenCalled(); - + function createProgram() { const program = new Command(); program.name("test"); registerPairingCli(program); + return program; + } + + async function runPairing(args: string[]) { + const program = createProgram(); + await program.parseAsync(args, { from: "user" }); + } + + function mockApprovedPairing() { + approveChannelPairingCode.mockResolvedValueOnce({ + id: "123", + entry: { + id: "123", + code: "ABCDEFGH", + createdAt: "2026-01-08T00:00:00Z", + lastSeenAt: "2026-01-08T00:00:00Z", + }, + }); + } + + it("evaluates pairing channels when registering the CLI (not at import)", async () => { + expect(listPairingChannels).not.toHaveBeenCalled(); + + createProgram(); expect(listPairingChannels).toHaveBeenCalledTimes(1); }); - it("labels Telegram ids as telegramUserId", async () => { - const { registerPairingCli } = await import("./pairing-cli.js"); + it.each([ + { + name: "telegram ids", + channel: "telegram", + id: "123", + label: "telegramUserId", + meta: { username: "peter" }, + }, + { + name: "discord ids", + channel: "discord", + id: "999", + label: "discordUserId", + meta: { tag: "Ada#0001" }, + }, + ])("labels $name correctly", async ({ channel, id, label, meta }) => { listChannelPairingRequests.mockResolvedValueOnce([ { - id: "123", + id, code: "ABC123", createdAt: "2026-01-08T00:00:00Z", lastSeenAt: "2026-01-08T00:00:00Z", - meta: { username: "peter" }, + meta, }, ]); const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const program = new Command(); - program.name("test"); - registerPairingCli(program); - await program.parseAsync(["pairing", "list", "--channel", "telegram"], { - from: "user", - }); - const output = log.mock.calls.map((call) => call.join(" ")).join("\n"); - expect(output).toContain("telegramUserId"); - expect(output).toContain("123"); + try { + await runPairing(["pairing", "list", "--channel", channel]); + const output = log.mock.calls.map((call) => call.join(" ")).join("\n"); + expect(output).toContain(label); + expect(output).toContain(id); + } finally { + log.mockRestore(); + } }); it("accepts channel as positional for list", async () => { - const { registerPairingCli } = await import("./pairing-cli.js"); listChannelPairingRequests.mockResolvedValueOnce([]); - const program = new Command(); - program.name("test"); - registerPairingCli(program); - await program.parseAsync(["pairing", "list", "telegram"], { from: "user" }); + await runPairing(["pairing", "list", "telegram"]); expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram"); }); it("forwards --account for list", async () => { - const { registerPairingCli } = await import("./pairing-cli.js"); listChannelPairingRequests.mockResolvedValueOnce([]); - const program = new Command(); - program.name("test"); - registerPairingCli(program); - await program.parseAsync(["pairing", "list", "--channel", "telegram", "--account", "yy"], { - from: "user", - }); + await runPairing(["pairing", "list", "--channel", "telegram", "--account", "yy"]); expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram", process.env, "yy"); }); it("normalizes channel aliases", async () => { - const { registerPairingCli } = await import("./pairing-cli.js"); listChannelPairingRequests.mockResolvedValueOnce([]); - const program = new Command(); - program.name("test"); - registerPairingCli(program); - await program.parseAsync(["pairing", "list", "imsg"], { from: "user" }); + await runPairing(["pairing", "list", "imsg"]); expect(normalizeChannelId).toHaveBeenCalledWith("imsg"); expect(listChannelPairingRequests).toHaveBeenCalledWith("imessage"); }); it("accepts extension channels outside the registry", async () => { - const { registerPairingCli } = await import("./pairing-cli.js"); listChannelPairingRequests.mockResolvedValueOnce([]); - const program = new Command(); - program.name("test"); - registerPairingCli(program); - await program.parseAsync(["pairing", "list", "zalo"], { from: "user" }); + await runPairing(["pairing", "list", "zalo"]); expect(normalizeChannelId).toHaveBeenCalledWith("zalo"); expect(listChannelPairingRequests).toHaveBeenCalledWith("zalo"); }); - it("labels Discord ids as discordUserId", async () => { - const { registerPairingCli } = await import("./pairing-cli.js"); - listChannelPairingRequests.mockResolvedValueOnce([ - { - id: "999", - code: "DEF456", - createdAt: "2026-01-08T00:00:00Z", - lastSeenAt: "2026-01-08T00:00:00Z", - meta: { tag: "Ada#0001" }, - }, - ]); - - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const program = new Command(); - program.name("test"); - registerPairingCli(program); - await program.parseAsync(["pairing", "list", "--channel", "discord"], { - from: "user", - }); - const output = log.mock.calls.map((call) => call.join(" ")).join("\n"); - expect(output).toContain("discordUserId"); - expect(output).toContain("999"); - }); - it("accepts channel as positional for approve (npm-run compatible)", async () => { - const { registerPairingCli } = await import("./pairing-cli.js"); - approveChannelPairingCode.mockResolvedValueOnce({ - id: "123", - entry: { - id: "123", - code: "ABCDEFGH", - createdAt: "2026-01-08T00:00:00Z", - lastSeenAt: "2026-01-08T00:00:00Z", - }, - }); + mockApprovedPairing(); const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const program = new Command(); - program.name("test"); - registerPairingCli(program); - await program.parseAsync(["pairing", "approve", "telegram", "ABCDEFGH"], { - from: "user", - }); + try { + await runPairing(["pairing", "approve", "telegram", "ABCDEFGH"]); - expect(approveChannelPairingCode).toHaveBeenCalledWith({ - channel: "telegram", - code: "ABCDEFGH", - }); - expect(log).toHaveBeenCalledWith(expect.stringContaining("Approved")); + expect(approveChannelPairingCode).toHaveBeenCalledWith({ + channel: "telegram", + code: "ABCDEFGH", + }); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Approved")); + } finally { + log.mockRestore(); + } }); it("forwards --account for approve", async () => { - const { registerPairingCli } = await import("./pairing-cli.js"); - approveChannelPairingCode.mockResolvedValueOnce({ - id: "123", - entry: { - id: "123", - code: "ABCDEFGH", - createdAt: "2026-01-08T00:00:00Z", - lastSeenAt: "2026-01-08T00:00:00Z", - }, - }); + mockApprovedPairing(); - const program = new Command(); - program.name("test"); - registerPairingCli(program); - await program.parseAsync( - ["pairing", "approve", "--channel", "telegram", "--account", "yy", "ABCDEFGH"], - { - from: "user", - }, - ); + await runPairing([ + "pairing", + "approve", + "--channel", + "telegram", + "--account", + "yy", + "ABCDEFGH", + ]); expect(approveChannelPairingCode).toHaveBeenCalledWith({ channel: "telegram", diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 5c78eaa367..3351df22dd 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -42,13 +42,11 @@ describe("parseCliProfileArgs", () => { expect(res.ok).toBe(false); }); - it("rejects combining --dev with --profile (dev first)", () => { - const res = parseCliProfileArgs(["node", "openclaw", "--dev", "--profile", "work", "status"]); - expect(res.ok).toBe(false); - }); - - it("rejects combining --dev with --profile (profile first)", () => { - const res = parseCliProfileArgs(["node", "openclaw", "--profile", "work", "--dev", "status"]); + it.each([ + ["--dev first", ["node", "openclaw", "--dev", "--profile", "work", "status"]], + ["--profile first", ["node", "openclaw", "--profile", "work", "--dev", "status"]], + ])("rejects combining --dev with --profile (%s)", (_name, argv) => { + const res = parseCliProfileArgs(argv); expect(res.ok).toBe(false); }); }); @@ -103,38 +101,45 @@ describe("applyCliProfileEnv", () => { }); describe("formatCliCommand", () => { - it("returns command unchanged when no profile is set", () => { - expect(formatCliCommand("openclaw doctor --fix", {})).toBe("openclaw doctor --fix"); - }); - - it("returns command unchanged when profile is default", () => { - expect(formatCliCommand("openclaw doctor --fix", { OPENCLAW_PROFILE: "default" })).toBe( - "openclaw doctor --fix", - ); - }); - - it("returns command unchanged when profile is Default (case-insensitive)", () => { - expect(formatCliCommand("openclaw doctor --fix", { OPENCLAW_PROFILE: "Default" })).toBe( - "openclaw doctor --fix", - ); - }); - - it("returns command unchanged when profile is invalid", () => { - expect(formatCliCommand("openclaw doctor --fix", { OPENCLAW_PROFILE: "bad profile" })).toBe( - "openclaw doctor --fix", - ); - }); - - it("returns command unchanged when --profile is already present", () => { - expect( - formatCliCommand("openclaw --profile work doctor --fix", { OPENCLAW_PROFILE: "work" }), - ).toBe("openclaw --profile work doctor --fix"); - }); - - it("returns command unchanged when --dev is already present", () => { - expect(formatCliCommand("openclaw --dev doctor", { OPENCLAW_PROFILE: "dev" })).toBe( - "openclaw --dev doctor", - ); + it.each([ + { + name: "no profile is set", + cmd: "openclaw doctor --fix", + env: {}, + expected: "openclaw doctor --fix", + }, + { + name: "profile is default", + cmd: "openclaw doctor --fix", + env: { OPENCLAW_PROFILE: "default" }, + expected: "openclaw doctor --fix", + }, + { + name: "profile is Default (case-insensitive)", + cmd: "openclaw doctor --fix", + env: { OPENCLAW_PROFILE: "Default" }, + expected: "openclaw doctor --fix", + }, + { + name: "profile is invalid", + cmd: "openclaw doctor --fix", + env: { OPENCLAW_PROFILE: "bad profile" }, + expected: "openclaw doctor --fix", + }, + { + name: "--profile is already present", + cmd: "openclaw --profile work doctor --fix", + env: { OPENCLAW_PROFILE: "work" }, + expected: "openclaw --profile work doctor --fix", + }, + { + name: "--dev is already present", + cmd: "openclaw --dev doctor", + env: { OPENCLAW_PROFILE: "dev" }, + expected: "openclaw --dev doctor", + }, + ])("returns command unchanged when $name", ({ cmd, env, expected }) => { + expect(formatCliCommand(cmd, env)).toBe(expected); }); it("inserts --profile flag when profile is set", () => { diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index e1b3f8aa59..5459c7d525 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -23,6 +23,21 @@ function formatRuntimeLogCallArg(value: unknown): string { } describe("cli program (nodes basics)", () => { + function createProgramWithCleanRuntimeLog() { + const program = buildProgram(); + runtime.log.mockClear(); + return program; + } + + async function runProgram(argv: string[]) { + const program = createProgramWithCleanRuntimeLog(); + await program.parseAsync(argv, { from: "user" }); + } + + function getRuntimeOutput() { + return runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n"); + } + function mockGatewayWithIosNodeListAnd(method: "node.describe" | "node.invoke", result: unknown) { callGateway.mockImplementation(async (...args: unknown[]) => { const opts = (args[0] ?? {}) as { method?: string }; @@ -53,9 +68,7 @@ describe("cli program (nodes basics)", () => { it("runs nodes list and calls node.pair.list", async () => { callGateway.mockResolvedValue({ pending: [], paired: [] }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync(["nodes", "list"], { from: "user" }); + await runProgram(["nodes", "list"]); expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" })); expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0"); }); @@ -93,12 +106,10 @@ describe("cli program (nodes basics)", () => { } return { ok: true }; }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync(["nodes", "list", "--connected"], { from: "user" }); + await runProgram(["nodes", "list", "--connected"]); expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" })); - const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n"); + const output = getRuntimeOutput(); expect(output).toContain("One"); expect(output).not.toContain("Two"); }); @@ -127,89 +138,83 @@ describe("cli program (nodes basics)", () => { } return { ok: true }; }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync(["nodes", "status", "--last-connected", "24h"], { - from: "user", - }); + await runProgram(["nodes", "status", "--last-connected", "24h"]); expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" })); - const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n"); + const output = getRuntimeOutput(); expect(output).toContain("One"); expect(output).not.toContain("Two"); }); - it("runs nodes status and calls node.list", async () => { + it.each([ + { + label: "paired node details", + node: { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + deviceFamily: "iPad", + modelIdentifier: "iPad16,6", + caps: ["canvas", "camera"], + paired: true, + connected: true, + }, + expectedOutput: [ + "Known: 1 · Paired: 1 · Connected: 1", + "iOS Node", + "Detail", + "device: iPad", + "hw: iPad16,6", + "Status", + "paired", + "Caps", + "camera", + "canvas", + ], + }, + { + label: "unpaired node details", + node: { + nodeId: "android-node", + displayName: "Peter's Tab S10 Ultra", + remoteIp: "192.168.0.99", + deviceFamily: "Android", + modelIdentifier: "samsung SM-X926B", + caps: ["canvas", "camera"], + paired: false, + connected: true, + }, + expectedOutput: [ + "Known: 1 · Paired: 0 · Connected: 1", + "Peter's Tab", + "S10 Ultra", + "Detail", + "device: Android", + "hw: samsung", + "SM-X926B", + "Status", + "unpaired", + "connected", + "Caps", + "camera", + "canvas", + ], + }, + ])("runs nodes status and renders $label", async ({ node, expectedOutput }) => { callGateway.mockResolvedValue({ ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - deviceFamily: "iPad", - modelIdentifier: "iPad16,6", - caps: ["canvas", "camera"], - paired: true, - connected: true, - }, - ], + nodes: [node], }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync(["nodes", "status"], { from: "user" }); + await runProgram(["nodes", "status"]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.list", params: {} }), ); - const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n"); - expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1"); - expect(output).toContain("iOS Node"); - expect(output).toContain("Detail"); - expect(output).toContain("device: iPad"); - expect(output).toContain("hw: iPad16,6"); - expect(output).toContain("Status"); - expect(output).toContain("paired"); - expect(output).toContain("Caps"); - expect(output).toContain("camera"); - expect(output).toContain("canvas"); - }); - - it("runs nodes status and shows unpaired nodes", async () => { - callGateway.mockResolvedValue({ - ts: Date.now(), - nodes: [ - { - nodeId: "android-node", - displayName: "Peter's Tab S10 Ultra", - remoteIp: "192.168.0.99", - deviceFamily: "Android", - modelIdentifier: "samsung SM-X926B", - caps: ["canvas", "camera"], - paired: false, - connected: true, - }, - ], - }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync(["nodes", "status"], { from: "user" }); - - const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n"); - expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1"); - expect(output).toContain("Peter's Tab"); - expect(output).toContain("S10 Ultra"); - expect(output).toContain("Detail"); - expect(output).toContain("device: Android"); - expect(output).toContain("hw: samsung"); - expect(output).toContain("SM-X926B"); - expect(output).toContain("Status"); - expect(output).toContain("unpaired"); - expect(output).toContain("connected"); - expect(output).toContain("Caps"); - expect(output).toContain("camera"); - expect(output).toContain("canvas"); + const output = getRuntimeOutput(); + for (const expected of expectedOutput) { + expect(output).toContain(expected); + } }); it("runs nodes describe and calls node.describe", async () => { @@ -222,11 +227,7 @@ describe("cli program (nodes basics)", () => { connected: true, }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync(["nodes", "describe", "--node", "ios-node"], { - from: "user", - }); + await runProgram(["nodes", "describe", "--node", "ios-node"]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.list", params: {} }), @@ -238,7 +239,7 @@ describe("cli program (nodes basics)", () => { }), ); - const out = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n"); + const out = getRuntimeOutput(); expect(out).toContain("Commands"); expect(out).toContain("canvas.eval"); }); @@ -248,9 +249,7 @@ describe("cli program (nodes basics)", () => { requestId: "r1", node: { nodeId: "n1", token: "t1" }, }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync(["nodes", "approve", "r1"], { from: "user" }); + await runProgram(["nodes", "approve", "r1"]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.pair.approve", @@ -268,21 +267,16 @@ describe("cli program (nodes basics)", () => { payload: { result: "ok" }, }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync( - [ - "nodes", - "invoke", - "--node", - "ios-node", - "--command", - "canvas.eval", - "--params", - '{"javaScript":"1+1"}', - ], - { from: "user" }, - ); + await runProgram([ + "nodes", + "invoke", + "--node", + "ios-node", + "--command", + "canvas.eval", + "--params", + '{"javaScript":"1+1"}', + ]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.list", params: {} }), diff --git a/src/cli/program.nodes-media.e2e.test.ts b/src/cli/program.nodes-media.e2e.test.ts index 538de141c5..342d41dd36 100644 --- a/src/cli/program.nodes-media.e2e.test.ts +++ b/src/cli/program.nodes-media.e2e.test.ts @@ -30,6 +30,24 @@ async function expectLoggedSingleMediaFile(params?: { return mediaPath; } +function expectParserAcceptsUrlWithoutBase64( + parse: (payload: Record) => { url?: string; base64?: string }, + payload: Record, + expectedUrl: string, +) { + const result = parse(payload); + expect(result.url).toBe(expectedUrl); + expect(result.base64).toBeUndefined(); +} + +function expectParserRejectsMissingMedia( + parse: (payload: Record) => unknown, + payload: Record, + expectedMessage: string, +) { + expect(() => parse(payload)).toThrow(expectedMessage); +} + const IOS_NODE = { nodeId: "ios-node", displayName: "iOS Node", @@ -61,6 +79,31 @@ function mockNodeGateway(command?: string, payload?: Record) { const { buildProgram } = await import("./program.js"); describe("cli program (nodes media)", () => { + function createProgramWithCleanRuntimeLog() { + const program = buildProgram(); + runtime.log.mockClear(); + return program; + } + + async function runNodesCommand(argv: string[]) { + const program = createProgramWithCleanRuntimeLog(); + await program.parseAsync(argv, { from: "user" }); + } + + async function runAndExpectUrlPayloadMediaFile(params: { + command: "camera.snap" | "camera.clip"; + payload: Record; + argv: string[]; + expectedPathPattern: RegExp; + }) { + mockNodeGateway(params.command, params.payload); + await runNodesCommand(params.argv); + await expectLoggedSingleMediaFile({ + expectedPathPattern: params.expectedPathPattern, + expectedContent: "url-content", + }); + } + beforeEach(() => { vi.clearAllMocks(); runTui.mockResolvedValue(undefined); @@ -69,9 +112,7 @@ describe("cli program (nodes media)", () => { it("runs nodes camera snap and prints two MEDIA paths", async () => { mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync(["nodes", "camera", "snap", "--node", "ios-node"], { from: "user" }); + await runNodesCommand(["nodes", "camera", "snap", "--node", "ios-node"]); const invokeCalls = callGateway.mock.calls .map((call) => call[0] as { method?: string; params?: Record }) @@ -107,12 +148,7 @@ describe("cli program (nodes media)", () => { hasAudio: true, }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync( - ["nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000"], - { from: "user" }, - ); + await runNodesCommand(["nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000"]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ @@ -140,28 +176,23 @@ describe("cli program (nodes media)", () => { it("runs nodes camera snap with facing front and passes params", async () => { mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync( - [ - "nodes", - "camera", - "snap", - "--node", - "ios-node", - "--facing", - "front", - "--max-width", - "640", - "--quality", - "0.8", - "--delay-ms", - "2000", - "--device-id", - "cam-123", - ], - { from: "user" }, - ); + await runNodesCommand([ + "nodes", + "camera", + "snap", + "--node", + "ios-node", + "--facing", + "front", + "--max-width", + "640", + "--quality", + "0.8", + "--delay-ms", + "2000", + "--device-id", + "cam-123", + ]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ @@ -193,23 +224,18 @@ describe("cli program (nodes media)", () => { hasAudio: false, }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync( - [ - "nodes", - "camera", - "clip", - "--node", - "ios-node", - "--duration", - "3000", - "--no-audio", - "--device-id", - "cam-123", - ], - { from: "user" }, - ); + await runNodesCommand([ + "nodes", + "camera", + "clip", + "--node", + "ios-node", + "--duration", + "3000", + "--no-audio", + "--device-id", + "cam-123", + ]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ @@ -238,12 +264,7 @@ describe("cli program (nodes media)", () => { hasAudio: true, }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync( - ["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"], - { from: "user" }, - ); + await runNodesCommand(["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"]); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ @@ -260,12 +281,7 @@ describe("cli program (nodes media)", () => { it("runs nodes canvas snapshot and prints MEDIA path", async () => { mockNodeGateway("canvas.snapshot", { format: "png", base64: "aGk=" }); - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync( - ["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"], - { from: "user" }, - ); + await runNodesCommand(["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"]); await expectLoggedSingleMediaFile({ expectedPathPattern: /openclaw-canvas-snapshot-.*\.png$/, @@ -307,62 +323,86 @@ describe("cli program (nodes media)", () => { globalThis.fetch = originalFetch; }); - it("runs nodes camera snap with url payload", async () => { - mockNodeGateway("camera.snap", { - format: "jpg", - url: "https://example.com/photo.jpg", - width: 640, - height: 480, - }); - - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync( - ["nodes", "camera", "snap", "--node", "ios-node", "--facing", "front"], - { from: "user" }, - ); - - await expectLoggedSingleMediaFile({ + it.each([ + { + label: "runs nodes camera snap with url payload", + command: "camera.snap" as const, + payload: { + format: "jpg", + url: "https://example.com/photo.jpg", + width: 640, + height: 480, + }, + argv: ["nodes", "camera", "snap", "--node", "ios-node", "--facing", "front"], expectedPathPattern: /openclaw-camera-snap-front-.*\.jpg$/, - expectedContent: "url-content", - }); - }); - - it("runs nodes camera clip with url payload", async () => { - mockNodeGateway("camera.clip", { - format: "mp4", - url: "https://example.com/clip.mp4", - durationMs: 5000, - hasAudio: true, - }); - - const program = buildProgram(); - runtime.log.mockClear(); - await program.parseAsync( - ["nodes", "camera", "clip", "--node", "ios-node", "--duration", "5000"], - { from: "user" }, - ); - - await expectLoggedSingleMediaFile({ + }, + { + label: "runs nodes camera clip with url payload", + command: "camera.clip" as const, + payload: { + format: "mp4", + url: "https://example.com/clip.mp4", + durationMs: 5000, + hasAudio: true, + }, + argv: ["nodes", "camera", "clip", "--node", "ios-node", "--duration", "5000"], expectedPathPattern: /openclaw-camera-clip-front-.*\.mp4$/, - expectedContent: "url-content", + }, + ])("$label", async ({ command, payload, argv, expectedPathPattern }) => { + await runAndExpectUrlPayloadMediaFile({ + command, + payload, + argv, + expectedPathPattern, }); }); }); - describe("parseCameraSnapPayload with url", () => { - it("accepts url without base64", () => { - const result = parseCameraSnapPayload({ - format: "jpg", - url: "https://example.com/photo.jpg", - width: 640, - height: 480, - }); - expect(result.url).toBe("https://example.com/photo.jpg"); - expect(result.base64).toBeUndefined(); - }); + describe("url payload parsers", () => { + const parserCases = [ + { + label: "camera snap parser", + parse: (payload: Record) => parseCameraSnapPayload(payload), + validPayload: { + format: "jpg", + url: "https://example.com/photo.jpg", + width: 640, + height: 480, + }, + invalidPayload: { format: "jpg", width: 640, height: 480 }, + expectedUrl: "https://example.com/photo.jpg", + expectedError: "invalid camera.snap payload", + }, + { + label: "camera clip parser", + parse: (payload: Record) => parseCameraClipPayload(payload), + validPayload: { + format: "mp4", + url: "https://example.com/clip.mp4", + durationMs: 3000, + hasAudio: true, + }, + invalidPayload: { format: "mp4", durationMs: 3000, hasAudio: true }, + expectedUrl: "https://example.com/clip.mp4", + expectedError: "invalid camera.clip payload", + }, + ] as const; - it("accepts both base64 and url", () => { + it.each(parserCases)( + "accepts url without base64: $label", + ({ parse, validPayload, expectedUrl }) => { + expectParserAcceptsUrlWithoutBase64(parse, validPayload, expectedUrl); + }, + ); + + it.each(parserCases)( + "rejects payload with neither base64 nor url: $label", + ({ parse, invalidPayload, expectedError }) => { + expectParserRejectsMissingMedia(parse, invalidPayload, expectedError); + }, + ); + + it("snap parser accepts both base64 and url", () => { const result = parseCameraSnapPayload({ format: "jpg", base64: "aGk=", @@ -373,30 +413,5 @@ describe("cli program (nodes media)", () => { expect(result.base64).toBe("aGk="); expect(result.url).toBe("https://example.com/photo.jpg"); }); - - it("rejects payload with neither base64 nor url", () => { - expect(() => parseCameraSnapPayload({ format: "jpg", width: 640, height: 480 })).toThrow( - "invalid camera.snap payload", - ); - }); - }); - - describe("parseCameraClipPayload with url", () => { - it("accepts url without base64", () => { - const result = parseCameraClipPayload({ - format: "mp4", - url: "https://example.com/clip.mp4", - durationMs: 3000, - hasAudio: true, - }); - expect(result.url).toBe("https://example.com/clip.mp4"); - expect(result.base64).toBeUndefined(); - }); - - it("rejects payload with neither base64 nor url", () => { - expect(() => - parseCameraClipPayload({ format: "mp4", durationMs: 3000, hasAudio: true }), - ).toThrow("invalid camera.clip payload"); - }); }); }); diff --git a/src/cli/program.smoke.e2e.test.ts b/src/cli/program.smoke.e2e.test.ts index 6048a95a66..cca4e06a9a 100644 --- a/src/cli/program.smoke.e2e.test.ts +++ b/src/cli/program.smoke.e2e.test.ts @@ -20,99 +20,108 @@ installSmokeProgramMocks(); const { buildProgram } = await import("./program.js"); describe("cli program (smoke)", () => { + function createProgram() { + return buildProgram(); + } + + async function runProgram(argv: string[]) { + const program = createProgram(); + await program.parseAsync(argv, { from: "user" }); + } + beforeEach(() => { vi.clearAllMocks(); runTui.mockResolvedValue(undefined); ensureConfigReady.mockResolvedValue(undefined); }); - it("runs message with required options", async () => { - const program = buildProgram(); - await expect( - program.parseAsync(["message", "send", "--target", "+1", "--message", "hi"], { - from: "user", - }), - ).rejects.toThrow("exit"); - expect(messageCommand).toHaveBeenCalled(); - }); - - it("runs message react with signal author fields", async () => { - const program = buildProgram(); - await expect( - program.parseAsync( - [ - "message", - "react", - "--channel", - "signal", - "--target", - "signal:group:abc123", - "--message-id", - "1737630212345", - "--emoji", - "✅", - "--target-author-uuid", - "123e4567-e89b-12d3-a456-426614174000", - ], - { from: "user" }, - ), - ).rejects.toThrow("exit"); + it.each([ + { + label: "runs message with required options", + argv: ["message", "send", "--target", "+1", "--message", "hi"], + }, + { + label: "runs message react with signal author fields", + argv: [ + "message", + "react", + "--channel", + "signal", + "--target", + "signal:group:abc123", + "--message-id", + "1737630212345", + "--emoji", + "✅", + "--target-author-uuid", + "123e4567-e89b-12d3-a456-426614174000", + ], + }, + ])("$label", async ({ argv }) => { + await expect(runProgram(argv)).rejects.toThrow("exit"); expect(messageCommand).toHaveBeenCalled(); }); it("runs status command", async () => { - const program = buildProgram(); - await program.parseAsync(["status"], { from: "user" }); + await runProgram(["status"]); expect(statusCommand).toHaveBeenCalled(); }); it("registers memory command", () => { - const program = buildProgram(); + const program = createProgram(); const names = program.commands.map((command) => command.name()); expect(names).toContain("memory"); }); - it("runs tui without overriding timeout", async () => { - const program = buildProgram(); - await program.parseAsync(["tui"], { from: "user" }); - expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: undefined })); - }); - - it("runs tui with explicit timeout override", async () => { - const program = buildProgram(); - await program.parseAsync(["tui", "--timeout-ms", "45000"], { - from: "user", - }); - expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 45000 })); - }); - - it("warns and ignores invalid tui timeout override", async () => { - const program = buildProgram(); - await program.parseAsync(["tui", "--timeout-ms", "nope"], { from: "user" }); - expect(runtime.error).toHaveBeenCalledWith('warning: invalid --timeout-ms "nope"; ignoring'); - expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: undefined })); + it.each([ + { + label: "runs tui without overriding timeout", + argv: ["tui"], + expectedTimeoutMs: undefined, + expectedWarning: undefined, + }, + { + label: "runs tui with explicit timeout override", + argv: ["tui", "--timeout-ms", "45000"], + expectedTimeoutMs: 45000, + expectedWarning: undefined, + }, + { + label: "warns and ignores invalid tui timeout override", + argv: ["tui", "--timeout-ms", "nope"], + expectedTimeoutMs: undefined, + expectedWarning: 'warning: invalid --timeout-ms "nope"; ignoring', + }, + ])("$label", async ({ argv, expectedTimeoutMs, expectedWarning }) => { + await runProgram(argv); + if (expectedWarning) { + expect(runtime.error).toHaveBeenCalledWith(expectedWarning); + } + expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: expectedTimeoutMs })); }); it("runs config alias as configure", async () => { - const program = buildProgram(); - await program.parseAsync(["config"], { from: "user" }); + await runProgram(["config"]); expect(configureCommand).toHaveBeenCalled(); }); - it("runs setup without wizard flags", async () => { - const program = buildProgram(); - await program.parseAsync(["setup"], { from: "user" }); - expect(setupCommand).toHaveBeenCalled(); - expect(onboardCommand).not.toHaveBeenCalled(); - }); - - it("runs setup wizard when wizard flags are present", async () => { - const program = buildProgram(); - await program.parseAsync(["setup", "--remote-url", "ws://example"], { - from: "user", - }); - expect(onboardCommand).toHaveBeenCalled(); - expect(setupCommand).not.toHaveBeenCalled(); + it.each([ + { + label: "runs setup without wizard flags", + argv: ["setup"], + expectSetupCalled: true, + expectOnboardCalled: false, + }, + { + label: "runs setup wizard when wizard flags are present", + argv: ["setup", "--remote-url", "ws://example"], + expectSetupCalled: false, + expectOnboardCalled: true, + }, + ])("$label", async ({ argv, expectSetupCalled, expectOnboardCalled }) => { + await runProgram(argv); + expect(setupCommand).toHaveBeenCalledTimes(expectSetupCalled ? 1 : 0); + expect(onboardCommand).toHaveBeenCalledTimes(expectOnboardCalled ? 1 : 0); }); it("passes auth api keys to onboard", async () => { @@ -168,11 +177,14 @@ describe("cli program (smoke)", () => { ] as const; for (const entry of cases) { - const program = buildProgram(); - await program.parseAsync( - ["onboard", "--non-interactive", "--auth-choice", entry.authChoice, entry.flag, entry.key], - { from: "user" }, - ); + await runProgram([ + "onboard", + "--non-interactive", + "--auth-choice", + entry.authChoice, + entry.flag, + entry.key, + ]); expect(onboardCommand).toHaveBeenCalledWith( expect.objectContaining({ nonInteractive: true, @@ -186,26 +198,22 @@ describe("cli program (smoke)", () => { }); it("passes custom provider flags to onboard", async () => { - const program = buildProgram(); - await program.parseAsync( - [ - "onboard", - "--non-interactive", - "--auth-choice", - "custom-api-key", - "--custom-base-url", - "https://llm.example.com/v1", - "--custom-api-key", - "sk-custom-test", - "--custom-model-id", - "foo-large", - "--custom-provider-id", - "my-custom", - "--custom-compatibility", - "anthropic", - ], - { from: "user" }, - ); + await runProgram([ + "onboard", + "--non-interactive", + "--auth-choice", + "custom-api-key", + "--custom-base-url", + "https://llm.example.com/v1", + "--custom-api-key", + "sk-custom-test", + "--custom-model-id", + "foo-large", + "--custom-provider-id", + "my-custom", + "--custom-compatibility", + "anthropic", + ]); expect(onboardCommand).toHaveBeenCalledWith( expect.objectContaining({ @@ -221,22 +229,27 @@ describe("cli program (smoke)", () => { ); }); - it("runs channels login", async () => { - const program = buildProgram(); - await program.parseAsync(["channels", "login", "--account", "work"], { - from: "user", - }); - expect(runChannelLogin).toHaveBeenCalledWith( - { channel: undefined, account: "work", verbose: false }, - runtime, - ); - }); - - it("runs channels logout", async () => { - const program = buildProgram(); - await program.parseAsync(["channels", "logout", "--account", "work"], { - from: "user", - }); - expect(runChannelLogout).toHaveBeenCalledWith({ channel: undefined, account: "work" }, runtime); + it.each([ + { + label: "runs channels login", + argv: ["channels", "login", "--account", "work"], + expectCall: () => + expect(runChannelLogin).toHaveBeenCalledWith( + { channel: undefined, account: "work", verbose: false }, + runtime, + ), + }, + { + label: "runs channels logout", + argv: ["channels", "logout", "--account", "work"], + expectCall: () => + expect(runChannelLogout).toHaveBeenCalledWith( + { channel: undefined, account: "work" }, + runtime, + ), + }, + ])("$label", async ({ argv, expectCall }) => { + await runProgram(argv); + expectCall(); }); }); diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index 4f1698d4ec..7f87bc5a7b 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -39,6 +39,18 @@ const testProgramContext: ProgramContext = { }; describe("command-registry", () => { + const createProgram = () => new Command(); + + const withProcessArgv = async (argv: string[], run: () => Promise) => { + const prevArgv = process.argv; + process.argv = argv; + try { + await run(); + } finally { + process.argv = prevArgv; + } + }; + it("includes both agent and agents in core CLI command names", () => { const names = getCoreCliCommandNames(); expect(names).toContain("agent"); @@ -46,7 +58,7 @@ describe("command-registry", () => { }); it("registerCoreCliByName resolves agents to the agent entry", async () => { - const program = new Command(); + const program = createProgram(); const found = await registerCoreCliByName(program, testProgramContext, "agents"); expect(found).toBe(true); const agentsCmd = program.commands.find((c) => c.name() === "agents"); @@ -57,20 +69,20 @@ describe("command-registry", () => { }); it("registerCoreCliByName returns false for unknown commands", async () => { - const program = new Command(); + const program = createProgram(); const found = await registerCoreCliByName(program, testProgramContext, "nonexistent"); expect(found).toBe(false); }); it("registers doctor placeholder for doctor primary command", () => { - const program = new Command(); + const program = createProgram(); registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "doctor"]); expect(program.commands.map((command) => command.name())).toEqual(["doctor"]); }); it("treats maintenance commands as top-level builtins", async () => { - const program = new Command(); + const program = createProgram(); expect(await registerCoreCliByName(program, testProgramContext, "doctor")).toBe(true); @@ -83,17 +95,12 @@ describe("command-registry", () => { }); it("registers grouped core entry placeholders without duplicate command errors", async () => { - const program = new Command(); + const program = createProgram(); registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "vitest"]); - - const prevArgv = process.argv; - process.argv = ["node", "openclaw", "status"]; - try { - program.exitOverride(); + program.exitOverride(); + await withProcessArgv(["node", "openclaw", "status"], async () => { await program.parseAsync(["node", "openclaw", "status"]); - } finally { - process.argv = prevArgv; - } + }); const names = program.commands.map((command) => command.name()); expect(names).toContain("status"); diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index d597f7d192..0ec070e384 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -29,22 +29,30 @@ function makeRuntime() { } describe("ensureConfigReady", () => { + async function runEnsureConfigReady(commandPath: string[]) { + vi.resetModules(); + const { ensureConfigReady } = await import("./config-guard.js"); + await ensureConfigReady({ runtime: makeRuntime() as never, commandPath }); + } + beforeEach(() => { vi.clearAllMocks(); readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot()); }); - it("skips doctor flow for read-only fast path commands", async () => { - vi.resetModules(); - const { ensureConfigReady } = await import("./config-guard.js"); - await ensureConfigReady({ runtime: makeRuntime() as never, commandPath: ["status"] }); - expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled(); - }); - - it("runs doctor flow for commands that may mutate state", async () => { - vi.resetModules(); - const { ensureConfigReady } = await import("./config-guard.js"); - await ensureConfigReady({ runtime: makeRuntime() as never, commandPath: ["message"] }); - expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1); + it.each([ + { + name: "skips doctor flow for read-only fast path commands", + commandPath: ["status"], + expectedDoctorCalls: 0, + }, + { + name: "runs doctor flow for commands that may mutate state", + commandPath: ["message"], + expectedDoctorCalls: 1, + }, + ])("$name", async ({ commandPath, expectedDoctorCalls }) => { + await runEnsureConfigReady(commandPath); + expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(expectedDoctorCalls); }); }); diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index 37c4160afb..af5c797819 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const doctorCommand = vi.fn(); const dashboardCommand = vi.fn(); @@ -32,7 +32,19 @@ vi.mock("../../runtime.js", () => ({ defaultRuntime: runtime, })); +let registerMaintenanceCommands: typeof import("./register.maintenance.js").registerMaintenanceCommands; + +beforeAll(async () => { + ({ registerMaintenanceCommands } = await import("./register.maintenance.js")); +}); + describe("registerMaintenanceCommands doctor action", () => { + async function runMaintenanceCli(args: string[]) { + const program = new Command(); + registerMaintenanceCommands(program); + await program.parseAsync(args, { from: "user" }); + } + beforeEach(() => { vi.clearAllMocks(); }); @@ -40,11 +52,7 @@ describe("registerMaintenanceCommands doctor action", () => { it("exits with code 0 after successful doctor run", async () => { doctorCommand.mockResolvedValue(undefined); - const { registerMaintenanceCommands } = await import("./register.maintenance.js"); - const program = new Command(); - registerMaintenanceCommands(program); - - await program.parseAsync(["doctor", "--non-interactive", "--yes"], { from: "user" }); + await runMaintenanceCli(["doctor", "--non-interactive", "--yes"]); expect(doctorCommand).toHaveBeenCalledWith( runtime, @@ -59,11 +67,7 @@ describe("registerMaintenanceCommands doctor action", () => { it("exits with code 1 when doctor fails", async () => { doctorCommand.mockRejectedValue(new Error("doctor failed")); - const { registerMaintenanceCommands } = await import("./register.maintenance.js"); - const program = new Command(); - registerMaintenanceCommands(program); - - await program.parseAsync(["doctor"], { from: "user" }); + await runMaintenanceCli(["doctor"]); expect(runtime.error).toHaveBeenCalledWith("Error: doctor failed"); expect(runtime.exit).toHaveBeenCalledWith(1); diff --git a/src/cli/program/register.subclis.e2e.test.ts b/src/cli/program/register.subclis.e2e.test.ts index 052818c3c7..370316b47e 100644 --- a/src/cli/program/register.subclis.e2e.test.ts +++ b/src/cli/program/register.subclis.e2e.test.ts @@ -27,6 +27,16 @@ describe("registerSubCliCommands", () => { const originalArgv = process.argv; const originalEnv = { ...process.env }; + const createRegisteredProgram = (argv: string[], name?: string) => { + process.argv = argv; + const program = new Command(); + if (name) { + program.name(name); + } + registerSubCliCommands(program, process.argv); + return program; + }; + beforeEach(() => { process.env = { ...originalEnv }; delete process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS; @@ -42,9 +52,7 @@ describe("registerSubCliCommands", () => { }); it("registers only the primary placeholder and dispatches", async () => { - process.argv = ["node", "openclaw", "acp"]; - const program = new Command(); - registerSubCliCommands(program, process.argv); + const program = createRegisteredProgram(["node", "openclaw", "acp"]); expect(program.commands.map((cmd) => cmd.name())).toEqual(["acp"]); @@ -55,9 +63,7 @@ describe("registerSubCliCommands", () => { }); it("registers placeholders for all subcommands when no primary", () => { - process.argv = ["node", "openclaw"]; - const program = new Command(); - registerSubCliCommands(program, process.argv); + const program = createRegisteredProgram(["node", "openclaw"]); const names = program.commands.map((cmd) => cmd.name()); expect(names).toContain("acp"); @@ -67,10 +73,7 @@ describe("registerSubCliCommands", () => { }); it("re-parses argv for lazy subcommands", async () => { - process.argv = ["node", "openclaw", "nodes", "list"]; - const program = new Command(); - program.name("openclaw"); - registerSubCliCommands(program, process.argv); + const program = createRegisteredProgram(["node", "openclaw", "nodes", "list"], "openclaw"); expect(program.commands.map((cmd) => cmd.name())).toEqual(["nodes"]); @@ -81,10 +84,7 @@ describe("registerSubCliCommands", () => { }); it("replaces placeholder when registering a subcommand by name", async () => { - process.argv = ["node", "openclaw", "acp", "--help"]; - const program = new Command(); - program.name("openclaw"); - registerSubCliCommands(program, process.argv); + const program = createRegisteredProgram(["node", "openclaw", "acp", "--help"], "openclaw"); await registerSubCliByName(program, "acp"); diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 78fdf1ecb9..22c6e02016 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -47,6 +47,21 @@ function createRemoteQrConfig(params?: { withTailscale?: boolean }) { } describe("registerQrCli", () => { + function createProgram() { + const program = new Command(); + registerQrCli(program); + return program; + } + + async function runQr(args: string[]) { + const program = createProgram(); + await program.parseAsync(["qr", ...args], { from: "user" }); + } + + async function expectQrExit(args: string[]) { + await expect(runQr(args)).rejects.toThrow("exit"); + } + beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); @@ -68,10 +83,7 @@ describe("registerQrCli", () => { }, }); - const program = new Command(); - registerQrCli(program); - - await program.parseAsync(["qr", "--setup-code-only"], { from: "user" }); + await runQr(["--setup-code-only"]); const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", @@ -90,10 +102,7 @@ describe("registerQrCli", () => { }, }); - const program = new Command(); - registerQrCli(program); - - await program.parseAsync(["qr"], { from: "user" }); + await runQr([]); expect(qrGenerate).toHaveBeenCalledTimes(1); const output = runtime.log.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); @@ -111,12 +120,7 @@ describe("registerQrCli", () => { }, }); - const program = new Command(); - registerQrCli(program); - - await program.parseAsync(["qr", "--setup-code-only", "--token", "override-token"], { - from: "user", - }); + await runQr(["--setup-code-only", "--token", "override-token"]); const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", @@ -133,10 +137,7 @@ describe("registerQrCli", () => { }, }); - const program = new Command(); - registerQrCli(program); - - await expect(program.parseAsync(["qr"], { from: "user" })).rejects.toThrow("exit"); + await expectQrExit([]); const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); expect(output).toContain("only bound to loopback"); @@ -144,10 +145,7 @@ describe("registerQrCli", () => { it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => { loadConfig.mockReturnValue(createRemoteQrConfig()); - - const program = new Command(); - registerQrCli(program); - await program.parseAsync(["qr", "--setup-code-only", "--remote"], { from: "user" }); + await runQr(["--setup-code-only", "--remote"]); const expected = encodePairingSetupCode({ url: "wss://remote.example.com:444", @@ -156,12 +154,18 @@ describe("registerQrCli", () => { expect(runtime.log).toHaveBeenCalledWith(expected); }); - it("reports gateway.remote.url as source in --remote json output", async () => { - loadConfig.mockReturnValue(createRemoteQrConfig()); + it.each([ + { name: "without tailscale configured", withTailscale: false }, + { name: "when tailscale is configured", withTailscale: true }, + ])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => { + loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale })); + runCommandWithTimeout.mockResolvedValue({ + code: 0, + stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}', + stderr: "", + }); - const program = new Command(); - registerQrCli(program); - await program.parseAsync(["qr", "--json", "--remote"], { from: "user" }); + await runQr(["--json", "--remote"]); const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { setupCode?: string; @@ -172,6 +176,7 @@ describe("registerQrCli", () => { expect(payload.gatewayUrl).toBe("wss://remote.example.com:444"); expect(payload.auth).toBe("token"); expect(payload.urlSource).toBe("gateway.remote.url"); + expect(runCommandWithTimeout).not.toHaveBeenCalled(); }); it("errors when --remote is set but no remote URL is configured", async () => { @@ -183,33 +188,8 @@ describe("registerQrCli", () => { }, }); - const program = new Command(); - registerQrCli(program); - - await expect(program.parseAsync(["qr", "--remote"], { from: "user" })).rejects.toThrow("exit"); - + await expectQrExit(["--remote"]); const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); expect(output).toContain("qr --remote requires"); }); - - it("prefers gateway.remote.url over tailscale when --remote is set", async () => { - loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: true })); - runCommandWithTimeout.mockResolvedValue({ - code: 0, - stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}', - stderr: "", - }); - - const program = new Command(); - registerQrCli(program); - await program.parseAsync(["qr", "--json", "--remote"], { from: "user" }); - - const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as { - gatewayUrl?: string; - urlSource?: string; - }; - expect(payload.gatewayUrl).toBe("wss://remote.example.com:444"); - expect(payload.urlSource).toBe("gateway.remote.url"); - expect(runCommandWithTimeout).not.toHaveBeenCalled(); - }); }); diff --git a/src/cli/update-cli.option-collisions.test.ts b/src/cli/update-cli.option-collisions.test.ts index 7ea0c672bb..c0dd2d8840 100644 --- a/src/cli/update-cli.option-collisions.test.ts +++ b/src/cli/update-cli.option-collisions.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runRegisteredCli } from "../test-utils/command-runner.js"; const updateCommand = vi.fn(async (_opts: unknown) => {}); const updateStatusCommand = vi.fn(async (_opts: unknown) => {}); @@ -28,6 +29,12 @@ vi.mock("../runtime.js", () => ({ })); describe("update cli option collisions", () => { + let registerUpdateCli: typeof import("./update-cli.js").registerUpdateCli; + + beforeAll(async () => { + ({ registerUpdateCli } = await import("./update-cli.js")); + }); + beforeEach(() => { updateCommand.mockClear(); updateStatusCommand.mockClear(); @@ -38,11 +45,10 @@ describe("update cli option collisions", () => { }); it("forwards parent-captured --json/--timeout to `update status`", async () => { - const { registerUpdateCli } = await import("./update-cli.js"); - const program = new Command(); - registerUpdateCli(program); - - await program.parseAsync(["update", "status", "--json", "--timeout", "9"], { from: "user" }); + await runRegisteredCli({ + register: registerUpdateCli as (program: Command) => void, + argv: ["update", "status", "--json", "--timeout", "9"], + }); expect(updateStatusCommand).toHaveBeenCalledWith( expect.objectContaining({ @@ -53,11 +59,10 @@ describe("update cli option collisions", () => { }); it("forwards parent-captured --timeout to `update wizard`", async () => { - const { registerUpdateCli } = await import("./update-cli.js"); - const program = new Command(); - registerUpdateCli(program); - - await program.parseAsync(["update", "wizard", "--timeout", "13"], { from: "user" }); + await runRegisteredCli({ + register: registerUpdateCli as (program: Command) => void, + argv: ["update", "wizard", "--timeout", "13"], + }); expect(updateWizardCommand).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 9b755a7c6d..a5d9cfc4a2 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1,7 +1,5 @@ -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +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 { captureEnv } from "../test-utils/env.js"; @@ -120,23 +118,15 @@ const { updateCommand, registerUpdateCli, updateStatusCommand, updateWizardComma await import("./update-cli.js"); describe("update-cli", () => { - let fixtureRoot = ""; + const fixtureRoot = "/tmp/openclaw-update-tests"; let fixtureCount = 0; - const createCaseDir = async (prefix: string) => { + const createCaseDir = (prefix: string) => { const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); // Tests only need a stable path; the directory does not have to exist because all I/O is mocked. return dir; }; - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-tests-")); - }); - - afterAll(async () => { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - }); - const baseConfig = {} as OpenClawConfig; const baseSnapshot: ConfigFileSnapshot = { path: "/tmp/openclaw-config.json", @@ -186,8 +176,17 @@ describe("update-cli", () => { return call; }; + const makeOkUpdateResult = (overrides: Partial = {}): UpdateRunResult => + ({ + status: "ok", + mode: "git", + steps: [], + durationMs: 100, + ...overrides, + }) as UpdateRunResult; + const setupNonInteractiveDowngrade = async () => { - const tempDir = await createCaseDir("openclaw-update"); + const tempDir = createCaseDir("openclaw-update"); setTty(false); readPackageVersion.mockResolvedValue("2.0.0"); @@ -332,55 +331,53 @@ describe("update-cli", () => { expect(parsed.channel.value).toBe("stable"); }); - it("defaults to dev channel for git installs when unset", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }); + it.each([ + { + name: "defaults to dev channel for git installs when unset", + mode: "git" as const, + options: {}, + prepare: async () => {}, + expectedChannel: "dev" as const, + expectedTag: undefined as string | undefined, + }, + { + name: "defaults to stable channel for package installs when unset", + mode: "npm" as const, + options: { yes: true }, + prepare: async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + }, + expectedChannel: "stable" as const, + expectedTag: "latest", + }, + { + name: "uses stored beta channel when configured", + mode: "git" as const, + options: {}, + prepare: async () => { + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + config: { update: { channel: "beta" } } as OpenClawConfig, + }); + }, + expectedChannel: "beta" as const, + expectedTag: undefined as string | undefined, + }, + ])("$name", async ({ mode, options, prepare, expectedChannel, expectedTag }) => { + await prepare(); + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode })); - await updateCommand({}); + await updateCommand(options); - expectUpdateCallChannel("dev"); - }); - - it("defaults to stable channel for package installs when unset", async () => { - const tempDir = await createCaseDir("openclaw-update"); - - mockPackageInstallStatus(tempDir); - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "npm", - steps: [], - durationMs: 100, - }); - - await updateCommand({ yes: true }); - - const call = expectUpdateCallChannel("stable"); - expect(call?.tag).toBe("latest"); - }); - - it("uses stored beta channel when configured", async () => { - vi.mocked(readConfigFileSnapshot).mockResolvedValue({ - ...baseSnapshot, - config: { update: { channel: "beta" } } as OpenClawConfig, - }); - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }); - - await updateCommand({}); - - expectUpdateCallChannel("beta"); + const call = expectUpdateCallChannel(expectedChannel); + if (expectedTag !== undefined) { + expect(call?.tag).toBe(expectedTag); + } }); it("falls back to latest when beta tag is older than release", async () => { - const tempDir = await createCaseDir("openclaw-update"); + const tempDir = createCaseDir("openclaw-update"); mockPackageInstallStatus(tempDir); vi.mocked(readConfigFileSnapshot).mockResolvedValue({ @@ -391,12 +388,11 @@ describe("update-cli", () => { tag: "latest", version: "1.2.3-1", }); - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "npm", - steps: [], - durationMs: 100, - }); + vi.mocked(runGatewayUpdate).mockResolvedValue( + makeOkUpdateResult({ + mode: "npm", + }), + ); await updateCommand({}); @@ -405,15 +401,14 @@ describe("update-cli", () => { }); it("honors --tag override", async () => { - const tempDir = await createCaseDir("openclaw-update"); + const tempDir = createCaseDir("openclaw-update"); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "npm", - steps: [], - durationMs: 100, - }); + vi.mocked(runGatewayUpdate).mockResolvedValue( + makeOkUpdateResult({ + mode: "npm", + }), + ); await updateCommand({ tag: "next" }); @@ -422,14 +417,7 @@ describe("update-cli", () => { }); it("updateCommand outputs JSON when --json is set", async () => { - const mockResult: UpdateRunResult = { - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }; - - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); vi.mocked(defaultRuntime.log).mockClear(); await updateCommand({ json: true }); @@ -464,14 +452,7 @@ describe("update-cli", () => { }); it("updateCommand restarts daemon by default", async () => { - const mockResult: UpdateRunResult = { - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }; - - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); vi.mocked(runDaemonRestart).mockResolvedValue(true); await updateCommand({}); @@ -480,18 +461,11 @@ describe("update-cli", () => { }); it("updateCommand continues after doctor sub-step and clears update flag", async () => { - const mockResult: UpdateRunResult = { - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }; - const envSnapshot = captureEnv(["OPENCLAW_UPDATE_IN_PROGRESS"]); const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); try { delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); vi.mocked(runDaemonRestart).mockResolvedValue(true); vi.mocked(doctorCommand).mockResolvedValue(undefined); vi.mocked(defaultRuntime.log).mockClear(); @@ -515,14 +489,7 @@ describe("update-cli", () => { }); it("updateCommand skips restart when --no-restart is set", async () => { - const mockResult: UpdateRunResult = { - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }; - - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); await updateCommand({ restart: false }); @@ -530,14 +497,7 @@ describe("update-cli", () => { }); it("updateCommand skips success message when restart does not run", async () => { - const mockResult: UpdateRunResult = { - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }; - - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); vi.mocked(runDaemonRestart).mockResolvedValue(false); vi.mocked(defaultRuntime.log).mockClear(); @@ -547,35 +507,35 @@ describe("update-cli", () => { expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(false); }); - it("updateCommand validates timeout option", async () => { + it.each([ + { + name: "update command", + run: async () => await updateCommand({ timeout: "invalid" }), + requireTty: false, + }, + { + name: "update status command", + run: async () => await updateStatusCommand({ timeout: "invalid" }), + requireTty: false, + }, + { + name: "update wizard command", + run: async () => await updateWizardCommand({ timeout: "invalid" }), + requireTty: true, + }, + ])("validates timeout option for $name", async ({ run, requireTty }) => { + setTty(requireTty); vi.mocked(defaultRuntime.error).mockClear(); vi.mocked(defaultRuntime.exit).mockClear(); - await updateCommand({ timeout: "invalid" }); - - expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout")); - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); - }); - - it("updateStatusCommand validates timeout option", async () => { - vi.mocked(defaultRuntime.error).mockClear(); - vi.mocked(defaultRuntime.exit).mockClear(); - - await updateStatusCommand({ timeout: "invalid" }); + await run(); expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout")); expect(defaultRuntime.exit).toHaveBeenCalledWith(1); }); it("persists update channel when --channel is set", async () => { - const mockResult: UpdateRunResult = { - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }; - - vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult); + vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); await updateCommand({ channel: "beta" }); @@ -586,26 +546,31 @@ describe("update-cli", () => { expect(call?.update?.channel).toBe("beta"); }); - it("requires confirmation on downgrade when non-interactive", async () => { + it.each([ + { + name: "requires confirmation without --yes", + options: {}, + shouldExit: true, + shouldRunUpdate: false, + }, + { + name: "allows downgrade with --yes", + options: { yes: true }, + shouldExit: false, + shouldRunUpdate: true, + }, + ])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunUpdate }) => { await setupNonInteractiveDowngrade(); + await updateCommand(options); - await updateCommand({}); - - expect(defaultRuntime.error).toHaveBeenCalledWith( - expect.stringContaining("Downgrade confirmation required."), + const downgradeMessageSeen = vi + .mocked(defaultRuntime.error) + .mock.calls.some((call) => String(call[0]).includes("Downgrade confirmation required.")); + expect(downgradeMessageSeen).toBe(shouldExit); + expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe( + shouldExit, ); - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); - }); - - it("allows downgrade with --yes in non-interactive mode", async () => { - await setupNonInteractiveDowngrade(); - - await updateCommand({ yes: true }); - - expect(defaultRuntime.error).not.toHaveBeenCalledWith( - expect.stringContaining("Downgrade confirmation required."), - ); - expect(runGatewayUpdate).toHaveBeenCalled(); + expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate); }); it("updateWizardCommand requires a TTY", async () => { @@ -621,19 +586,8 @@ describe("update-cli", () => { expect(defaultRuntime.exit).toHaveBeenCalledWith(1); }); - it("updateWizardCommand validates timeout option", async () => { - setTty(true); - vi.mocked(defaultRuntime.error).mockClear(); - vi.mocked(defaultRuntime.exit).mockClear(); - - await updateWizardCommand({ timeout: "invalid" }); - - expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout")); - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); - }); - it("updateWizardCommand offers dev checkout and forwards selections", async () => { - const tempDir = await createCaseDir("openclaw-update-wizard"); + const tempDir = createCaseDir("openclaw-update-wizard"); const envSnapshot = captureEnv(["OPENCLAW_GIT_DIR"]); try { setTty(true); diff --git a/src/commands/health.snapshot.e2e.test.ts b/src/commands/health.snapshot.e2e.test.ts index 27f54e694c..8b1231b670 100644 --- a/src/commands/health.snapshot.e2e.test.ts +++ b/src/commands/health.snapshot.e2e.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -99,13 +99,19 @@ async function runSuccessfulTelegramProbe( return { calls, telegram }; } +let createPluginRuntime: typeof import("../plugins/runtime/index.js").createPluginRuntime; +let setTelegramRuntime: typeof import("../../extensions/telegram/src/runtime.js").setTelegramRuntime; + describe("getHealthSnapshot", () => { - beforeEach(async () => { + beforeAll(async () => { + ({ createPluginRuntime } = await import("../plugins/runtime/index.js")); + ({ setTelegramRuntime } = await import("../../extensions/telegram/src/runtime.js")); + }); + + beforeEach(() => { setActivePluginRegistry( createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), ); - const { createPluginRuntime } = await import("../plugins/runtime/index.js"); - const { setTelegramRuntime } = await import("../../extensions/telegram/src/runtime.js"); setTelegramRuntime(createPluginRuntime()); }); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index acdd7ee654..9cdaac1d7d 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; let modelsListCommand: typeof import("./models/list.list-command.js").modelsListCommand; +let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegistry; +let toModelRow: typeof import("./models/list.registry.js").toModelRow; const loadConfig = vi.fn(); const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined); @@ -274,6 +276,7 @@ describe("models list/status", () => { beforeAll(async () => { ({ modelsListCommand } = await import("./models/list.list-command.js")); + ({ loadModelRegistry, toModelRow } = await import("./models/list.registry.js")); }); it("models list syncs auth-profiles into auth.json before availability checks", async () => { @@ -309,17 +312,12 @@ describe("models list/status", () => { expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7"); }); - it("models list provider filter normalizes z.ai alias", async () => { - await expectZaiProviderFilter("z.ai"); - }); - - it("models list provider filter normalizes Z.AI alias casing", async () => { - await expectZaiProviderFilter("Z.AI"); - }); - - it("models list provider filter normalizes z-ai alias", async () => { - await expectZaiProviderFilter("z-ai"); - }); + it.each(["z.ai", "Z.AI", "z-ai"] as const)( + "models list provider filter normalizes %s alias", + async (provider) => { + await expectZaiProviderFilter(provider); + }, + ); it("models list marks auth as unavailable when ZAI key is missing", async () => { setDefaultZaiRegistry({ available: false }); @@ -331,57 +329,67 @@ describe("models list/status", () => { expect(payload.models[0]?.available).toBe(false); }); - it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => { - const payload = await runGoogleAntigravityListCase({ + it.each([ + { + name: "thinking", configuredModelId: "claude-opus-4-6-thinking", templateId: "claude-opus-4-5-thinking", templateName: "Claude Opus 4.5 Thinking", - }); - expectAntigravityModel(payload, { - key: "google-antigravity/claude-opus-4-6-thinking", - available: false, - includesTags: true, - }); - }); - - it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => { - const payload = await runGoogleAntigravityListCase({ + expectedKey: "google-antigravity/claude-opus-4-6-thinking", + }, + { + name: "non-thinking", configuredModelId: "claude-opus-4-6", templateId: "claude-opus-4-5", templateName: "Claude Opus 4.5", - }); - expectAntigravityModel(payload, { - key: "google-antigravity/claude-opus-4-6", - available: false, - includesTags: true, - }); - }); + expectedKey: "google-antigravity/claude-opus-4-6", + }, + ] as const)( + "models list resolves antigravity opus 4.6 $name from 4.5 template", + async ({ configuredModelId, templateId, templateName, expectedKey }) => { + const payload = await runGoogleAntigravityListCase({ + configuredModelId, + templateId, + templateName, + }); + expectAntigravityModel(payload, { + key: expectedKey, + available: false, + includesTags: true, + }); + }, + ); - it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => { - const payload = await runGoogleAntigravityListCase({ + it.each([ + { + name: "thinking", configuredModelId: "claude-opus-4-6-thinking", templateId: "claude-opus-4-5-thinking", templateName: "Claude Opus 4.5 Thinking", - available: true, - }); - expectAntigravityModel(payload, { - key: "google-antigravity/claude-opus-4-6-thinking", - available: true, - }); - }); - - it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => { - const payload = await runGoogleAntigravityListCase({ + expectedKey: "google-antigravity/claude-opus-4-6-thinking", + }, + { + name: "non-thinking", configuredModelId: "claude-opus-4-6", templateId: "claude-opus-4-5", templateName: "Claude Opus 4.5", - available: true, - }); - expectAntigravityModel(payload, { - key: "google-antigravity/claude-opus-4-6", - available: true, - }); - }); + expectedKey: "google-antigravity/claude-opus-4-6", + }, + ] as const)( + "models list marks synthesized antigravity opus 4.6 $name as available when template is available", + async ({ configuredModelId, templateId, templateName, expectedKey }) => { + const payload = await runGoogleAntigravityListCase({ + configuredModelId, + templateId, + templateName, + available: true, + }); + expectAntigravityModel(payload, { + key: expectedKey, + available: true, + }); + }, + ); it("models list prefers registry availability over provider auth heuristics", async () => { const payload = await runGoogleAntigravityListCase({ @@ -472,13 +480,10 @@ describe("models list/status", () => { makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"), ]; - const { loadModelRegistry } = await import("./models/list.registry.js"); await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable"); }); it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { - const { toModelRow } = await import("./models/list.registry.js"); - const row = toModelRow({ model: makeGoogleAntigravityTemplate( "claude-opus-4-6-thinking", diff --git a/src/commands/onboard-interactive.e2e.test.ts b/src/commands/onboard-interactive.e2e.test.ts deleted file mode 100644 index ec1a4dcb3e..0000000000 --- a/src/commands/onboard-interactive.e2e.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; - -const createClackPrompterMock = vi.hoisted(() => vi.fn()); -const runOnboardingWizardMock = vi.hoisted(() => vi.fn()); -const restoreTerminalStateMock = vi.hoisted(() => vi.fn()); - -vi.mock("../wizard/clack-prompter.js", () => ({ createClackPrompter: createClackPrompterMock })); -vi.mock("../wizard/onboarding.js", () => ({ runOnboardingWizard: runOnboardingWizardMock })); -vi.mock("../terminal/restore.js", () => ({ restoreTerminalState: restoreTerminalStateMock })); - -import { WizardCancelledError } from "../wizard/prompts.js"; -import { runInteractiveOnboarding } from "./onboard-interactive.js"; - -const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), -}; - -describe("runInteractiveOnboarding", () => { - beforeEach(() => { - createClackPrompterMock.mockReset(); - runOnboardingWizardMock.mockReset(); - restoreTerminalStateMock.mockReset(); - (runtime.log as ReturnType).mockClear(); - (runtime.error as ReturnType).mockClear(); - (runtime.exit as ReturnType).mockClear(); - - createClackPrompterMock.mockReturnValue({}); - runOnboardingWizardMock.mockResolvedValue(undefined); - }); - - it("exits with code 1 when the wizard is cancelled", async () => { - runOnboardingWizardMock.mockRejectedValue(new WizardCancelledError()); - - await runInteractiveOnboarding({} as never, runtime); - - expect(runtime.exit).toHaveBeenCalledWith(1); - expect(restoreTerminalStateMock).toHaveBeenCalledWith("onboarding finish", { - resumeStdinIfPaused: false, - }); - }); - - it("rethrows non-cancel errors", async () => { - const err = new Error("boom"); - runOnboardingWizardMock.mockRejectedValue(err); - - await expect(runInteractiveOnboarding({} as never, runtime)).rejects.toThrow("boom"); - - expect(runtime.exit).not.toHaveBeenCalled(); - expect(restoreTerminalStateMock).toHaveBeenCalledWith("onboarding finish", { - resumeStdinIfPaused: false, - }); - }); -}); diff --git a/src/commands/onboard-interactive.test.ts b/src/commands/onboard-interactive.test.ts index 4a1dbb44ff..4dd8d9b4dd 100644 --- a/src/commands/onboard-interactive.test.ts +++ b/src/commands/onboard-interactive.test.ts @@ -69,4 +69,17 @@ describe("runInteractiveOnboarding", () => { Number.MAX_SAFE_INTEGER; expect(restoreOrder).toBeLessThan(exitOrder); }); + + it("rethrows non-cancel errors after restoring terminal state", async () => { + const runtime = makeRuntime(); + const err = new Error("boom"); + mocks.runOnboardingWizard.mockRejectedValueOnce(err); + + await expect(runInteractiveOnboarding({} as never, runtime)).rejects.toThrow("boom"); + + expect(runtime.exit).not.toHaveBeenCalled(); + expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish", { + resumeStdinIfPaused: false, + }); + }); }); diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index c808b16d59..69ccc14c57 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -29,33 +29,29 @@ function asMessage(payload: Record): Message { } describe("resolveDiscordMessageChannelId", () => { - it("uses message.channelId when present", () => { - const channelId = resolveDiscordMessageChannelId({ - message: asMessage({ channelId: " 123 " }), - }); - expect(channelId).toBe("123"); - }); - - it("falls back to message.channel_id", () => { - const channelId = resolveDiscordMessageChannelId({ - message: asMessage({ channel_id: " 234 " }), - }); - expect(channelId).toBe("234"); - }); - - it("falls back to message.rawData.channel_id", () => { - const channelId = resolveDiscordMessageChannelId({ - message: asMessage({ rawData: { channel_id: "456" } }), - }); - expect(channelId).toBe("456"); - }); - - it("falls back to eventChannelId and coerces numeric values", () => { - const channelId = resolveDiscordMessageChannelId({ - message: asMessage({}), - eventChannelId: 789, - }); - expect(channelId).toBe("789"); + it.each([ + { + name: "uses message.channelId when present", + params: { message: asMessage({ channelId: " 123 " }) }, + expected: "123", + }, + { + name: "falls back to message.channel_id", + params: { message: asMessage({ channel_id: " 234 " }) }, + expected: "234", + }, + { + name: "falls back to message.rawData.channel_id", + params: { message: asMessage({ rawData: { channel_id: "456" } }) }, + expected: "456", + }, + { + name: "falls back to eventChannelId and coerces numeric values", + params: { message: asMessage({}), eventChannelId: 789 }, + expected: "789", + }, + ] as const)("$name", ({ params, expected }) => { + expect(resolveDiscordMessageChannelId(params)).toBe(expected); }); }); diff --git a/src/discord/monitor/provider.proxy.test.ts b/src/discord/monitor/provider.proxy.test.ts index 365a82de72..ba7e2f5873 100644 --- a/src/discord/monitor/provider.proxy.test.ts +++ b/src/discord/monitor/provider.proxy.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { GatewayIntents, @@ -70,6 +70,12 @@ vi.mock("ws", () => ({ })); describe("createDiscordGatewayPlugin", () => { + let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin; + + beforeAll(async () => { + ({ createDiscordGatewayPlugin } = await import("./gateway-plugin.js")); + }); + function createRuntime() { return { log: vi.fn(), @@ -87,7 +93,6 @@ describe("createDiscordGatewayPlugin", () => { }); it("uses proxy agent for gateway WebSocket when configured", async () => { - const { createDiscordGatewayPlugin } = await import("./gateway-plugin.js"); const runtime = createRuntime(); const plugin = createDiscordGatewayPlugin({ @@ -111,7 +116,6 @@ describe("createDiscordGatewayPlugin", () => { }); it("falls back to the default gateway plugin when proxy is invalid", async () => { - const { createDiscordGatewayPlugin } = await import("./gateway-plugin.js"); const runtime = createRuntime(); const plugin = createDiscordGatewayPlugin({ diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 8a017c14ce..b952ccfc8d 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -63,12 +63,15 @@ describe("runBootOnce", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); }); - it("skips when BOOT.md is empty", async () => { + it.each([ + { title: "empty", content: " \n", reason: "empty" as const }, + { title: "whitespace-only", content: "\n\t ", reason: "empty" as const }, + ])("skips when BOOT.md is $title", async ({ content, reason }) => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); - await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8"); + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({ status: "skipped", - reason: "empty", + reason, }); expect(agentCommand).not.toHaveBeenCalled(); await fs.rm(workspaceDir, { recursive: true, force: true }); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 0435ed705d..80903d093f 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -94,6 +94,11 @@ function setGatewayNetworkDefaults(port = 18789) { pickPrimaryTailnetIPv4.mockReturnValue(undefined); } +function setLocalLoopbackGatewayConfig(port = 18789) { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + setGatewayNetworkDefaults(port); +} + function makeRemotePasswordGatewayConfig(remotePassword: string, localPassword = "from-config") { return { gateway: { @@ -109,20 +114,19 @@ describe("callGateway url resolution", () => { resetGatewayCallMocks(); }); - it("keeps loopback when local bind is auto even if tailnet is present", async () => { + it.each([ + { + label: "keeps loopback when local bind is auto even if tailnet is present", + tailnetIp: "100.64.0.1", + }, + { + label: "falls back to loopback when local bind is auto without tailnet IP", + tailnetIp: undefined, + }, + ])("$label", async ({ tailnetIp }) => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); resolveGatewayPort.mockReturnValue(18800); - pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); - - await callGateway({ method: "health" }); - - expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); - }); - - it("falls back to loopback when local bind is auto without tailnet IP", async () => { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); - resolveGatewayPort.mockReturnValue(18800); - pickPrimaryTailnetIPv4.mockReturnValue(undefined); + pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp); await callGateway({ method: "health" }); @@ -199,34 +203,25 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); - it("uses least-privilege scopes by default for non-CLI callers", async () => { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); - resolveGatewayPort.mockReturnValue(18789); - pickPrimaryTailnetIPv4.mockReturnValue(undefined); - - await callGateway({ method: "health" }); - - expect(lastClientOptions?.scopes).toEqual(["operator.read"]); - }); - - it("keeps legacy admin scopes for explicit CLI callers", async () => { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); - resolveGatewayPort.mockReturnValue(18789); - pickPrimaryTailnetIPv4.mockReturnValue(undefined); - - await callGatewayCli({ method: "health" }); - - expect(lastClientOptions?.scopes).toEqual([ - "operator.admin", - "operator.approvals", - "operator.pairing", - ]); + it.each([ + { + label: "uses least-privilege scopes by default for non-CLI callers", + call: () => callGateway({ method: "health" }), + expectedScopes: ["operator.read"], + }, + { + label: "keeps legacy admin scopes for explicit CLI callers", + call: () => callGatewayCli({ method: "health" }), + expectedScopes: ["operator.admin", "operator.approvals", "operator.pairing"], + }, + ])("$label", async ({ call, expectedScopes }) => { + setLocalLoopbackGatewayConfig(); + await call(); + expect(lastClientOptions?.scopes).toEqual(expectedScopes); }); it("passes explicit scopes through, including empty arrays", async () => { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); - resolveGatewayPort.mockReturnValue(18789); - pickPrimaryTailnetIPv4.mockReturnValue(undefined); + setLocalLoopbackGatewayConfig(); await callGatewayScoped({ method: "health", scopes: ["operator.read"] }); expect(lastClientOptions?.scopes).toEqual(["operator.read"]); @@ -242,10 +237,7 @@ describe("buildGatewayConnectionDetails", () => { }); it("uses explicit url overrides and omits bind details", () => { - loadConfig.mockReturnValue({ - gateway: { mode: "local", bind: "loopback" }, - }); - resolveGatewayPort.mockReturnValue(18800); + setLocalLoopbackGatewayConfig(18800); pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); const details = buildGatewayConnectionDetails({ @@ -340,11 +332,7 @@ describe("buildGatewayConnectionDetails", () => { }); it("allows ws:// for loopback addresses in local mode", () => { - loadConfig.mockReturnValue({ - gateway: { mode: "local", bind: "loopback" }, - }); - resolveGatewayPort.mockReturnValue(18789); - pickPrimaryTailnetIPv4.mockReturnValue(undefined); + setLocalLoopbackGatewayConfig(); const details = buildGatewayConnectionDetails(); @@ -365,11 +353,7 @@ describe("callGateway error details", () => { startMode = "close"; closeCode = 1006; closeReason = ""; - loadConfig.mockReturnValue({ - gateway: { mode: "local", bind: "loopback" }, - }); - resolveGatewayPort.mockReturnValue(18789); - pickPrimaryTailnetIPv4.mockReturnValue(undefined); + setLocalLoopbackGatewayConfig(); let err: Error | null = null; try { @@ -386,11 +370,7 @@ describe("callGateway error details", () => { it("includes connection details on timeout", async () => { startMode = "silent"; - loadConfig.mockReturnValue({ - gateway: { mode: "local", bind: "loopback" }, - }); - resolveGatewayPort.mockReturnValue(18789); - pickPrimaryTailnetIPv4.mockReturnValue(undefined); + setLocalLoopbackGatewayConfig(); vi.useFakeTimers(); let errMessage = ""; @@ -409,11 +389,7 @@ describe("callGateway error details", () => { it("does not overflow very large timeout values", async () => { startMode = "silent"; - loadConfig.mockReturnValue({ - gateway: { mode: "local", bind: "loopback" }, - }); - resolveGatewayPort.mockReturnValue(18789); - pickPrimaryTailnetIPv4.mockReturnValue(undefined); + setLocalLoopbackGatewayConfig(); vi.useFakeTimers(); let errMessage = ""; @@ -474,89 +450,29 @@ describe("callGateway url override auth requirements", () => { describe("callGateway password resolution", () => { let envSnapshot: ReturnType; + const explicitAuthCases = [ + { + label: "password", + authKey: "password", + envKey: "OPENCLAW_GATEWAY_PASSWORD", + envValue: "from-env", + configValue: "from-config", + explicitValue: "explicit-password", + }, + { + label: "token", + authKey: "token", + envKey: "OPENCLAW_GATEWAY_TOKEN", + envValue: "env-token", + configValue: "local-token", + explicitValue: "explicit-token", + }, + ] as const; beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PASSWORD"]); + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PASSWORD", "OPENCLAW_GATEWAY_TOKEN"]); resetGatewayCallMocks(); delete process.env.OPENCLAW_GATEWAY_PASSWORD; - setGatewayNetworkDefaults(18789); - }); - - afterEach(() => { - envSnapshot.restore(); - }); - - it("uses local config password when env is unset", async () => { - loadConfig.mockReturnValue({ - gateway: { - mode: "local", - bind: "loopback", - auth: { password: "secret" }, - }, - }); - - await callGateway({ method: "health" }); - - expect(lastClientOptions?.password).toBe("secret"); - }); - - it("prefers env password over local config password", async () => { - process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env"; - loadConfig.mockReturnValue({ - gateway: { - mode: "local", - bind: "loopback", - auth: { password: "from-config" }, - }, - }); - - await callGateway({ method: "health" }); - - expect(lastClientOptions?.password).toBe("from-env"); - }); - - it("uses remote password in remote mode when env is unset", async () => { - loadConfig.mockReturnValue(makeRemotePasswordGatewayConfig("remote-secret")); - - await callGateway({ method: "health" }); - - expect(lastClientOptions?.password).toBe("remote-secret"); - }); - - it("prefers env password over remote password in remote mode", async () => { - process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env"; - loadConfig.mockReturnValue(makeRemotePasswordGatewayConfig("remote-secret")); - - await callGateway({ method: "health" }); - - expect(lastClientOptions?.password).toBe("from-env"); - }); - - it("uses explicit password when url override is set", async () => { - process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env"; - loadConfig.mockReturnValue({ - gateway: { - mode: "local", - auth: { password: "from-config" }, - }, - }); - - await callGateway({ - method: "health", - url: "wss://override.example/ws", - password: "explicit-password", - }); - - expect(lastClientOptions?.password).toBe("explicit-password"); - }); -}); - -describe("callGateway token resolution", () => { - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); - resetGatewayCallMocks(); delete process.env.OPENCLAW_GATEWAY_TOKEN; setGatewayNetworkDefaults(18789); }); @@ -565,21 +481,73 @@ describe("callGateway token resolution", () => { envSnapshot.restore(); }); - it("uses explicit token when url override is set", async () => { - process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; + it.each([ + { + label: "uses local config password when env is unset", + envPassword: undefined, + config: { + gateway: { + mode: "local", + bind: "loopback", + auth: { password: "secret" }, + }, + }, + expectedPassword: "secret", + }, + { + label: "prefers env password over local config password", + envPassword: "from-env", + config: { + gateway: { + mode: "local", + bind: "loopback", + auth: { password: "from-config" }, + }, + }, + expectedPassword: "from-env", + }, + { + label: "uses remote password in remote mode when env is unset", + envPassword: undefined, + config: makeRemotePasswordGatewayConfig("remote-secret"), + expectedPassword: "remote-secret", + }, + { + label: "prefers env password over remote password in remote mode", + envPassword: "from-env", + config: makeRemotePasswordGatewayConfig("remote-secret"), + expectedPassword: "from-env", + }, + ])("$label", async ({ envPassword, config, expectedPassword }) => { + if (envPassword !== undefined) { + process.env.OPENCLAW_GATEWAY_PASSWORD = envPassword; + } + loadConfig.mockReturnValue(config); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.password).toBe(expectedPassword); + }); + + it.each(explicitAuthCases)("uses explicit $label when url override is set", async (testCase) => { + process.env[testCase.envKey] = testCase.envValue; + const auth = { [testCase.authKey]: testCase.configValue } as { + password?: string; + token?: string; + }; loadConfig.mockReturnValue({ gateway: { mode: "local", - auth: { token: "local-token" }, + auth, }, }); await callGateway({ method: "health", url: "wss://override.example/ws", - token: "explicit-token", + [testCase.authKey]: testCase.explicitValue, }); - expect(lastClientOptions?.token).toBe("explicit-token"); + expect(lastClientOptions?.[testCase.authKey]).toBe(testCase.explicitValue); }); }); diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 9a5808f802..174f23f3d0 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -3,7 +3,7 @@ import fsPromises from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../../infra/agent-events.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; import { resetLogger, setLoggerOverride } from "../../logging.js"; @@ -462,15 +462,20 @@ describe("exec approval handlers", () => { }); describe("gateway healthHandlers.status scope handling", () => { - beforeEach(async () => { - const status = await import("../../commands/status.js"); - vi.mocked(status.getStatusSummary).mockClear(); + let statusModule: typeof import("../../commands/status.js"); + let healthHandlers: typeof import("./health.js").healthHandlers; + + beforeAll(async () => { + statusModule = await import("../../commands/status.js"); + ({ healthHandlers } = await import("./health.js")); + }); + + beforeEach(() => { + vi.mocked(statusModule.getStatusSummary).mockClear(); }); async function runHealthStatus(scopes: string[]) { const respond = vi.fn(); - const status = await import("../../commands/status.js"); - const { healthHandlers } = await import("./health.js"); await healthHandlers.status({ req: {} as never, @@ -481,22 +486,21 @@ describe("gateway healthHandlers.status scope handling", () => { isWebchatConnect: () => false, }); - return { respond, status }; + return respond; } - it("requests redacted status for non-admin clients", async () => { - const { respond, status } = await runHealthStatus(["operator.read"]); + it.each([ + { scopes: ["operator.read"], includeSensitive: false }, + { scopes: ["operator.admin"], includeSensitive: true }, + ])( + "requests includeSensitive=$includeSensitive for scopes $scopes", + async ({ scopes, includeSensitive }) => { + const respond = await runHealthStatus(scopes); - expect(vi.mocked(status.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: false }); - expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); - }); - - it("requests full status for admin clients", async () => { - const { respond, status } = await runHealthStatus(["operator.admin"]); - - expect(vi.mocked(status.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: true }); - expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); - }); + expect(vi.mocked(statusModule.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive }); + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }, + ); }); describe("logs.tail", () => { diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index 1d153f5273..c1c79941e1 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; @@ -73,16 +73,32 @@ vi.mock("./openclaw-root.js", () => ({ resolveOpenClawPackageRootSync: vi.fn(() => null), })); +let resolveControlUiRepoRoot: typeof import("./control-ui-assets.js").resolveControlUiRepoRoot; +let resolveControlUiDistIndexPath: typeof import("./control-ui-assets.js").resolveControlUiDistIndexPath; +let resolveControlUiDistIndexHealth: typeof import("./control-ui-assets.js").resolveControlUiDistIndexHealth; +let resolveControlUiRootOverrideSync: typeof import("./control-ui-assets.js").resolveControlUiRootOverrideSync; +let resolveControlUiRootSync: typeof import("./control-ui-assets.js").resolveControlUiRootSync; +let openclawRoot: typeof import("./openclaw-root.js"); + describe("control UI assets helpers (fs-mocked)", () => { + beforeAll(async () => { + ({ + resolveControlUiRepoRoot, + resolveControlUiDistIndexPath, + resolveControlUiDistIndexHealth, + resolveControlUiRootOverrideSync, + resolveControlUiRootSync, + } = await import("./control-ui-assets.js")); + openclawRoot = await import("./openclaw-root.js"); + }); + beforeEach(() => { state.entries.clear(); state.realpaths.clear(); vi.clearAllMocks(); }); - it("resolves repo root from src argv1", async () => { - const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js"); - + it("resolves repo root from src argv1", () => { const root = abs("fixtures/ui-src"); setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n"); @@ -90,9 +106,7 @@ describe("control UI assets helpers (fs-mocked)", () => { expect(resolveControlUiRepoRoot(argv1)).toBe(root); }); - it("resolves repo root by traversing up (dist argv1)", async () => { - const { resolveControlUiRepoRoot } = await import("./control-ui-assets.js"); - + it("resolves repo root by traversing up (dist argv1)", () => { const root = abs("fixtures/ui-dist"); setFile(path.join(root, "package.json"), "{}\n"); setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n"); @@ -102,8 +116,6 @@ describe("control UI assets helpers (fs-mocked)", () => { }); it("resolves dist control-ui index path for dist argv1", async () => { - const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js"); - const argv1 = abs(path.join("fixtures", "pkg", "dist", "index.js")); const distDir = path.dirname(argv1); await expect(resolveControlUiDistIndexPath(argv1)).resolves.toBe( @@ -112,9 +124,6 @@ describe("control UI assets helpers (fs-mocked)", () => { }); it("uses resolveOpenClawPackageRoot when available", async () => { - const openclawRoot = await import("./openclaw-root.js"); - const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js"); - const pkgRoot = abs("fixtures/openclaw"); ( openclawRoot.resolveOpenClawPackageRoot as unknown as ReturnType @@ -126,8 +135,6 @@ describe("control UI assets helpers (fs-mocked)", () => { }); it("falls back to package.json name matching when root resolution fails", async () => { - const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js"); - const root = abs("fixtures/fallback"); setFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" })); setFile(path.join(root, "dist", "control-ui", "index.html"), "\n"); @@ -138,8 +145,6 @@ describe("control UI assets helpers (fs-mocked)", () => { }); it("returns null when fallback package name does not match", async () => { - const { resolveControlUiDistIndexPath } = await import("./control-ui-assets.js"); - const root = abs("fixtures/not-openclaw"); setFile(path.join(root, "package.json"), JSON.stringify({ name: "malicious-pkg" })); setFile(path.join(root, "dist", "control-ui", "index.html"), "\n"); @@ -148,8 +153,6 @@ describe("control UI assets helpers (fs-mocked)", () => { }); it("reports health for missing + existing dist assets", async () => { - const { resolveControlUiDistIndexHealth } = await import("./control-ui-assets.js"); - const root = abs("fixtures/health"); const indexPath = path.join(root, "dist", "control-ui", "index.html"); @@ -165,9 +168,7 @@ describe("control UI assets helpers (fs-mocked)", () => { }); }); - it("resolves control-ui root from override file or directory", async () => { - const { resolveControlUiRootOverrideSync } = await import("./control-ui-assets.js"); - + it("resolves control-ui root from override file or directory", () => { const root = abs("fixtures/override"); const uiDir = path.join(root, "dist", "control-ui"); const indexPath = path.join(uiDir, "index.html"); @@ -181,9 +182,6 @@ describe("control UI assets helpers (fs-mocked)", () => { }); it("resolves control-ui root for dist bundle argv1 and moduleUrl candidates", async () => { - const openclawRoot = await import("./openclaw-root.js"); - const { resolveControlUiRootSync } = await import("./control-ui-assets.js"); - const pkgRoot = abs("fixtures/openclaw-bundle"); ( openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType diff --git a/src/infra/openclaw-root.test.ts b/src/infra/openclaw-root.test.ts index bcf6bc9b66..5f5f41ef1c 100644 --- a/src/infra/openclaw-root.test.ts +++ b/src/infra/openclaw-root.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; @@ -90,6 +90,14 @@ vi.mock("node:fs/promises", async (importOriginal) => { }); describe("resolveOpenClawPackageRoot", () => { + let resolveOpenClawPackageRoot: typeof import("./openclaw-root.js").resolveOpenClawPackageRoot; + let resolveOpenClawPackageRootSync: typeof import("./openclaw-root.js").resolveOpenClawPackageRootSync; + + beforeAll(async () => { + ({ resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } = + await import("./openclaw-root.js")); + }); + beforeEach(() => { state.entries.clear(); state.realpaths.clear(); @@ -97,8 +105,6 @@ describe("resolveOpenClawPackageRoot", () => { }); it("resolves package root from .bin argv1", async () => { - const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); - const project = fx("bin-scenario"); const argv1 = path.join(project, "node_modules", ".bin", "openclaw"); const pkgRoot = path.join(project, "node_modules", "openclaw"); @@ -108,8 +114,6 @@ describe("resolveOpenClawPackageRoot", () => { }); it("resolves package root via symlinked argv1", async () => { - const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); - const project = fx("symlink-scenario"); const bin = path.join(project, "bin", "openclaw"); const realPkg = path.join(project, "real-pkg"); @@ -132,8 +136,6 @@ describe("resolveOpenClawPackageRoot", () => { }); it("prefers moduleUrl candidates", async () => { - const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); - const pkgRoot = fx("moduleurl"); setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "index.js")).toString(); @@ -142,8 +144,6 @@ describe("resolveOpenClawPackageRoot", () => { }); it("returns null for non-openclaw package roots", async () => { - const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); - const pkgRoot = fx("not-openclaw"); setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" })); @@ -151,8 +151,6 @@ describe("resolveOpenClawPackageRoot", () => { }); it("async resolver matches sync behavior", async () => { - const { resolveOpenClawPackageRoot } = await import("./openclaw-root.js"); - const pkgRoot = fx("async"); setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 4c219d4249..cc50c90986 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; @@ -86,16 +86,32 @@ function createAlwaysConfiguredPluginConfig(account: Record = { }; } -describe("runMessageAction context isolation", () => { - beforeEach(async () => { - const { createPluginRuntime } = await import("../../plugins/runtime/index.js"); - const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"); - const { setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"); - const { setWhatsAppRuntime } = await import("../../../extensions/whatsapp/src/runtime.js"); - const runtime = createPluginRuntime(); - setSlackRuntime(runtime); +let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime; +let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime; +let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime; +let setWhatsAppRuntime: typeof import("../../../extensions/whatsapp/src/runtime.js").setWhatsAppRuntime; + +function installChannelRuntimes(params?: { includeTelegram?: boolean; includeWhatsApp?: boolean }) { + const runtime = createPluginRuntime(); + setSlackRuntime(runtime); + if (params?.includeTelegram !== false) { setTelegramRuntime(runtime); + } + if (params?.includeWhatsApp !== false) { setWhatsAppRuntime(runtime); + } +} + +describe("runMessageAction context isolation", () => { + beforeAll(async () => { + ({ createPluginRuntime } = await import("../../plugins/runtime/index.js")); + ({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js")); + ({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js")); + ({ setWhatsAppRuntime } = await import("../../../extensions/whatsapp/src/runtime.js")); + }); + + beforeEach(() => { + installChannelRuntimes(); setActivePluginRegistry( createTestRegistry([ { @@ -222,59 +238,59 @@ describe("runMessageAction context isolation", () => { expect(result.kind).toBe("action"); }); - it("allows WhatsApp send when target matches current chat", async () => { + it.each([ + { + name: "whatsapp", + channel: "whatsapp", + target: "123@g.us", + currentChannelId: "123@g.us", + }, + { + name: "imessage", + channel: "imessage", + target: "imessage:+15551234567", + currentChannelId: "imessage:+15551234567", + }, + ] as const)("allows $name send when target matches current context", async (testCase) => { const result = await runDrySend({ cfg: whatsappConfig, actionParams: { - channel: "whatsapp", - target: "123@g.us", + channel: testCase.channel, + target: testCase.target, message: "hi", }, - toolContext: { currentChannelId: "123@g.us" }, + toolContext: { currentChannelId: testCase.currentChannelId }, }); expect(result.kind).toBe("send"); }); - it("blocks WhatsApp send when target differs from current chat", async () => { + it.each([ + { + name: "whatsapp", + channel: "whatsapp", + target: "456@g.us", + currentChannelId: "123@g.us", + currentChannelProvider: "whatsapp", + }, + { + name: "imessage", + channel: "imessage", + target: "imessage:+15551230000", + currentChannelId: "imessage:+15551234567", + currentChannelProvider: "imessage", + }, + ] as const)("blocks $name send when target differs from current context", async (testCase) => { const result = await runDrySend({ cfg: whatsappConfig, actionParams: { - channel: "whatsapp", - target: "456@g.us", - message: "hi", - }, - toolContext: { currentChannelId: "123@g.us", currentChannelProvider: "whatsapp" }, - }); - - expect(result.kind).toBe("send"); - }); - - it("allows iMessage send when target matches current handle", async () => { - const result = await runDrySend({ - cfg: whatsappConfig, - actionParams: { - channel: "imessage", - target: "imessage:+15551234567", - message: "hi", - }, - toolContext: { currentChannelId: "imessage:+15551234567" }, - }); - - expect(result.kind).toBe("send"); - }); - - it("blocks iMessage send when target differs from current handle", async () => { - const result = await runDrySend({ - cfg: whatsappConfig, - actionParams: { - channel: "imessage", - target: "imessage:+15551230000", + channel: testCase.channel, + target: testCase.target, message: "hi", }, toolContext: { - currentChannelId: "imessage:+15551234567", - currentChannelProvider: "imessage", + currentChannelId: testCase.currentChannelId, + currentChannelProvider: testCase.currentChannelProvider, }, }); @@ -498,11 +514,8 @@ describe("runMessageAction sendAttachment hydration", () => { }); describe("runMessageAction sandboxed media validation", () => { - beforeEach(async () => { - const { createPluginRuntime } = await import("../../plugins/runtime/index.js"); - const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"); - const runtime = createPluginRuntime(); - setSlackRuntime(runtime); + beforeEach(() => { + installChannelRuntimes({ includeTelegram: false, includeWhatsApp: false }); setActivePluginRegistry( createTestRegistry([ { @@ -518,38 +531,38 @@ describe("runMessageAction sandboxed media validation", () => { setActivePluginRegistry(createTestRegistry([])); }); - it("rejects media outside the sandbox root", async () => { - await withSandbox(async (sandboxDir) => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - media: "/etc/passwd", - message: "", - }, - sandboxRoot: sandboxDir, - }), - ).rejects.toThrow(/sandbox/i); - }); - }); + it.each(["/etc/passwd", "file:///etc/passwd"])( + "rejects out-of-sandbox media reference: %s", + async (media) => { + await withSandbox(async (sandboxDir) => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + media, + message: "", + }, + sandboxRoot: sandboxDir, + }), + ).rejects.toThrow(/sandbox/i); + }); + }, + ); - it("rejects file:// media outside the sandbox root", async () => { - await withSandbox(async (sandboxDir) => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - media: "file:///etc/passwd", - message: "", - }, - sandboxRoot: sandboxDir, - }), - ).rejects.toThrow(/sandbox/i); - }); + it("rejects data URLs in media params", async () => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + media: "data:image/png;base64,abcd", + message: "", + }, + }), + ).rejects.toThrow(/data:/i); }); it("rewrites sandbox-relative media paths", async () => { @@ -592,20 +605,6 @@ describe("runMessageAction sandboxed media validation", () => { expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "note.ogg")); }); }); - - it("rejects data URLs in media params", async () => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - media: "data:image/png;base64,abcd", - message: "", - }, - }), - ).rejects.toThrow(/data:/i); - }); }); describe("runMessageAction media caption behavior", () => { diff --git a/src/infra/outbound/message-action-runner.threading.test.ts b/src/infra/outbound/message-action-runner.threading.test.ts index c5b040dd3c..4342594794 100644 --- a/src/infra/outbound/message-action-runner.threading.test.ts +++ b/src/infra/outbound/message-action-runner.threading.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -80,11 +80,18 @@ const defaultTelegramToolContext = { currentThreadTs: "42", } as const; +let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime; +let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime; +let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime; + describe("runMessageAction threading auto-injection", () => { - beforeEach(async () => { - const { createPluginRuntime } = await import("../../plugins/runtime/index.js"); - const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"); - const { setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js"); + beforeAll(async () => { + ({ createPluginRuntime } = await import("../../plugins/runtime/index.js")); + ({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js")); + ({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js")); + }); + + beforeEach(() => { const runtime = createPluginRuntime(); setSlackRuntime(runtime); setTelegramRuntime(runtime); @@ -110,94 +117,73 @@ describe("runMessageAction threading auto-injection", () => { mocks.recordSessionMetaFromInbound.mockReset(); }); - it("uses toolContext thread when auto-threading is active", async () => { + it.each([ + { + name: "exact channel id", + target: "channel:C123", + threadTs: "111.222", + expectedSessionKey: "agent:main:slack:channel:c123:thread:111.222", + }, + { + name: "case-insensitive channel id", + target: "channel:c123", + threadTs: "333.444", + expectedSessionKey: "agent:main:slack:channel:c123:thread:333.444", + }, + ] as const)("auto-threads slack using $name", async (testCase) => { mockHandledSendAction(); const call = await runThreadingAction({ cfg: slackConfig, actionParams: { channel: "slack", - target: "channel:C123", + target: testCase.target, message: "hi", }, toolContext: { currentChannelId: "C123", - currentThreadTs: "111.222", + currentThreadTs: testCase.threadTs, replyToMode: "all", }, }); expect(call?.ctx?.agentId).toBe("main"); - expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222"); + expect(call?.ctx?.mirror?.sessionKey).toBe(testCase.expectedSessionKey); }); - it("matches auto-threading when channel ids differ in case", async () => { - mockHandledSendAction(); - - const call = await runThreadingAction({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "channel:c123", - message: "hi", - }, - toolContext: { - currentChannelId: "C123", - currentThreadTs: "333.444", - replyToMode: "all", - }, - }); - - expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:333.444"); - }); - - it("auto-injects telegram threadId from toolContext when omitted", async () => { + it.each([ + { + name: "injects threadId for matching target", + target: "telegram:123", + expectedThreadId: "42", + }, + { + name: "injects threadId for prefixed group target", + target: "telegram:group:123", + expectedThreadId: "42", + }, + { + name: "skips threadId when target chat differs", + target: "telegram:999", + expectedThreadId: undefined, + }, + ] as const)("telegram auto-threading: $name", async (testCase) => { mockHandledSendAction(); const call = await runThreadingAction({ cfg: telegramConfig, actionParams: { channel: "telegram", - target: "telegram:123", + target: testCase.target, message: "hi", }, toolContext: defaultTelegramToolContext, }); - expect(call?.threadId).toBe("42"); - expect(call?.ctx?.params?.threadId).toBe("42"); - }); - - it("skips telegram auto-threading when target chat differs", async () => { - mockHandledSendAction(); - - const call = await runThreadingAction({ - cfg: telegramConfig, - actionParams: { - channel: "telegram", - target: "telegram:999", - message: "hi", - }, - toolContext: defaultTelegramToolContext, - }); - - expect(call?.ctx?.params?.threadId).toBeUndefined(); - }); - - it("matches telegram target with internal prefix variations", async () => { - mockHandledSendAction(); - - const call = await runThreadingAction({ - cfg: telegramConfig, - actionParams: { - channel: "telegram", - target: "telegram:group:123", - message: "hi", - }, - toolContext: defaultTelegramToolContext, - }); - - expect(call?.ctx?.params?.threadId).toBe("42"); + expect(call?.ctx?.params?.threadId).toBe(testCase.expectedThreadId); + if (testCase.expectedThreadId !== undefined) { + expect(call?.threadId).toBe(testCase.expectedThreadId); + } }); it("uses explicit telegram threadId when provided", async () => { diff --git a/src/infra/ssh-config.test.ts b/src/infra/ssh-config.test.ts index 53937cc245..318f2dab97 100644 --- a/src/infra/ssh-config.test.ts +++ b/src/infra/ssh-config.test.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import { EventEmitter } from "node:events"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; type MockSpawnChild = EventEmitter & { stdout?: EventEmitter & { setEncoding?: (enc: string) => void }; @@ -40,9 +40,15 @@ vi.mock("node:child_process", () => { const spawnMock = vi.mocked(spawn); +let parseSshConfigOutput: typeof import("./ssh-config.js").parseSshConfigOutput; +let resolveSshConfig: typeof import("./ssh-config.js").resolveSshConfig; + describe("ssh-config", () => { - it("parses ssh -G output", async () => { - const { parseSshConfigOutput } = await import("./ssh-config.js"); + beforeAll(async () => { + ({ parseSshConfigOutput, resolveSshConfig } = await import("./ssh-config.js")); + }); + + it("parses ssh -G output", () => { const parsed = parseSshConfigOutput( "user bob\nhostname example.com\nport 2222\nidentityfile none\nidentityfile /tmp/id\n", ); @@ -53,7 +59,6 @@ describe("ssh-config", () => { }); it("resolves ssh config via ssh -G", async () => { - const { resolveSshConfig } = await import("./ssh-config.js"); const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 }); expect(config?.user).toBe("steipete"); expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net"); @@ -74,7 +79,6 @@ describe("ssh-config", () => { }, ); - const { resolveSshConfig } = await import("./ssh-config.js"); const config = await resolveSshConfig({ user: "me", host: "bad-host", port: 22 }); expect(config).toBeNull(); }); diff --git a/src/infra/transport-ready.test.ts b/src/infra/transport-ready.test.ts index f2b8d770aa..318224cc78 100644 --- a/src/infra/transport-ready.test.ts +++ b/src/infra/transport-ready.test.ts @@ -12,6 +12,10 @@ vi.mock("./backoff.js", () => ({ }, })); +function createRuntime() { + return { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; +} + describe("waitForTransportReady", () => { beforeEach(() => { vi.useFakeTimers(); @@ -22,7 +26,7 @@ describe("waitForTransportReady", () => { }); it("returns when the check succeeds and logs after the delay", async () => { - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntime(); let attempts = 0; const readyPromise = waitForTransportReady({ label: "test transport", @@ -48,7 +52,7 @@ describe("waitForTransportReady", () => { }); it("throws after the timeout", async () => { - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntime(); const waitPromise = waitForTransportReady({ label: "test transport", timeoutMs: 110, @@ -65,7 +69,7 @@ describe("waitForTransportReady", () => { }); it("returns early when aborted", async () => { - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntime(); const controller = new AbortController(); controller.abort(); await waitForTransportReady({ diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 88f4a2cbd0..cc88cc1ce7 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -147,22 +147,23 @@ describe("update-startup", () => { return { log, parsed }; } - it("logs update hint for npm installs when newer tag exists", async () => { - const { log, parsed } = await runUpdateCheckAndReadState("stable"); + it.each([ + { + name: "stable channel", + channel: "stable" as const, + }, + { + name: "beta channel with older beta tag", + channel: "beta" as const, + }, + ])("logs latest update hint for $name", async ({ channel }) => { + const { log, parsed } = await runUpdateCheckAndReadState(channel); expect(log.info).toHaveBeenCalledWith( expect.stringContaining("update available (latest): v2.0.0"), ); expect(parsed.lastNotifiedVersion).toBe("2.0.0"); expect(parsed.lastAvailableVersion).toBe("2.0.0"); - }); - - it("uses latest when beta tag is older than release", async () => { - const { log, parsed } = await runUpdateCheckAndReadState("beta"); - - expect(log.info).toHaveBeenCalledWith( - expect.stringContaining("update available (latest): v2.0.0"), - ); expect(parsed.lastNotifiedTag).toBe("latest"); }); diff --git a/src/line/template-messages.test.ts b/src/line/template-messages.test.ts index 2a755c5cac..b142b2a765 100644 --- a/src/line/template-messages.test.ts +++ b/src/line/template-messages.test.ts @@ -8,26 +8,8 @@ import { createImageCarouselColumn, createProductCarousel, messageAction, - postbackAction, } from "./template-messages.js"; -describe("messageAction", () => { - it("truncates label to 20 characters", () => { - const action = messageAction("This is a very long label that exceeds the limit"); - - expect(action.label).toBe("This is a very long "); - }); -}); - -describe("postbackAction", () => { - it("truncates data to 300 characters", () => { - const longData = "x".repeat(400); - const action = postbackAction("Test", longData); - - expect((action as { data: string }).data.length).toBe(300); - }); -}); - describe("createConfirmTemplate", () => { it("truncates text to 240 characters", () => { const longText = "x".repeat(300); @@ -118,33 +100,25 @@ describe("carousel column limits", () => { }); describe("createProductCarousel", () => { - it("uses URI action when actionUrl provided", () => { - const template = createProductCarousel([ - { - title: "Product", - description: "Desc", - actionLabel: "Buy", - actionUrl: "https://shop.com/buy", - }, - ]); - + it.each([ + { + title: "Product", + description: "Desc", + actionLabel: "Buy", + actionUrl: "https://shop.com/buy", + expectedType: "uri", + }, + { + title: "Product", + description: "Desc", + actionLabel: "Select", + actionData: "product_id=123", + expectedType: "postback", + }, + ])("uses expected action type for product action", ({ expectedType, ...item }) => { + const template = createProductCarousel([item]); const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> }) .columns; - expect(columns[0].actions[0].type).toBe("uri"); - }); - - it("uses postback action when actionData provided", () => { - const template = createProductCarousel([ - { - title: "Product", - description: "Desc", - actionLabel: "Select", - actionData: "product_id=123", - }, - ]); - - const columns = (template.template as { columns: Array<{ actions: Array<{ type: string }> }> }) - .columns; - expect(columns[0].actions[0].type).toBe("postback"); + expect(columns[0].actions[0].type).toBe(expectedType); }); }); diff --git a/src/logging/console-settings.test.ts b/src/logging/console-settings.test.ts index 5284e891f1..905aea21d6 100644 --- a/src/logging/console-settings.test.ts +++ b/src/logging/console-settings.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./config.js", () => ({ readLoggingConfig: () => undefined, @@ -27,6 +27,13 @@ type ConsoleSnapshot = { let originalIsTty: boolean | undefined; let snapshot: ConsoleSnapshot; +let logging: typeof import("../logging.js"); +let state: typeof import("./state.js"); + +beforeAll(async () => { + logging = await import("../logging.js"); + state = await import("./state.js"); +}); beforeEach(() => { loadConfigCalls = 0; @@ -42,7 +49,7 @@ beforeEach(() => { Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); }); -afterEach(async () => { +afterEach(() => { console.log = snapshot.log; console.info = snapshot.info; console.warn = snapshot.warn; @@ -50,14 +57,11 @@ afterEach(async () => { console.debug = snapshot.debug; console.trace = snapshot.trace; Object.defineProperty(process.stdout, "isTTY", { value: originalIsTty, configurable: true }); - const logging = await import("../logging.js"); logging.setConsoleConfigLoaderForTests(); vi.restoreAllMocks(); }); -async function loadLogging() { - const logging = await import("../logging.js"); - const state = await import("./state.js"); +function loadLogging() { state.loggingState.cachedConsoleSettings = null; logging.setConsoleConfigLoaderForTests(() => { loadConfigCalls += 1; @@ -71,8 +75,8 @@ async function loadLogging() { } describe("getConsoleSettings", () => { - it("does not recurse when loadConfig logs during resolution", async () => { - const { logging } = await loadLogging(); + it("does not recurse when loadConfig logs during resolution", () => { + const { logging } = loadLogging(); logging.setConsoleTimestampPrefix(true); logging.enableConsoleCapture(); const { getConsoleSettings } = logging; @@ -80,8 +84,8 @@ describe("getConsoleSettings", () => { expect(loadConfigCalls).toBe(1); }); - it("skips config fallback during re-entrant resolution", async () => { - const { logging, state } = await loadLogging(); + it("skips config fallback during re-entrant resolution", () => { + const { logging, state } = loadLogging(); state.loggingState.resolvingConsoleSettings = true; logging.setConsoleTimestampPrefix(true); logging.enableConsoleCapture(); diff --git a/src/media/input-files.fetch-guard.test.ts b/src/media/input-files.fetch-guard.test.ts index 68224200d1..0b293e5cf4 100644 --- a/src/media/input-files.fetch-guard.test.ts +++ b/src/media/input-files.fetch-guard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; const fetchWithSsrFGuardMock = vi.fn(); @@ -10,6 +10,15 @@ async function waitForMicrotaskTurn(): Promise { await new Promise((resolve) => queueMicrotask(resolve)); } +let fetchWithGuard: typeof import("./input-files.js").fetchWithGuard; +let extractImageContentFromSource: typeof import("./input-files.js").extractImageContentFromSource; +let extractFileContentFromSource: typeof import("./input-files.js").extractFileContentFromSource; + +beforeAll(async () => { + ({ fetchWithGuard, extractImageContentFromSource, extractFileContentFromSource } = + await import("./input-files.js")); +}); + describe("fetchWithGuard", () => { it("rejects oversized streamed payloads and cancels the stream", async () => { let canceled = false; @@ -40,7 +49,6 @@ describe("fetchWithGuard", () => { finalUrl: "https://example.com/file.bin", }); - const { fetchWithGuard } = await import("./input-files.js"); await expect( fetchWithGuard({ url: "https://example.com/file.bin", @@ -64,7 +72,6 @@ describe("base64 size guards", () => { kind: "images", expectedError: "Image too large", run: async (data: string) => { - const { extractImageContentFromSource } = await import("./input-files.js"); return await extractImageContentFromSource( { type: "base64", data, mediaType: "image/png" }, { @@ -81,7 +88,6 @@ describe("base64 size guards", () => { kind: "files", expectedError: "File too large", run: async (data: string) => { - const { extractFileContentFromSource } = await import("./input-files.js"); return await extractFileContentFromSource({ source: { type: "base64", data, mediaType: "text/plain", filename: "x.txt" }, limits: { diff --git a/src/media/store.redirect.test.ts b/src/media/store.redirect.test.ts index f007199b97..54c44109b8 100644 --- a/src/media/store.redirect.test.ts +++ b/src/media/store.redirect.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; -import JSZip from "jszip"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createPinnedLookup } from "../infra/net/ssrf.js"; import { captureEnv } from "../test-utils/env.js"; @@ -92,41 +91,6 @@ describe("media store redirects", () => { expect(await fs.readFile(saved.path, "utf8")).toBe("redirected"); }); - it("sniffs xlsx from zip content when headers and url extension are missing", async () => { - mockRequest.mockImplementationOnce((_url, _opts, cb) => { - const { req, res } = createMockHttpExchange(); - - res.statusCode = 200; - res.headers = {}; - setImmediate(() => { - cb(res as unknown); - const zip = new JSZip(); - zip.file( - "[Content_Types].xml", - '', - ); - zip.file("xl/workbook.xml", ""); - void zip - .generateAsync({ type: "nodebuffer" }) - .then((buf) => { - res.write(buf); - res.end(); - }) - .catch((err) => { - res.destroy(err); - }); - }); - - return req; - }); - - const saved = await saveMediaSource("https://example.com/download"); - expect(saved.contentType).toBe( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ); - expect(path.extname(saved.path)).toBe(".xlsx"); - }); - it("fails when redirect response omits location header", async () => { mockRequest.mockImplementationOnce((_url, _opts, cb) => { const { req, res } = createMockHttpExchange(); diff --git a/src/memory/batch-voyage.test.ts b/src/memory/batch-voyage.test.ts index 8e9e374f53..e3ca43a341 100644 --- a/src/memory/batch-voyage.test.ts +++ b/src/memory/batch-voyage.test.ts @@ -1,5 +1,5 @@ import { ReadableStream } from "node:stream/web"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { VoyageBatchOutputLine, VoyageBatchRequest } from "./batch-voyage.js"; import type { VoyageEmbeddingClient } from "./embeddings-voyage.js"; @@ -10,6 +10,12 @@ vi.mock("../infra/retry.js", () => ({ })); describe("runVoyageEmbeddingBatches", () => { + let runVoyageEmbeddingBatches: typeof import("./batch-voyage.js").runVoyageEmbeddingBatches; + + beforeAll(async () => { + ({ runVoyageEmbeddingBatches } = await import("./batch-voyage.js")); + }); + afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); @@ -84,8 +90,6 @@ describe("runVoyageEmbeddingBatches", () => { body: stream, }); - const { runVoyageEmbeddingBatches } = await import("./batch-voyage.js"); - const results = await runVoyageEmbeddingBatches({ client: mockClient, agentId: "agent-1", @@ -156,8 +160,6 @@ describe("runVoyageEmbeddingBatches", () => { fetchMock.mockResolvedValueOnce({ ok: true, body: stream }); - const { runVoyageEmbeddingBatches } = await import("./batch-voyage.js"); - const results = await runVoyageEmbeddingBatches({ client: mockClient, agentId: "a1", diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index 3c9a0dedde..4553b2d8cb 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -1,7 +1,7 @@ /** * Test: before_compaction & after_compaction hook wiring */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const hookMocks = vi.hoisted(() => ({ runner: { @@ -20,6 +20,14 @@ vi.mock("../infra/agent-events.js", () => ({ })); describe("compaction hook wiring", () => { + let handleAutoCompactionStart: typeof import("../agents/pi-embedded-subscribe.handlers.compaction.js").handleAutoCompactionStart; + let handleAutoCompactionEnd: typeof import("../agents/pi-embedded-subscribe.handlers.compaction.js").handleAutoCompactionEnd; + + beforeAll(async () => { + ({ handleAutoCompactionStart, handleAutoCompactionEnd } = + await import("../agents/pi-embedded-subscribe.handlers.compaction.js")); + }); + beforeEach(() => { hookMocks.runner.hasHooks.mockReset(); hookMocks.runner.hasHooks.mockReturnValue(false); @@ -29,12 +37,9 @@ describe("compaction hook wiring", () => { hookMocks.runner.runAfterCompaction.mockResolvedValue(undefined); }); - it("calls runBeforeCompaction in handleAutoCompactionStart", async () => { + it("calls runBeforeCompaction in handleAutoCompactionStart", () => { hookMocks.runner.hasHooks.mockReturnValue(true); - const { handleAutoCompactionStart } = - await import("../agents/pi-embedded-subscribe.handlers.compaction.js"); - const ctx = { params: { runId: "r1", session: { messages: [1, 2, 3] } }, state: { compactionInFlight: false }, @@ -54,12 +59,9 @@ describe("compaction hook wiring", () => { expect(event?.messageCount).toBe(3); }); - it("calls runAfterCompaction when willRetry is false", async () => { + it("calls runAfterCompaction when willRetry is false", () => { hookMocks.runner.hasHooks.mockReturnValue(true); - const { handleAutoCompactionEnd } = - await import("../agents/pi-embedded-subscribe.handlers.compaction.js"); - const ctx = { params: { runId: "r2", session: { messages: [1, 2] } }, state: { compactionInFlight: true }, @@ -88,12 +90,9 @@ describe("compaction hook wiring", () => { expect(event?.compactedCount).toBe(1); }); - it("does not call runAfterCompaction when willRetry is true", async () => { + it("does not call runAfterCompaction when willRetry is true", () => { hookMocks.runner.hasHooks.mockReturnValue(true); - const { handleAutoCompactionEnd } = - await import("../agents/pi-embedded-subscribe.handlers.compaction.js"); - const ctx = { params: { runId: "r3", session: { messages: [] } }, state: { compactionInFlight: true }, diff --git a/src/process/child-process-bridge.test.ts b/src/process/child-process-bridge.test.ts index 8d90eeee7c..55855ce156 100644 --- a/src/process/child-process-bridge.test.ts +++ b/src/process/child-process-bridge.test.ts @@ -89,11 +89,11 @@ describe("attachChildProcessBridge", () => { addedSigterm("SIGTERM"); await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("timeout waiting for child exit")), 10_000); + const timeout = setTimeout(() => reject(new Error("timeout waiting for child exit")), 2_000); child.once("exit", () => { clearTimeout(timeout); resolve(); }); }); - }, 20_000); + }, 5_000); }); diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 5f670f8f4d..edf0019e1d 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -38,7 +38,7 @@ describe("runCommandWithTimeout", () => { it("kills command when no output timeout elapses", async () => { const result = await runCommandWithTimeout( - [process.execPath, "-e", "setTimeout(() => {}, 1_000)"], + [process.execPath, "-e", "setTimeout(() => {}, 120)"], { timeoutMs: 1_000, noOutputTimeoutMs: 35, @@ -55,11 +55,11 @@ describe("runCommandWithTimeout", () => { [ process.execPath, "-e", - 'let i=0; const t=setInterval(() => { process.stdout.write("."); i += 1; if (i >= 2) { clearInterval(t); process.exit(0); } }, 5);', + 'process.stdout.write("."); setTimeout(() => process.stdout.write("."), 30); setTimeout(() => process.exit(0), 60);', ], { timeoutMs: 1_000, - noOutputTimeoutMs: 120, + noOutputTimeoutMs: 500, }, ); @@ -72,7 +72,7 @@ describe("runCommandWithTimeout", () => { it("reports global timeout termination when overall timeout elapses", async () => { const result = await runCommandWithTimeout( - [process.execPath, "-e", "setTimeout(() => {}, 1_000)"], + [process.execPath, "-e", "setTimeout(() => {}, 120)"], { timeoutMs: 15, }, diff --git a/src/process/kill-tree.test.ts b/src/process/kill-tree.test.ts index 48f081f19e..b566248c67 100644 --- a/src/process/kill-tree.test.ts +++ b/src/process/kill-tree.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { killProcessTree } from "./kill-tree.js"; const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), @@ -32,7 +33,6 @@ describe("killProcessTree", () => { afterEach(() => { killSpy.mockRestore(); vi.useRealTimers(); - vi.resetModules(); vi.clearAllMocks(); }); @@ -45,7 +45,6 @@ describe("killProcessTree", () => { }) as typeof process.kill); await withPlatform("win32", async () => { - const { killProcessTree } = await import("./kill-tree.js"); killProcessTree(4242, { graceMs: 25 }); expect(spawnMock).toHaveBeenCalledTimes(1); @@ -70,7 +69,6 @@ describe("killProcessTree", () => { }) as typeof process.kill); await withPlatform("win32", async () => { - const { killProcessTree } = await import("./kill-tree.js"); killProcessTree(5252, { graceMs: 10 }); await vi.advanceTimersByTimeAsync(10); @@ -103,7 +101,6 @@ describe("killProcessTree", () => { }) as typeof process.kill); await withPlatform("linux", async () => { - const { killProcessTree } = await import("./kill-tree.js"); killProcessTree(3333, { graceMs: 10 }); await vi.advanceTimersByTimeAsync(10); @@ -123,7 +120,6 @@ describe("killProcessTree", () => { }) as typeof process.kill); await withPlatform("linux", async () => { - const { killProcessTree } = await import("./kill-tree.js"); killProcessTree(4444, { graceMs: 5 }); await vi.advanceTimersByTimeAsync(5); diff --git a/src/process/supervisor/adapters/child.test.ts b/src/process/supervisor/adapters/child.test.ts index 2413915525..d1ac79975e 100644 --- a/src/process/supervisor/adapters/child.test.ts +++ b/src/process/supervisor/adapters/child.test.ts @@ -1,7 +1,7 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { spawnWithFallbackMock, killProcessTreeMock } = vi.hoisted(() => ({ spawnWithFallbackMock: vi.fn(), @@ -16,6 +16,8 @@ vi.mock("../../kill-tree.js", () => ({ killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args), })); +let createChildAdapter: typeof import("./child.js").createChildAdapter; + function createStubChild(pid = 1234) { const child = new EventEmitter() as ChildProcess; child.stdin = new PassThrough() as ChildProcess["stdin"]; @@ -33,7 +35,6 @@ async function createAdapterHarness(params?: { argv?: string[]; env?: NodeJS.ProcessEnv; }) { - const { createChildAdapter } = await import("./child.js"); const { child, killMock } = createStubChild(params?.pid); spawnWithFallbackMock.mockResolvedValue({ child, @@ -48,6 +49,10 @@ async function createAdapterHarness(params?: { } describe("createChildAdapter", () => { + beforeAll(async () => { + ({ createChildAdapter } = await import("./child.js")); + }); + beforeEach(() => { spawnWithFallbackMock.mockReset(); killProcessTreeMock.mockReset(); diff --git a/src/process/supervisor/adapters/pty.test.ts b/src/process/supervisor/adapters/pty.test.ts index 618aefc155..654c5b4408 100644 --- a/src/process/supervisor/adapters/pty.test.ts +++ b/src/process/supervisor/adapters/pty.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { spawnMock, ptyKillMock, killProcessTreeMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), @@ -32,6 +32,12 @@ function createStubPty(pid = 1234) { } describe("createPtyAdapter", () => { + let createPtyAdapter: typeof import("./pty.js").createPtyAdapter; + + beforeAll(async () => { + ({ createPtyAdapter } = await import("./pty.js")); + }); + beforeEach(() => { spawnMock.mockReset(); ptyKillMock.mockReset(); @@ -41,7 +47,6 @@ describe("createPtyAdapter", () => { afterEach(() => { vi.useRealTimers(); - vi.resetModules(); vi.clearAllMocks(); }); @@ -50,7 +55,6 @@ describe("createPtyAdapter", () => { Object.defineProperty(process, "platform", { value: "linux", configurable: true }); try { spawnMock.mockReturnValue(createStubPty()); - const { createPtyAdapter } = await import("./pty.js"); const adapter = await createPtyAdapter({ shell: "bash", @@ -69,7 +73,6 @@ describe("createPtyAdapter", () => { it("uses process-tree kill for SIGKILL by default", async () => { spawnMock.mockReturnValue(createStubPty()); - const { createPtyAdapter } = await import("./pty.js"); const adapter = await createPtyAdapter({ shell: "bash", @@ -84,7 +87,6 @@ describe("createPtyAdapter", () => { it("wait does not settle immediately on SIGKILL", async () => { vi.useFakeTimers(); spawnMock.mockReturnValue(createStubPty()); - const { createPtyAdapter } = await import("./pty.js"); const adapter = await createPtyAdapter({ shell: "bash", @@ -111,7 +113,6 @@ describe("createPtyAdapter", () => { vi.useFakeTimers(); const stub = createStubPty(); spawnMock.mockReturnValue(stub); - const { createPtyAdapter } = await import("./pty.js"); const adapter = await createPtyAdapter({ shell: "bash", @@ -131,7 +132,6 @@ describe("createPtyAdapter", () => { it("resolves wait when exit fires before wait is called", async () => { const stub = createStubPty(); spawnMock.mockReturnValue(stub); - const { createPtyAdapter } = await import("./pty.js"); const adapter = await createPtyAdapter({ shell: "bash", @@ -146,7 +146,6 @@ describe("createPtyAdapter", () => { it("keeps inherited env when no override env is provided", async () => { const stub = createStubPty(); spawnMock.mockReturnValue(stub); - const { createPtyAdapter } = await import("./pty.js"); await createPtyAdapter({ shell: "bash", @@ -160,7 +159,6 @@ describe("createPtyAdapter", () => { it("passes explicit env overrides as strings", async () => { const stub = createStubPty(); spawnMock.mockReturnValue(stub); - const { createPtyAdapter } = await import("./pty.js"); await createPtyAdapter({ shell: "bash", @@ -177,7 +175,6 @@ describe("createPtyAdapter", () => { Object.defineProperty(process, "platform", { value: "win32", configurable: true }); try { spawnMock.mockReturnValue(createStubPty()); - const { createPtyAdapter } = await import("./pty.js"); const adapter = await createPtyAdapter({ shell: "powershell.exe", @@ -199,7 +196,6 @@ describe("createPtyAdapter", () => { Object.defineProperty(process, "platform", { value: "win32", configurable: true }); try { spawnMock.mockReturnValue(createStubPty(4567)); - const { createPtyAdapter } = await import("./pty.js"); const adapter = await createPtyAdapter({ shell: "powershell.exe", diff --git a/src/process/supervisor/supervisor.pty-command.test.ts b/src/process/supervisor/supervisor.pty-command.test.ts index 3fec62d4df..582179e130 100644 --- a/src/process/supervisor/supervisor.pty-command.test.ts +++ b/src/process/supervisor/supervisor.pty-command.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { createPtyAdapterMock } = vi.hoisted(() => ({ createPtyAdapterMock: vi.fn(), @@ -33,13 +33,18 @@ function createStubPtyAdapter() { } describe("process supervisor PTY command contract", () => { + let createProcessSupervisor: typeof import("./supervisor.js").createProcessSupervisor; + + beforeAll(async () => { + ({ createProcessSupervisor } = await import("./supervisor.js")); + }); + beforeEach(() => { createPtyAdapterMock.mockReset(); }); it("passes PTY command verbatim to shell args", async () => { createPtyAdapterMock.mockResolvedValue(createStubPtyAdapter()); - const { createProcessSupervisor } = await import("./supervisor.js"); const supervisor = createProcessSupervisor(); const command = `printf '%s\\n' "a b" && printf '%s\\n' '$HOME'`; @@ -60,7 +65,6 @@ describe("process supervisor PTY command contract", () => { it("rejects empty PTY command", async () => { createPtyAdapterMock.mockResolvedValue(createStubPtyAdapter()); - const { createProcessSupervisor } = await import("./supervisor.js"); const supervisor = createProcessSupervisor(); await expect( diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index 6fb03bb43e..79d184672a 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -24,7 +24,7 @@ describe("process supervisor", () => { sessionId: "s1", backendId: "test", mode: "child", - argv: [process.execPath, "-e", "setTimeout(() => {}, 1_000)"], + argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], timeoutMs: 1_000, noOutputTimeoutMs: 20, stdinMode: "pipe-closed", @@ -42,7 +42,7 @@ describe("process supervisor", () => { backendId: "test", scopeKey: "scope:a", mode: "child", - argv: [process.execPath, "-e", "setTimeout(() => {}, 1_000)"], + argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], timeoutMs: 1_000, stdinMode: "pipe-open", }); @@ -71,7 +71,7 @@ describe("process supervisor", () => { sessionId: "s-timeout", backendId: "test", mode: "child", - argv: [process.execPath, "-e", "setTimeout(() => {}, 1_000)"], + argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], timeoutMs: 1, stdinMode: "pipe-closed", }); diff --git a/src/telegram/audit.test.ts b/src/telegram/audit.test.ts index 7992dd6e16..914c3d7d9f 100644 --- a/src/telegram/audit.test.ts +++ b/src/telegram/audit.test.ts @@ -1,12 +1,19 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds; +let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership; describe("telegram audit", () => { + beforeAll(async () => { + ({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } = + await import("./audit.js")); + }); + beforeEach(() => { vi.unstubAllGlobals(); }); it("collects unmentioned numeric group ids and flags wildcard", async () => { - const { collectTelegramUnmentionedGroupIds } = await import("./audit.js"); const res = collectTelegramUnmentionedGroupIds({ "*": { requireMention: false }, "-1001": { requireMention: false }, @@ -20,7 +27,6 @@ describe("telegram audit", () => { }); it("audits membership via getChatMember", async () => { - const { auditTelegramGroupMembership } = await import("./audit.js"); vi.stubGlobal( "fetch", vi.fn().mockResolvedValueOnce( @@ -42,7 +48,6 @@ describe("telegram audit", () => { }); it("reports bot not in group when status is left", async () => { - const { auditTelegramGroupMembership } = await import("./audit.js"); vi.stubGlobal( "fetch", vi.fn().mockResolvedValueOnce( diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index b6223c2888..f2eff7d130 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -356,22 +356,35 @@ describe("createTelegramBot", () => { expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined); }); - it("dedupes duplicate callback_query updates by update_id", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - + it("dedupes duplicate updates for callback_query, message, and channel_post", async () => { loadConfig.mockReturnValue({ channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", + groups: { + "-100777111222": { + enabled: true, + requireMention: false, + }, + }, + }, }, }); createTelegramBot({ token: "tok" }); - const handler = getOnHandler("callback_query") as ( + const callbackHandler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + const messageHandler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + const channelPostHandler = getOnHandler("channel_post") as ( ctx: Record, ) => Promise; - const ctx = { + await callbackHandler({ update: { update_id: 222 }, callbackQuery: { id: "cb-1", @@ -385,11 +398,76 @@ describe("createTelegramBot", () => { }, me: { username: "openclaw_bot" }, getFile: async () => ({}), - }; + }); + await callbackHandler({ + update: { update_id: 222 }, + callbackQuery: { + id: "cb-1", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); + expect(replySpy).toHaveBeenCalledTimes(1); - await handler(ctx); - await handler(ctx); + replySpy.mockClear(); + await messageHandler({ + update: { update_id: 111 }, + message: { + chat: { id: 123, type: "private" }, + from: { id: 456, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + await messageHandler({ + update: { update_id: 111 }, + message: { + chat: { id: 123, type: "private" }, + from: { id: 456, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + expect(replySpy).toHaveBeenCalledTimes(1); + + replySpy.mockClear(); + + await channelPostHandler({ + channelPost: { + chat: { id: -100777111222, type: "channel", title: "Wake Channel" }, + from: { id: 98765, is_bot: true, first_name: "wakebot", username: "wake_bot" }, + message_id: 777, + text: "wake check", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); + await channelPostHandler({ + channelPost: { + chat: { id: -100777111222, type: "channel", title: "Wake Channel" }, + from: { id: 98765, is_bot: true, first_name: "wakebot", username: "wake_bot" }, + message_id: 777, + text: "wake check", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); expect(replySpy).toHaveBeenCalledTimes(1); }); it("allows distinct callback_query ids without update_id", async () => { @@ -1975,73 +2053,4 @@ describe("createTelegramBot", () => { expect(replySpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); }); - it("dedupes duplicate message updates by update_id", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - const ctx = { - update: { update_id: 111 }, - message: { - chat: { id: 123, type: "private" }, - from: { id: 456, username: "testuser" }, - text: "hello", - date: 1736380800, - message_id: 42, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }; - - await handler(ctx); - await handler(ctx); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("dedupes duplicate channel_post updates by chat/message key", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { - "-100777111222": { - enabled: true, - requireMention: false, - }, - }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("channel_post") as (ctx: Record) => Promise; - - const ctx = { - channelPost: { - chat: { id: -100777111222, type: "channel", title: "Wake Channel" }, - from: { id: 98765, is_bot: true, first_name: "wakebot", username: "wake_bot" }, - message_id: 777, - text: "wake check", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }; - - await handler(ctx); - await handler(ctx); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); }); diff --git a/src/test-utils/command-runner.ts b/src/test-utils/command-runner.ts new file mode 100644 index 0000000000..82f6182d3c --- /dev/null +++ b/src/test-utils/command-runner.ts @@ -0,0 +1,10 @@ +import { Command } from "commander"; + +export async function runRegisteredCli(params: { + register: (program: Command) => void; + argv: string[]; +}): Promise { + const program = new Command(); + params.register(program); + await program.parseAsync(params.argv, { from: "user" }); +} diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 21aa681828..53687c2c41 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -68,33 +68,28 @@ describe("resolveGatewayConnection", () => { ); }); - it("uses explicit token when url override is set", () => { + it.each([ + { + label: "token", + auth: { token: "explicit-token" }, + expected: { token: "explicit-token", password: undefined }, + }, + { + label: "password", + auth: { password: "explicit-password" }, + expected: { token: undefined, password: "explicit-password" }, + }, + ])("uses explicit $label when url override is set", ({ auth, expected }) => { loadConfig.mockReturnValue({ gateway: { mode: "local" } }); const result = resolveGatewayConnection({ url: "wss://override.example/ws", - token: "explicit-token", + ...auth, }); expect(result).toEqual({ url: "wss://override.example/ws", - token: "explicit-token", - password: undefined, - }); - }); - - it("uses explicit password when url override is set", () => { - loadConfig.mockReturnValue({ gateway: { mode: "local" } }); - - const result = resolveGatewayConnection({ - url: "wss://override.example/ws", - password: "explicit-password", - }); - - expect(result).toEqual({ - url: "wss://override.example/ws", - token: undefined, - password: "explicit-password", + ...expected, }); }); diff --git a/src/tui/tui-input-history.test.ts b/src/tui/tui-input-history.test.ts index dfe9148b93..c62bf063cd 100644 --- a/src/tui/tui-input-history.test.ts +++ b/src/tui/tui-input-history.test.ts @@ -36,18 +36,10 @@ describe("createEditorSubmitHandler", () => { expect(editor.addToHistory).toHaveBeenCalledWith("hi"); }); - it("does not add empty-string submissions to history", () => { + it.each(["", " "])("does not add blank submissions to history", (text) => { const { editor, handler } = createSubmitHarness(); - handler(""); - - expect(editor.addToHistory).not.toHaveBeenCalled(); - }); - - it("does not add whitespace-only submissions to history", () => { - const { editor, handler } = createSubmitHarness(); - - handler(" "); + handler(text); expect(editor.addToHistory).not.toHaveBeenCalled(); }); @@ -79,12 +71,4 @@ describe("createEditorSubmitHandler", () => { expect(handleBangLine).toHaveBeenCalledWith("!ls"); }); - - it("treats a lone ! as a normal message", () => { - const { sendMessage, handler } = createSubmitHarness(); - - handler("!"); - - expect(sendMessage).toHaveBeenCalledWith("!"); - }); }); diff --git a/src/utils/run-with-concurrency.test.ts b/src/utils/run-with-concurrency.test.ts index 5cb9f4bf3b..d6ad889949 100644 --- a/src/utils/run-with-concurrency.test.ts +++ b/src/utils/run-with-concurrency.test.ts @@ -3,6 +3,10 @@ import { runTasksWithConcurrency } from "./run-with-concurrency.js"; describe("runTasksWithConcurrency", () => { it("preserves task order with bounded worker count", async () => { + const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; let running = 0; let peak = 0; const resolvers: Array<(() => void) | undefined> = []; @@ -17,18 +21,18 @@ describe("runTasksWithConcurrency", () => { }); const resultPromise = runTasksWithConcurrency({ tasks, limit: 2 }); - await vi.waitFor(() => { - expect(typeof resolvers[0]).toBe("function"); - expect(typeof resolvers[1]).toBe("function"); - }); + await flushMicrotasks(); + expect(typeof resolvers[0]).toBe("function"); + expect(typeof resolvers[1]).toBe("function"); + resolvers[1]?.(); - await vi.waitFor(() => { - expect(typeof resolvers[2]).toBe("function"); - }); + await flushMicrotasks(); + expect(typeof resolvers[2]).toBe("function"); + resolvers[0]?.(); - await vi.waitFor(() => { - expect(typeof resolvers[3]).toBe("function"); - }); + await flushMicrotasks(); + expect(typeof resolvers[3]).toBe("function"); + resolvers[2]?.(); resolvers[3]?.(); diff --git a/src/web/auto-reply/deliver-reply.test.ts b/src/web/auto-reply/deliver-reply.test.ts index 3344016f1a..ff5f7b6f10 100644 --- a/src/web/auto-reply/deliver-reply.test.ts +++ b/src/web/auto-reply/deliver-reply.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { logVerbose } from "../../globals.js"; +import { sleep } from "../../utils.js"; +import { loadWebMedia } from "../media.js"; import { deliverWebReply } from "./deliver-reply.js"; import type { WebInboundMsg } from "./types.js"; @@ -23,10 +26,6 @@ vi.mock("../../utils.js", async (importOriginal) => { }; }); -const { loadWebMedia } = await import("../media.js"); -const { sleep } = await import("../../utils.js"); -const { logVerbose } = await import("../../globals.js"); - function makeMsg(): WebInboundMsg { return { from: "+10000000000", diff --git a/src/web/auto-reply/web-auto-reply-utils.test.ts b/src/web/auto-reply/web-auto-reply-utils.test.ts index 9a2493eb1e..6e98d4a906 100644 --- a/src/web/auto-reply/web-auto-reply-utils.test.ts +++ b/src/web/auto-reply/web-auto-reply-utils.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { saveSessionStore } from "../../config/sessions.js"; import { isBotMentionedFromTargets, resolveMentionTargets } from "./mentions.js"; import { getSessionSnapshot } from "./session-snapshot.js"; @@ -81,42 +81,41 @@ describe("isBotMentionedFromTargets", () => { }); describe("resolveMentionTargets with @lid mapping", () => { - it("resolves mentionedJids via lid reverse mapping in authDir", async () => { - const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lid-mapping-")); - try { - await fs.writeFile( - path.join(authDir, "lid-mapping-777_reverse.json"), - JSON.stringify("+1777"), - ); - const msg = makeMsg({ + let authDir = ""; + + beforeAll(async () => { + authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lid-mapping-")); + await fs.writeFile(path.join(authDir, "lid-mapping-777_reverse.json"), JSON.stringify("+1777")); + }); + + afterAll(async () => { + if (!authDir) { + return; + } + await fs.rm(authDir, { recursive: true, force: true }); + authDir = ""; + }); + + it("uses @lid reverse mapping for mentions and self identity", () => { + const mentionTargets = resolveMentionTargets( + makeMsg({ body: "ping", mentionedJids: ["777@lid"], selfE164: "+15551234567", selfJid: "15551234567@s.whatsapp.net", - }); - const targets = resolveMentionTargets(msg, authDir); - expect(targets.normalizedMentions).toContain("+1777"); - } finally { - await fs.rm(authDir, { recursive: true, force: true }); - } - }); + }), + authDir, + ); + expect(mentionTargets.normalizedMentions).toContain("+1777"); - it("derives selfE164 from selfJid when selfJid is @lid and mapping exists", async () => { - const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lid-mapping-")); - try { - await fs.writeFile( - path.join(authDir, "lid-mapping-777_reverse.json"), - JSON.stringify("+1777"), - ); - const msg = makeMsg({ + const selfTargets = resolveMentionTargets( + makeMsg({ body: "ping", selfJid: "777@lid", - }); - const targets = resolveMentionTargets(msg, authDir); - expect(targets.selfE164).toBe("+1777"); - } finally { - await fs.rm(authDir, { recursive: true, force: true }); - } + }), + authDir, + ); + expect(selfTargets.selfE164).toBe("+1777"); }); }); diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index 606b244f1a..fe835be6a6 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -87,6 +87,7 @@ vi.mock("./session.js", () => { }); import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js"; +let createWaSocket: typeof import("./session.js").createWaSocket; async function waitForMessage(onMessage: ReturnType) { await vi.waitFor(() => expect(onMessage).toHaveBeenCalledTimes(1), { @@ -97,12 +98,19 @@ async function waitForMessage(onMessage: ReturnType) { } describe("web inbound media saves with extension", () => { + async function getMockSocket() { + return (await createWaSocket(false, false)) as unknown as { + ev: import("node:events").EventEmitter; + }; + } + beforeEach(() => { saveMediaBufferSpy.mockClear(); resetWebInboundDedupe(); }); beforeAll(async () => { + ({ createWaSocket } = await import("./session.js")); await fs.rm(HOME, { recursive: true, force: true }); }); @@ -118,12 +126,7 @@ describe("web inbound media saves with extension", () => { accountId: "default", authDir: path.join(HOME, "wa-auth"), }); - const { createWaSocket } = await import("./session.js"); - const realSock = await ( - createWaSocket as unknown as () => Promise<{ - ev: import("node:events").EventEmitter; - }> - )(); + const realSock = await getMockSocket(); realSock.ev.emit("messages.upsert", { type: "notify", @@ -202,12 +205,7 @@ describe("web inbound media saves with extension", () => { accountId: "default", authDir: path.join(HOME, "wa-auth"), }); - const { createWaSocket } = await import("./session.js"); - const realSock = await ( - createWaSocket as unknown as () => Promise<{ - ev: import("node:events").EventEmitter; - }> - )(); + const realSock = await getMockSocket(); const upsert = { type: "notify", diff --git a/src/web/login-qr.test.ts b/src/web/login-qr.test.ts index 4021ba73a1..4b16a28900 100644 --- a/src/web/login-qr.test.ts +++ b/src/web/login-qr.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js"; +import { createWaSocket, logoutWeb, waitForWaConnection } from "./session.js"; vi.mock("./session.js", () => { const createWaSocket = vi.fn( @@ -35,8 +37,6 @@ vi.mock("./qr-image.js", () => ({ renderQrPngBase64: vi.fn(async () => "base64"), })); -const { startWebLoginWithQr, waitForWebLogin } = await import("./login-qr.js"); -const { createWaSocket, waitForWaConnection, logoutWeb } = await import("./session.js"); const createWaSocketMock = vi.mocked(createWaSocket); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); const logoutWebMock = vi.mocked(logoutWeb); diff --git a/src/web/login.coverage.test.ts b/src/web/login.coverage.test.ts index 4d758e27b4..8b3673006e 100644 --- a/src/web/login.coverage.test.ts +++ b/src/web/login.coverage.test.ts @@ -3,10 +3,16 @@ import os from "node:os"; import path from "node:path"; import { DisconnectReason } from "@whiskeysockets/baileys"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loginWeb } from "./login.js"; +import { createWaSocket, formatError, waitForWaConnection } from "./session.js"; const rmMock = vi.spyOn(fs, "rm"); -const authDir = path.join(os.tmpdir(), "wa-creds"); +function resolveTestAuthDir() { + return path.join(os.tmpdir(), "wa-creds"); +} + +const authDir = resolveTestAuthDir(); vi.mock("../config/config.js", () => ({ loadConfig: () => @@ -14,7 +20,7 @@ vi.mock("../config/config.js", () => ({ channels: { whatsapp: { accounts: { - default: { enabled: true, authDir }, + default: { enabled: true, authDir: resolveTestAuthDir() }, }, }, }, @@ -22,6 +28,7 @@ vi.mock("../config/config.js", () => ({ })); vi.mock("./session.js", () => { + const authDir = resolveTestAuthDir(); const sockA = { ws: { close: vi.fn() } }; const sockB = { ws: { close: vi.fn() } }; let call = 0; @@ -43,11 +50,9 @@ vi.mock("./session.js", () => { }; }); -const { createWaSocket, waitForWaConnection, formatError } = await import("./session.js"); const createWaSocketMock = vi.mocked(createWaSocket); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); const formatErrorMock = vi.mocked(formatError); -const { loginWeb } = await import("./login.js"); describe("loginWeb coverage", () => { beforeEach(() => { diff --git a/src/web/media.test.ts b/src/web/media.test.ts index ff606dae38..8dc7a98748 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { resolveStateDir } from "../config/paths.js"; import { sendVoiceMessageDiscord } from "../discord/send.js"; import * as ssrf from "../infra/net/ssrf.js"; import { optimizeImageToPng } from "../media/image-ops.js"; @@ -52,8 +53,8 @@ beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-test-")); largeJpegBuffer = await sharp({ create: { - width: 800, - height: 800, + width: 400, + height: 400, channels: 3, background: "#ff0000", }, @@ -79,7 +80,8 @@ beforeAll(async () => { .png() .toBuffer(); alphaPngFile = await writeTempFile(alphaPngBuffer, ".png"); - const size = 72; + // Keep this small so the alpha-fallback test stays deterministic but fast. + const size = 24; const raw = buildDeterministicBytes(size * size * 4); fallbackPngBuffer = await sharp(raw, { raw: { width: size, height: size, channels: 4 } }) .png() @@ -132,18 +134,12 @@ describe("web media loading", () => { }); }); - it("strips MEDIA: prefix before reading local file", async () => { - const result = await loadWebMedia(`MEDIA:${tinyPngFile}`, 1024 * 1024); - - expect(result.kind).toBe("image"); - expect(result.buffer.length).toBeGreaterThan(0); - }); - - it("strips MEDIA: prefix with extra whitespace (LLM-friendly)", async () => { - const result = await loadWebMedia(` MEDIA : ${tinyPngFile}`, 1024 * 1024); - - expect(result.kind).toBe("image"); - expect(result.buffer.length).toBeGreaterThan(0); + it("strips MEDIA: prefix before reading local file (including whitespace variants)", async () => { + for (const input of [`MEDIA:${tinyPngFile}`, ` MEDIA : ${tinyPngFile}`]) { + const result = await loadWebMedia(input, 1024 * 1024); + expect(result.kind).toBe("image"); + expect(result.buffer.length).toBeGreaterThan(0); + } }); it("compresses large local images under the provided cap", async () => { @@ -375,7 +371,6 @@ describe("local media root guard", () => { }); it("allows default OpenClaw state workspace and sandbox roots", async () => { - const { resolveStateDir } = await import("../config/paths.js"); const stateDir = resolveStateDir(); const readFile = vi.fn(async () => Buffer.from("generated-media")); @@ -403,7 +398,6 @@ describe("local media root guard", () => { }); it("rejects default OpenClaw state per-agent workspace-* roots without explicit local roots", async () => { - const { resolveStateDir } = await import("../config/paths.js"); const stateDir = resolveStateDir(); const readFile = vi.fn(async () => Buffer.from("generated-media")); @@ -416,7 +410,6 @@ describe("local media root guard", () => { }); it("allows per-agent workspace-* paths with explicit local roots", async () => { - const { resolveStateDir } = await import("../config/paths.js"); const stateDir = resolveStateDir(); const readFile = vi.fn(async () => Buffer.from("generated-media")); const agentWorkspaceDir = path.join(stateDir, "workspace-clawdy");