From 1c753ea7860d8ad5e8e3947e0dbbbce1d3035252 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 05:43:30 +0000 Subject: [PATCH] test: dedupe fixtures and test harness setup --- extensions/bluebubbles/src/actions.test.ts | 60 ++-- .../bluebubbles/src/attachments.test.ts | 50 ++-- extensions/bluebubbles/src/chat.test.ts | 120 ++++---- extensions/bluebubbles/src/reactions.test.ts | 59 ++-- extensions/bluebubbles/src/send.test.ts | 47 ++- extensions/line/src/channel.logout.test.ts | 18 +- extensions/line/src/channel.startup.test.ts | 14 +- extensions/lobster/src/lobster-tool.test.ts | 58 +--- extensions/lobster/src/test-helpers.ts | 56 ++++ extensions/lobster/src/windows-spawn.test.ts | 65 ++--- extensions/msteams/src/attachments.test.ts | 267 ++++++++++-------- extensions/msteams/src/messenger.test.ts | 78 ++--- .../nostr/src/nostr-profile-http.test.ts | 47 ++- extensions/synology-chat/src/client.test.ts | 26 +- .../synology-chat/src/webhook-handler.test.ts | 48 ++-- extensions/telegram/src/channel.test.ts | 14 +- extensions/test-utils/runtime-env.ts | 12 + extensions/twitch/src/access-control.test.ts | 146 ++++------ .../voice-call/src/manager/events.test.ts | 40 ++- src/acp/translator.prompt-prefix.test.ts | 25 +- src/acp/translator.session-rate-limit.test.ts | 22 +- src/acp/translator.test-helpers.ts | 17 ++ .../bash-tools.process.poll-timeout.test.ts | 65 +++-- src/agents/model-auth.profiles.test.ts | 54 ++-- src/agents/model-fallback.test.ts | 138 +++++---- ...ssing-provider-apikey-from-env-var.test.ts | 13 +- ...ini-3-ids-preview-google-providers.test.ts | 10 +- src/agents/models-config.test-utils.ts | 9 + src/agents/ollama-stream.test.ts | 172 ++++++----- .../run.overflow-compaction.mocks.shared.ts | 18 +- ...ded-subscribe.handlers.tools.media.test.ts | 35 ++- ...session.subscribeembeddedpisession.test.ts | 46 +-- src/agents/pi-tools-agent-config.test.ts | 78 +++-- src/agents/skills-install-fallback.test.ts | 11 +- src/agents/skills-install.download.test.ts | 24 +- src/agents/skills-install.test.ts | 11 +- ...rs-workspace-skills-managed-skills.test.ts | 21 +- src/agents/subagent-announce.timeout.test.ts | 126 ++++----- .../subagent-registry.steer-restart.test.ts | 91 +++--- ...nk-low-reasoning-capable-models-no.test.ts | 73 +++-- ...ists-allowlisted-models-model-list.test.ts | 39 +-- ...ive-behavior.model-directive-test-utils.ts | 39 +++ ...l-verbose-during-flight-run-toggle.test.ts | 26 +- ...s-activation-from-allowfrom-groups.test.ts | 15 +- ....triggers.trigger-handling.test-harness.ts | 11 + src/browser/extension-relay.test.ts | 29 +- .../server-context.remote-tab-ops.test.ts | 75 ++--- src/config/channel-capabilities.test.ts | 16 +- src/config/schema.help.quality.test.ts | 74 +++-- src/config/sessions/store.pruning.test.ts | 17 +- ...onse-has-heartbeat-ok-but-includes.test.ts | 149 +++++----- .../isolated-agent.delivery.test-helpers.ts | 28 ++ ...agent.direct-delivery-forum-topics.test.ts | 42 +-- ...p-recipient-besteffortdeliver-true.test.ts | 46 ++- src/cron/isolated-agent.test-harness.ts | 29 +- ....uses-last-non-empty-agent-text-as.test.ts | 51 ++-- src/cron/service.delivery-plan.test.ts | 56 ++-- .../service.issue-13992-regression.test.ts | 66 ++--- ...s-main-jobs-empty-systemevent-text.test.ts | 34 +-- src/cron/service.store.migration.test.ts | 21 +- src/cron/service.test-harness.ts | 38 ++- src/gateway/call.test.ts | 32 +-- src/gateway/gateway-connection.test-mocks.ts | 27 ++ .../server-http.hooks-request-timeout.test.ts | 54 ++-- .../chat.directive-tags.test.ts | 79 +++--- src/gateway/server.config-patch.test.ts | 34 ++- src/gateway/server.cron.test.ts | 62 ++-- src/infra/exec-approval-forwarder.test.ts | 100 ++++--- src/infra/gateway-lock.test.ts | 50 ++-- src/node-host/exec-policy.test.ts | 199 ++++++------- src/slack/monitor.tool-result.test.ts | 41 +-- src/slack/send.upload.test.ts | 15 +- src/test-utils/fixture-suite.ts | 29 ++ src/tui/gateway-chat.test.ts | 36 +-- ...captures-media-path-image-messages.test.ts | 79 +++--- 75 files changed, 1886 insertions(+), 2136 deletions(-) create mode 100644 extensions/lobster/src/test-helpers.ts create mode 100644 extensions/test-utils/runtime-env.ts create mode 100644 src/acp/translator.test-helpers.ts create mode 100644 src/agents/models-config.test-utils.ts create mode 100644 src/auto-reply/reply.directive.directive-behavior.model-directive-test-utils.ts create mode 100644 src/gateway/gateway-connection.test-mocks.ts create mode 100644 src/test-utils/fixture-suite.ts diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index aabc5adf8fe..5db42331207 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => { const handleAction = bluebubblesMessageActions.handleAction!; const callHandleAction = (ctx: Omit[0], "channel">) => handleAction({ channel: "bluebubbles", ...ctx }); + const blueBubblesConfig = (): OpenClawConfig => ({ + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }); + const runReactAction = async (params: Record) => { + return await callHandleAction({ + action: "react", + params, + cfg: blueBubblesConfig(), + accountId: null, + }); + }; beforeEach(() => { vi.clearAllMocks(); @@ -285,23 +301,10 @@ describe("bluebubblesMessageActions", () => { it("sends reaction successfully with chatGuid", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( @@ -320,24 +323,11 @@ describe("bluebubblesMessageActions", () => { it("sends reaction removal successfully", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - remove: true, - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", + remove: true, }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 17060229930..7ebab0485df 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -64,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => { setBlueBubblesRuntime(runtimeStub); }); + async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) { + const largeBuffer = new Uint8Array(params.bufferBytes); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-large" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + ...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }), + }), + ).rejects.toThrow("too large"); + } + it("throws when guid is missing", async () => { const attachment: BlueBubblesAttachment = {}; await expect( @@ -175,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => { }); it("throws when attachment exceeds max bytes", async () => { - const largeBuffer = new Uint8Array(10 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + await expectAttachmentTooLarge({ + bufferBytes: 10 * 1024 * 1024, + maxBytes: 5 * 1024 * 1024, }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - maxBytes: 5 * 1024 * 1024, - }), - ).rejects.toThrow("too large"); }); it("uses default max bytes when not specified", async () => { - const largeBuffer = new Uint8Array(9 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("too large"); + await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 }); }); it("uses attachment mimeType as fallback when response has no content-type", async () => { diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index d22ded63613..cc37829bc9d 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({ }); describe("chat", () => { + function mockOkTextResponse() { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + } + + async function expectCalledUrlIncludesPassword(params: { + password: string; + invoke: () => Promise; + }) { + mockOkTextResponse(); + await params.invoke(); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(`password=${params.password}`); + } + + async function expectCalledUrlUsesConfigCredentials(params: { + serverHost: string; + password: string; + invoke: (cfg: { + channels: { bluebubbles: { serverUrl: string; password: string } }; + }) => Promise; + }) { + mockOkTextResponse(); + await params.invoke({ + channels: { + bluebubbles: { + serverUrl: `http://${params.serverHost}`, + password: params.password, + }, + }, + }); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(params.serverHost); + expect(calledUrl).toContain(`password=${params.password}`); + } + describe("markBlueBubblesChatRead", () => { it("does nothing when chatGuid is empty or whitespace", async () => { for (const chatGuid of ["", " "]) { @@ -73,18 +111,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await markBlueBubblesChatRead("chat-123", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + markBlueBubblesChatRead("chat-123", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -119,25 +153,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + markBlueBubblesChatRead("chat-123", { + cfg, + }), }); - - await markBlueBubblesChatRead("chat-123", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); }); @@ -536,18 +559,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -582,25 +601,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { + cfg, + }), }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); it("includes filename in multipart body", async () => { diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 0ea99f911f6..419ccc81e45 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -19,6 +19,27 @@ describe("reactions", () => { }); describe("sendBlueBubblesReaction", () => { + async function expectRemovedReaction(emoji: string) { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji, + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + } + it("throws when chatGuid is empty", async () => { await expect( sendBlueBubblesReaction({ @@ -208,45 +229,11 @@ describe("reactions", () => { }); it("sends reaction removal with dash prefix", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("love"); }); it("strips leading dash from emoji when remove flag is set", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "-love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("-love"); }); it("uses custom partIndex when provided", async () => { diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 9872372641e..6b2e5fe051f 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -44,6 +44,23 @@ function mockSendResponse(body: unknown) { }); } +function mockNewChatSendResponse(guid: string) { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid }, + }), + ), + }); +} + describe("send", () => { describe("resolveChatGuidForTarget", () => { const resolveHandleTargetGuid = async (data: Array>) => { @@ -453,20 +470,7 @@ describe("send", () => { }); it("strips markdown when creating a new chat", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-stripped" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-stripped"); const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", { serverUrl: "http://localhost:1234", @@ -483,20 +487,7 @@ describe("send", () => { }); it("creates a new chat when handle target is missing", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-guid" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-guid"); const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", { serverUrl: "http://localhost:1234", diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index c2864ec70c0..b11bdc99870 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -1,10 +1,6 @@ -import type { - OpenClawConfig, - PluginRuntime, - ResolvedLineAccount, - RuntimeEnv, -} from "openclaw/plugin-sdk"; +import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -47,16 +43,6 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { return { runtime, mocks: { writeConfigFile, resolveLineAccount } }; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function resolveAccount( resolveLineAccount: LineRuntimeMocks["resolveLineAccount"], cfg: OpenClawConfig, diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index abd1aedf17c..e5b0ce333f5 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -33,20 +33,10 @@ function createRuntime() { return { runtime, probeLineBot, monitorLineProvider }; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { token: string; secret: string; - runtime: RuntimeEnv; + runtime: ReturnType; }): ChannelGatewayContext { const snapshot: ChannelAccountSnapshot = { accountId: "default", diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 294e625ce2b..78de735f8ef 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -5,6 +5,12 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; const spawnState = vi.hoisted(() => ({ queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>, @@ -57,20 +63,9 @@ function fakeCtx(overrides: Partial = {}): OpenClawPl }; } -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("lobster plugin tool", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeAll(async () => { ({ createLobsterTool } = await import("./lobster-tool.js")); @@ -79,29 +74,7 @@ describe("lobster plugin tool", () => { }); afterEach(() => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); }); afterAll(async () => { @@ -156,17 +129,6 @@ describe("lobster plugin tool", () => { }); }; - const createWindowsShimFixture = async (params: { - shimPath: string; - scriptPath: string; - scriptToken: string; - }) => { - await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); - await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8"); - }; - it("runs lobster and returns parsed envelope in details", async () => { spawnState.queue.push({ stdout: JSON.stringify({ @@ -281,10 +243,10 @@ describe("lobster plugin tool", () => { setProcessPlatform("win32"); const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd"); - await createWindowsShimFixture({ + await createWindowsCmdShimFixture({ shimPath, scriptPath: shimScriptPath, - scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs", + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, }); process.env.PATHEXT = ".CMD;.EXE"; process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`; diff --git a/extensions/lobster/src/test-helpers.ts b/extensions/lobster/src/test-helpers.ts new file mode 100644 index 00000000000..30f2dc81d1b --- /dev/null +++ b/extensions/lobster/src/test-helpers.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext"; + +const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const; + +export type PlatformPathEnvSnapshot = { + platformDescriptor: PropertyDescriptor | undefined; + env: Record; +}; + +export function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot { + return { + platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"), + env: { + PATH: process.env.PATH, + Path: process.env.Path, + PATHEXT: process.env.PATHEXT, + Pathext: process.env.Pathext, + }, + }; +} + +export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void { + if (snapshot.platformDescriptor) { + Object.defineProperty(process, "platform", snapshot.platformDescriptor); + } + + for (const key of PATH_ENV_KEYS) { + const value = snapshot.env[key]; + if (value === undefined) { + delete process.env[key]; + continue; + } + process.env[key] = value; + } +} + +export async function createWindowsCmdShimFixture(params: { + shimPath: string; + scriptPath: string; + shimLine: string; +}): Promise { + await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); + await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8"); +} diff --git a/extensions/lobster/src/windows-spawn.test.ts b/extensions/lobster/src/windows-spawn.test.ts index 75f49f34b05..e3d791e36e4 100644 --- a/extensions/lobster/src/windows-spawn.test.ts +++ b/extensions/lobster/src/windows-spawn.test.ts @@ -2,22 +2,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("resolveWindowsLobsterSpawn", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); @@ -25,29 +20,7 @@ describe("resolveWindowsLobsterSpawn", () => { }); afterEach(async () => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); tempDir = ""; @@ -57,14 +30,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); @@ -75,14 +45,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %~dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 9590727e407..f33541cb8d3 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,9 +1,5 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { downloadMSTeamsAttachments } from "./attachments/download.js"; -import { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js"; -import { buildMSTeamsAttachmentPlaceholder } from "./attachments/html.js"; -import { buildMSTeamsMediaPayload } from "./attachments/payload.js"; import { setMSTeamsRuntime } from "./runtime.js"; /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */ @@ -52,7 +48,58 @@ const runtimeStub = { }, } as unknown as PluginRuntime; +type AttachmentsModule = typeof import("./attachments.js"); +type DownloadAttachmentsParams = Parameters[0]; +type DownloadGraphMediaParams = Parameters[0]; + +const DEFAULT_MESSAGE_URL = "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123"; +const DEFAULT_MAX_BYTES = 1024 * 1024; +const DEFAULT_ALLOW_HOSTS = ["x"]; + +const createOkFetchMock = (contentType: string, payload = "png") => + vi.fn(async () => { + return new Response(Buffer.from(payload), { + status: 200, + headers: { "content-type": contentType }, + }); + }); + +const buildDownloadParams = ( + attachments: DownloadAttachmentsParams["attachments"], + overrides: Partial< + Omit + > & + Pick = {}, +): DownloadAttachmentsParams => { + return { + attachments, + maxBytes: DEFAULT_MAX_BYTES, + allowHosts: DEFAULT_ALLOW_HOSTS, + resolveFn: publicResolveFn, + ...overrides, + }; +}; + +const buildDownloadGraphParams = ( + fetchFn: typeof fetch, + overrides: Partial< + Omit + > = {}, +): DownloadGraphMediaParams => { + return { + messageUrl: DEFAULT_MESSAGE_URL, + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + maxBytes: DEFAULT_MAX_BYTES, + fetchFn, + ...overrides, + }; +}; + describe("msteams attachments", () => { + const load = async () => { + return await import("./attachments.js"); + }; + beforeEach(() => { detectMimeMock.mockClear(); saveMediaBufferMock.mockClear(); @@ -62,11 +109,13 @@ describe("msteams attachments", () => { describe("buildMSTeamsAttachmentPlaceholder", () => { it("returns empty string when no attachments", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe(""); expect(buildMSTeamsAttachmentPlaceholder([])).toBe(""); }); it("returns image placeholder for image attachments", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "image/png", contentUrl: "https://x/img.png" }, @@ -81,6 +130,7 @@ describe("msteams attachments", () => { }); it("treats Teams file.download.info image attachments as images", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { @@ -92,6 +142,7 @@ describe("msteams attachments", () => { }); it("returns document placeholder for non-image attachments", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { contentType: "application/pdf", contentUrl: "https://x/x.pdf" }, @@ -106,6 +157,7 @@ describe("msteams attachments", () => { }); it("counts inline images in text/html attachments", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); expect( buildMSTeamsAttachmentPlaceholder([ { @@ -127,20 +179,13 @@ describe("msteams attachments", () => { describe("downloadMSTeamsAttachments", () => { it("downloads and stores image contentUrl attachments", async () => { - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = createOkFetchMock("image/png"); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], { + fetchFn: fetchMock as unknown as typeof fetch, + }), + ); expect(fetchMock).toHaveBeenCalled(); expect(saveMediaBufferMock).toHaveBeenCalled(); @@ -149,50 +194,38 @@ describe("msteams attachments", () => { }); it("supports Teams file.download.info downloadUrl attachments", async () => { - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "application/vnd.microsoft.teams.file.download.info", - content: { downloadUrl: "https://x/dl", fileType: "png" }, - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = createOkFetchMock("image/png"); + const media = await downloadMSTeamsAttachments( + buildDownloadParams( + [ + { + contentType: "application/vnd.microsoft.teams.file.download.info", + content: { downloadUrl: "https://x/dl", fileType: "png" }, + }, + ], + { fetchFn: fetchMock as unknown as typeof fetch }, + ), + ); expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); }); it("downloads non-image file attachments (PDF)", async () => { - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("pdf"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - }); + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = createOkFetchMock("application/pdf", "pdf"); detectMimeMock.mockResolvedValueOnce("application/pdf"); saveMediaBufferMock.mockResolvedValueOnce({ path: "/tmp/saved.pdf", contentType: "application/pdf", }); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], { + fetchFn: fetchMock as unknown as typeof fetch, + }), + ); expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); @@ -201,48 +234,42 @@ describe("msteams attachments", () => { }); it("downloads inline image URLs from html attachments", async () => { - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "text/html", - content: '', - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = createOkFetchMock("image/png"); + const media = await downloadMSTeamsAttachments( + buildDownloadParams( + [ + { + contentType: "text/html", + content: '', + }, + ], + { fetchFn: fetchMock as unknown as typeof fetch }, + ), + ); expect(media).toHaveLength(1); expect(fetchMock).toHaveBeenCalled(); }); it("stores inline data:image base64 payloads", async () => { + const { downloadMSTeamsAttachments } = await load(); const base64 = Buffer.from("png").toString("base64"); - const media = await downloadMSTeamsAttachments({ - attachments: [ + const media = await downloadMSTeamsAttachments( + buildDownloadParams([ { contentType: "text/html", content: ``, }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - }); + ]), + ); expect(media).toHaveLength(1); expect(saveMediaBufferMock).toHaveBeenCalled(); }); it("retries with auth when the first request is unauthorized", async () => { + const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { const headers = new Headers(opts?.headers); const hasAuth = Boolean(headers.get("Authorization")); @@ -255,21 +282,20 @@ describe("msteams attachments", () => { }); }); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - allowHosts: ["x"], - authAllowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], { + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + authAllowHosts: ["x"], + fetchFn: fetchMock as unknown as typeof fetch, + }), + ); expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); }); it("skips auth retries when the host is not in auth allowlist", async () => { + const { downloadMSTeamsAttachments } = await load(); const tokenProvider = { getAccessToken: vi.fn(async () => "token") }; const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { const headers = new Headers(opts?.headers); @@ -283,17 +309,17 @@ describe("msteams attachments", () => { }); }); - const media = await downloadMSTeamsAttachments({ - attachments: [ - { contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }, - ], - maxBytes: 1024 * 1024, - tokenProvider, - allowHosts: ["azureedge.net"], - authAllowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams( + [{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }], + { + tokenProvider, + allowHosts: ["azureedge.net"], + authAllowHosts: ["graph.microsoft.com"], + fetchFn: fetchMock as unknown as typeof fetch, + }, + ), + ); expect(media).toHaveLength(0); expect(fetchMock).toHaveBeenCalled(); @@ -301,13 +327,15 @@ describe("msteams attachments", () => { }); it("skips urls outside the allowlist", async () => { + const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([{ contentType: "image/png", contentUrl: "https://evil.test/img" }], { + allowHosts: ["graph.microsoft.com"], + resolveFn: undefined, + fetchFn: fetchMock as unknown as typeof fetch, + }), + ); expect(media).toHaveLength(0); expect(fetchMock).not.toHaveBeenCalled(); @@ -316,6 +344,7 @@ describe("msteams attachments", () => { describe("buildMSTeamsGraphMessageUrls", () => { it("builds channel message urls", async () => { + const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "channel", conversationId: "19:thread@thread.tacv2", @@ -326,6 +355,7 @@ describe("msteams attachments", () => { }); it("builds channel reply urls when replyToId is present", async () => { + const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "channel", messageId: "reply-id", @@ -338,6 +368,7 @@ describe("msteams attachments", () => { }); it("builds chat message urls", async () => { + const { buildMSTeamsGraphMessageUrls } = await load(); const urls = buildMSTeamsGraphMessageUrls({ conversationType: "groupChat", conversationId: "19:chat@thread.v2", @@ -349,6 +380,7 @@ describe("msteams attachments", () => { describe("downloadMSTeamsGraphMedia", () => { it("downloads hostedContents images", async () => { + const { downloadMSTeamsGraphMedia } = await load(); const base64 = Buffer.from("png").toString("base64"); const fetchMock = vi.fn(async (url: string) => { if (url.endsWith("/hostedContents")) { @@ -371,12 +403,9 @@ describe("msteams attachments", () => { return new Response("not found", { status: 404 }); }); - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadMSTeamsGraphMedia( + buildDownloadGraphParams(fetchMock as unknown as typeof fetch), + ); expect(media.media).toHaveLength(1); expect(fetchMock).toHaveBeenCalled(); @@ -384,6 +413,7 @@ describe("msteams attachments", () => { }); it("merges SharePoint reference attachments with hosted content", async () => { + const { downloadMSTeamsGraphMedia } = await load(); const hostedBase64 = Buffer.from("png").toString("base64"); const shareUrl = "https://contoso.sharepoint.com/site/file"; const fetchMock = vi.fn(async (url: string) => { @@ -440,17 +470,15 @@ describe("msteams attachments", () => { return new Response("not found", { status: 404 }); }); - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadMSTeamsGraphMedia( + buildDownloadGraphParams(fetchMock as unknown as typeof fetch), + ); expect(media.media).toHaveLength(2); }); it("blocks SharePoint redirects to hosts outside allowHosts", async () => { + const { downloadMSTeamsGraphMedia } = await load(); const shareUrl = "https://contoso.sharepoint.com/site/file"; const escapedUrl = "https://evil.example/internal.pdf"; fetchRemoteMediaMock.mockImplementationOnce(async (params) => { @@ -515,13 +543,11 @@ describe("msteams attachments", () => { return new Response("not found", { status: 404 }); }); - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadMSTeamsGraphMedia( + buildDownloadGraphParams(fetchMock as unknown as typeof fetch, { + allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], + }), + ); expect(media.media).toHaveLength(0); const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); @@ -534,6 +560,7 @@ describe("msteams attachments", () => { describe("buildMSTeamsMediaPayload", () => { it("returns single and multi-file fields", async () => { + const { buildMSTeamsMediaPayload } = await load(); const payload = buildMSTeamsMediaPayload([ { path: "/tmp/a.png", contentType: "image/png" }, { path: "/tmp/b.png", contentType: "image/png" }, diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index cbd562ae3ad..ba176019994 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -49,6 +49,28 @@ const runtimeStub = { }, } as unknown as PluginRuntime; +const createNoopAdapter = (): MSTeamsAdapter => ({ + continueConversation: async () => {}, + process: async () => {}, +}); + +const createRecordedSendActivity = ( + sink: string[], + failFirstWithStatusCode?: number, +): ((activity: unknown) => Promise<{ id: string }>) => { + let attempts = 0; + return async (activity: unknown) => { + const { text } = activity as { text?: string }; + const content = text ?? ""; + sink.push(content); + attempts += 1; + if (failFirstWithStatusCode !== undefined && attempts === 1) { + throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode }); + } + return { id: `id:${content}` }; + }; +}; + describe("msteams messenger", () => { beforeEach(() => { setMSTeamsRuntime(runtimeStub); @@ -117,17 +139,9 @@ describe("msteams messenger", () => { it("sends thread messages via the provided context", async () => { const sent: string[] = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - sent.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(sent), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -149,11 +163,7 @@ describe("msteams messenger", () => { continueConversation: async (_appId, reference, logic) => { seen.reference = reference; await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - seen.texts.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, + sendActivity: createRecordedSendActivity(seen.texts), }); }, process: async () => {}, @@ -192,10 +202,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -242,20 +249,9 @@ describe("msteams messenger", () => { const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("throttled"), { statusCode: 429 }); - } - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(attempts, 429), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -280,10 +276,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); await expect( sendMSTeamsMessages({ @@ -303,18 +296,7 @@ describe("msteams messenger", () => { const adapter: MSTeamsAdapter = { continueConversation: async (_appId, _reference, logic) => { - await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("server error"), { - statusCode: 503, - }); - } - return { id: `id:${text ?? ""}` }; - }, - }); + await logic({ sendActivity: createRecordedSendActivity(attempts, 503) }); }, process: async () => {}, }; diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index d0c1c30ac8b..5e2d3c838d5 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -204,6 +204,23 @@ describe("nostr-profile-http", () => { }); describe("PUT /api/channels/nostr/:accountId/profile", () => { + async function expectPrivatePictureRejected(pictureUrl: string) { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "hacker", + picture: pictureUrl, + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + expect(data.error).toContain("private"); + } + it("validates profile and publishes", async () => { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); @@ -263,37 +280,11 @@ describe("nostr-profile-http", () => { }); it("rejects private IP in picture URL (SSRF protection)", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://127.0.0.1/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg"); }); it("rejects ISATAP-embedded private IPv4 in picture URL", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg"); }); it("rejects non-https URLs", async () => { diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index 9aa14f3f5f3..edb48306948 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -23,14 +23,14 @@ async function settleTimers(promise: Promise): Promise { return promise; } -function mockSuccessResponse() { +function mockResponse(statusCode: number, body: string) { const httpsRequest = vi.mocked(https.request); httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { const res = new EventEmitter() as any; - res.statusCode = 200; + res.statusCode = statusCode; process.nextTick(() => { callback(res); - res.emit("data", Buffer.from('{"success":true}')); + res.emit("data", Buffer.from(body)); res.emit("end"); }); const req = new EventEmitter() as any; @@ -41,22 +41,12 @@ function mockSuccessResponse() { }); } +function mockSuccessResponse() { + mockResponse(200, '{"success":true}'); +} + function mockFailureResponse(statusCode = 500) { - const httpsRequest = vi.mocked(https.request); - httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { - const res = new EventEmitter() as any; - res.statusCode = statusCode; - process.nextTick(() => { - callback(res); - res.emit("data", Buffer.from("error")); - res.emit("end"); - }); - const req = new EventEmitter() as any; - req.write = vi.fn(); - req.end = vi.fn(); - req.destroy = vi.fn(); - return req; - }); + mockResponse(statusCode, "error"); } describe("sendMessage", () => { diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 9248cc427e6..7e20c100610 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -80,6 +80,24 @@ describe("createWebhookHandler", () => { }; }); + async function expectForbiddenByPolicy(params: { + account: Partial; + bodyContains: string; + }) { + const handler = createWebhookHandler({ + account: makeAccount(params.account), + deliver: vi.fn(), + log, + }); + + const req = makeReq("POST", validBody); + const res = makeRes(); + await handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain(params.bodyContains); + } + it("rejects non-POST methods with 405", async () => { const handler = createWebhookHandler({ account: makeAccount(), @@ -129,36 +147,20 @@ describe("createWebhookHandler", () => { }); it("returns 403 for unauthorized user with allowlist policy", async () => { - const handler = createWebhookHandler({ - account: makeAccount({ + await expectForbiddenByPolicy({ + account: { dmPolicy: "allowlist", allowedUserIds: ["456"], - }), - deliver: vi.fn(), - log, + }, + bodyContains: "not authorized", }); - - const req = makeReq("POST", validBody); - const res = makeRes(); - await handler(req, res); - - expect(res._status).toBe(403); - expect(res._body).toContain("not authorized"); }); it("returns 403 when DMs are disabled", async () => { - const handler = createWebhookHandler({ - account: makeAccount({ dmPolicy: "disabled" }), - deliver: vi.fn(), - log, + await expectForbiddenByPolicy({ + account: { dmPolicy: "disabled" }, + bodyContains: "disabled", }); - - const req = makeReq("POST", validBody); - const res = makeRes(); - await handler(req, res); - - expect(res._status).toBe(403); - expect(res._body).toContain("disabled"); }); it("returns 429 when rate limited", async () => { diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index ffe4ce58fb7..0fd75ae7664 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedTelegramAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { telegramPlugin } from "./channel.js"; import { setTelegramRuntime } from "./runtime.js"; @@ -25,20 +25,10 @@ function createCfg(): OpenClawConfig { } as OpenClawConfig; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { cfg: OpenClawConfig; accountId: string; - runtime: RuntimeEnv; + runtime: ReturnType; }): ChannelGatewayContext { const account = telegramPlugin.config.resolveAccount( params.cfg, diff --git a/extensions/test-utils/runtime-env.ts b/extensions/test-utils/runtime-env.ts new file mode 100644 index 00000000000..747ad5f5f3a --- /dev/null +++ b/extensions/test-utils/runtime-env.ts @@ -0,0 +1,12 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { vi } from "vitest"; + +export function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} diff --git a/extensions/twitch/src/access-control.test.ts b/extensions/twitch/src/access-control.test.ts index fc8fd184d1e..83746717e4a 100644 --- a/extensions/twitch/src/access-control.test.ts +++ b/extensions/twitch/src/access-control.test.ts @@ -17,6 +17,38 @@ describe("checkTwitchAccessControl", () => { channel: "testchannel", }; + function runAccessCheck(params: { + account?: Partial; + message?: Partial; + }) { + return checkTwitchAccessControl({ + message: { + ...mockMessage, + ...params.message, + }, + account: { + ...mockAccount, + ...params.account, + }, + botUsername: "testbot", + }); + } + + function expectSingleRoleAllowed(params: { + role: NonNullable[number]; + message: Partial; + }) { + const result = runAccessCheck({ + account: { allowedRoles: [params.role] }, + message: { + message: "@testbot hello", + ...params.message, + }, + }); + expect(result.allowed).toBe(true); + return result; + } + describe("when no restrictions are configured", () => { it("allows messages that mention the bot (default requireMention)", () => { const message: TwitchChatMessage = { @@ -243,22 +275,10 @@ describe("checkTwitchAccessControl", () => { describe("allowedRoles", () => { it("allows users with matching role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); expect(result.matchSource).toBe("role"); }); @@ -323,79 +343,31 @@ describe("checkTwitchAccessControl", () => { }); it("handles moderator role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); }); it("handles subscriber role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["subscriber"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isSub: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "subscriber", + message: { isSub: true }, }); - expect(result.allowed).toBe(true); }); it("handles owner role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "owner", + message: { isOwner: true }, }); - expect(result.allowed).toBe(true); }); it("handles vip role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["vip"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isVip: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "vip", + message: { isVip: true }, }); - expect(result.allowed).toBe(true); }); }); @@ -421,21 +393,15 @@ describe("checkTwitchAccessControl", () => { }); it("checks allowlist before allowedRoles", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowFrom: ["123456"], - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: false, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = runAccessCheck({ + account: { + allowFrom: ["123456"], + allowedRoles: ["owner"], + }, + message: { + message: "@testbot hello", + isOwner: false, + }, }); expect(result.allowed).toBe(true); expect(result.matchSource).toBe("allowlist"); diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index f1d5b5d6f03..f37f8624267 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -71,19 +71,26 @@ function createInboundInitiatedEvent(params: { }; } +function createRejectingInboundContext(): { + ctx: CallManagerContext; + hangupCalls: HangupCallInput[]; +} { + const hangupCalls: HangupCallInput[] = []; + const provider = createProvider({ + hangupCall: async (input: HangupCallInput): Promise => { + hangupCalls.push(input); + }, + }); + const ctx = createContext({ + config: createInboundDisabledConfig(), + provider, + }); + return { ctx, hangupCalls }; +} + describe("processEvent (functional)", () => { it("calls provider hangup when rejecting inbound call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - - const ctx = createContext({ - config: createInboundDisabledConfig(), - provider, - }); + const { ctx, hangupCalls } = createRejectingInboundContext(); const event = createInboundInitiatedEvent({ id: "evt-1", providerCallId: "prov-1", @@ -118,16 +125,7 @@ describe("processEvent (functional)", () => { }); it("calls hangup only once for duplicate events for same rejected call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - const ctx = createContext({ - config: createInboundDisabledConfig(), - provider, - }); + const { ctx, hangupCalls } = createRejectingInboundContext(); const event1 = createInboundInitiatedEvent({ id: "evt-init", providerCallId: "prov-dup", diff --git a/src/acp/translator.prompt-prefix.test.ts b/src/acp/translator.prompt-prefix.test.ts index d0f0f66cda9..f6d2b93d263 100644 --- a/src/acp/translator.prompt-prefix.test.ts +++ b/src/acp/translator.prompt-prefix.test.ts @@ -1,16 +1,11 @@ import os from "node:os"; import path from "node:path"; -import type { AgentSideConnection, PromptRequest } from "@agentclientprotocol/sdk"; +import type { PromptRequest } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; - -function createConnection(): AgentSideConnection { - return { - sessionUpdate: vi.fn(async () => {}), - } as unknown as AgentSideConnection; -} +import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; describe("acp prompt cwd prefix", () => { async function runPromptWithCwd(cwd: string) { @@ -33,14 +28,14 @@ describe("acp prompt cwd prefix", () => { } return {}; }); - const gateway = { - request: requestSpy, - } as unknown as GatewayClient; - - const agent = new AcpGatewayAgent(createConnection(), gateway, { - sessionStore, - prefixCwd: true, - }); + const agent = new AcpGatewayAgent( + createAcpConnection(), + createAcpGateway(requestSpy as unknown as GatewayClient["request"]), + { + sessionStore, + prefixCwd: true, + }, + ); try { await expect( diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 3e3977da124..2e7d03b0f7b 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -1,5 +1,4 @@ import type { - AgentSideConnection, LoadSessionRequest, NewSessionRequest, PromptRequest, @@ -8,20 +7,7 @@ import { describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; - -function createConnection(): AgentSideConnection { - return { - sessionUpdate: vi.fn(async () => {}), - } as unknown as AgentSideConnection; -} - -function createGateway( - request: GatewayClient["request"] = vi.fn(async () => ({ ok: true })) as GatewayClient["request"], -): GatewayClient { - return { - request, - } as unknown as GatewayClient; -} +import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; function createNewSessionRequest(cwd = "/tmp"): NewSessionRequest { return { @@ -55,7 +41,7 @@ function createPromptRequest( async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) { const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; const sessionStore = createInMemorySessionStore(); - const agent = new AcpGatewayAgent(createConnection(), createGateway(request), { + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), { sessionStore, }); await agent.loadSession(createLoadSessionRequest(params.sessionId)); @@ -74,7 +60,7 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text: describe("acp session creation rate limit", () => { it("rate limits excessive newSession bursts", async () => { const sessionStore = createInMemorySessionStore(); - const agent = new AcpGatewayAgent(createConnection(), createGateway(), { + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), { sessionStore, sessionCreateRateLimit: { maxRequests: 2, @@ -93,7 +79,7 @@ describe("acp session creation rate limit", () => { it("does not count loadSession refreshes for an existing session ID", async () => { const sessionStore = createInMemorySessionStore(); - const agent = new AcpGatewayAgent(createConnection(), createGateway(), { + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), { sessionStore, sessionCreateRateLimit: { maxRequests: 1, diff --git a/src/acp/translator.test-helpers.ts b/src/acp/translator.test-helpers.ts new file mode 100644 index 00000000000..c80918ba2cc --- /dev/null +++ b/src/acp/translator.test-helpers.ts @@ -0,0 +1,17 @@ +import type { AgentSideConnection } from "@agentclientprotocol/sdk"; +import { vi } from "vitest"; +import type { GatewayClient } from "../gateway/client.js"; + +export function createAcpConnection(): AgentSideConnection { + return { + sessionUpdate: vi.fn(async () => {}), + } as unknown as AgentSideConnection; +} + +export function createAcpGateway( + request: GatewayClient["request"] = vi.fn(async () => ({ ok: true })) as GatewayClient["request"], +): GatewayClient { + return { + request, + } as unknown as GatewayClient; +} diff --git a/src/agents/bash-tools.process.poll-timeout.test.ts b/src/agents/bash-tools.process.poll-timeout.test.ts index 00482a9c1e0..269bd5aa9d9 100644 --- a/src/agents/bash-tools.process.poll-timeout.test.ts +++ b/src/agents/bash-tools.process.poll-timeout.test.ts @@ -46,28 +46,33 @@ function pollStatus(result: Awaited { +async function expectCompletedPollWithTimeout(params: { + sessionId: string; + callId: string; + timeout: number | string; + advanceMs: number; + assertUnresolvedAtMs?: number; +}) { vi.useFakeTimers(); try { - const sessionId = "sess"; - const { processTool, session } = createProcessSessionHarness(sessionId); + const { processTool, session } = createProcessSessionHarness(params.sessionId); setTimeout(() => { appendOutput(session, "stdout", "done\n"); markExited(session, 0, null, "completed"); }, 10); - const pollPromise = pollSession(processTool, "toolcall", sessionId, 2000); + const pollPromise = pollSession(processTool, params.callId, params.sessionId, params.timeout); + if (params.assertUnresolvedAtMs !== undefined) { + let resolved = false; + void pollPromise.finally(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(params.assertUnresolvedAtMs); + expect(resolved).toBe(false); + } - let resolved = false; - void pollPromise.finally(() => { - resolved = true; - }); - - await vi.advanceTimersByTimeAsync(200); - expect(resolved).toBe(false); - - await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(params.advanceMs); const poll = await pollPromise; const details = poll.details as { status?: string; aggregated?: string }; expect(details.status).toBe("completed"); @@ -75,27 +80,25 @@ test("process poll waits for completion when timeout is provided", async () => { } finally { vi.useRealTimers(); } +} + +test("process poll waits for completion when timeout is provided", async () => { + await expectCompletedPollWithTimeout({ + sessionId: "sess", + callId: "toolcall", + timeout: 2000, + assertUnresolvedAtMs: 200, + advanceMs: 100, + }); }); test("process poll accepts string timeout values", async () => { - vi.useFakeTimers(); - try { - const sessionId = "sess-2"; - const { processTool, session } = createProcessSessionHarness(sessionId); - setTimeout(() => { - appendOutput(session, "stdout", "done\n"); - markExited(session, 0, null, "completed"); - }, 10); - - const pollPromise = pollSession(processTool, "toolcall", sessionId, "2000"); - await vi.advanceTimersByTimeAsync(350); - const poll = await pollPromise; - const details = poll.details as { status?: string; aggregated?: string }; - expect(details.status).toBe("completed"); - expect(details.aggregated ?? "").toContain("done"); - } finally { - vi.useRealTimers(); - } + await expectCompletedPollWithTimeout({ + sessionId: "sess-2", + callId: "toolcall", + timeout: "2000", + advanceMs: 350, + }); }); test("process poll exposes adaptive retryInMs for repeated no-output polls", async () => { diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index 4bcd3c07cd5..0035447063d 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -35,6 +35,18 @@ async function resolveBedrockProvider() { }); } +async function expectBedrockAuthSource(params: { + env: Record; + expectedSource: string; +}) { + await withEnvAsync(params.env, async () => { + const resolved = await resolveBedrockProvider(); + expect(resolved.mode).toBe("aws-sdk"); + expect(resolved.apiKey).toBeUndefined(); + expect(resolved.source).toContain(params.expectedSource); + }); +} + describe("getApiKeyForModel", () => { it("migrates legacy oauth.json into auth-profiles.json", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-")); @@ -226,57 +238,39 @@ describe("getApiKeyForModel", () => { }); it("prefers Bedrock bearer token over access keys and profile", async () => { - await withEnvAsync( - { + await expectBedrockAuthSource({ + env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-token", AWS_ACCESS_KEY_ID: "access-key", AWS_SECRET_ACCESS_KEY: "secret-key", AWS_PROFILE: "profile", }, - async () => { - const resolved = await resolveBedrockProvider(); - - expect(resolved.mode).toBe("aws-sdk"); - expect(resolved.apiKey).toBeUndefined(); - expect(resolved.source).toContain("AWS_BEARER_TOKEN_BEDROCK"); - }, - ); + expectedSource: "AWS_BEARER_TOKEN_BEDROCK", + }); }); it("prefers Bedrock access keys over profile", async () => { - await withEnvAsync( - { + await expectBedrockAuthSource({ + env: { AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_ACCESS_KEY_ID: "access-key", AWS_SECRET_ACCESS_KEY: "secret-key", AWS_PROFILE: "profile", }, - async () => { - const resolved = await resolveBedrockProvider(); - - expect(resolved.mode).toBe("aws-sdk"); - expect(resolved.apiKey).toBeUndefined(); - expect(resolved.source).toContain("AWS_ACCESS_KEY_ID"); - }, - ); + expectedSource: "AWS_ACCESS_KEY_ID", + }); }); it("uses Bedrock profile when access keys are missing", async () => { - await withEnvAsync( - { + await expectBedrockAuthSource({ + env: { AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_ACCESS_KEY_ID: undefined, AWS_SECRET_ACCESS_KEY: undefined, AWS_PROFILE: "profile", }, - async () => { - const resolved = await resolveBedrockProvider(); - - expect(resolved.mode).toBe("aws-sdk"); - expect(resolved.apiKey).toBeUndefined(); - expect(resolved.source).toContain("AWS_PROFILE"); - }, - ); + expectedSource: "AWS_PROFILE", + }); }); it("accepts VOYAGE_API_KEY for voyage", async () => { diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 75fca258ef6..bb5704be1a7 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -107,6 +107,60 @@ function createOverrideFailureRun(params: { }); } +function makeSingleProviderStore(params: { + provider: string; + usageStat: NonNullable[string]; +}): AuthProfileStore { + const profileId = `${params.provider}:default`; + return { + version: AUTH_STORE_VERSION, + profiles: { + [profileId]: { + type: "api_key", + provider: params.provider, + key: "test-key", + }, + }, + usageStats: { + [profileId]: params.usageStat, + }, + }; +} + +function createFallbackOnlyRun() { + return vi.fn().mockImplementation(async (providerId, modelId) => { + if (providerId === "fallback") { + return "ok"; + } + throw new Error(`unexpected provider: ${providerId}/${modelId}`); + }); +} + +async function expectSkippedUnavailableProvider(params: { + providerPrefix: string; + usageStat: NonNullable[string]; + expectedReason: string; +}) { + const provider = `${params.providerPrefix}-${crypto.randomUUID()}`; + const cfg = makeProviderFallbackCfg(provider); + const store = makeSingleProviderStore({ + provider, + usageStat: params.usageStat, + }); + const run = createFallbackOnlyRun(); + + const result = await runWithStoredAuth({ + cfg, + store, + provider, + run, + }); + + expect(result.result).toBe("ok"); + expect(run.mock.calls).toEqual([["fallback", "ok-model"]]); + expect(result.attempts[0]?.reason).toBe(params.expectedReason); +} + describe("runWithModelFallback", () => { it("normalizes openai gpt-5.3 codex to openai-codex before running", async () => { const cfg = makeCfg(); @@ -310,86 +364,26 @@ describe("runWithModelFallback", () => { }); it("skips providers when all profiles are in cooldown", async () => { - const provider = `cooldown-test-${crypto.randomUUID()}`; - const profileId = `${provider}:default`; - - const store: AuthProfileStore = { - version: AUTH_STORE_VERSION, - profiles: { - [profileId]: { - type: "api_key", - provider, - key: "test-key", - }, + await expectSkippedUnavailableProvider({ + providerPrefix: "cooldown-test", + usageStat: { + cooldownUntil: Date.now() + 5 * 60_000, }, - usageStats: { - [profileId]: { - cooldownUntil: Date.now() + 5 * 60_000, - }, - }, - }; - - const cfg = makeProviderFallbackCfg(provider); - const run = vi.fn().mockImplementation(async (providerId, modelId) => { - if (providerId === "fallback") { - return "ok"; - } - throw new Error(`unexpected provider: ${providerId}/${modelId}`); + expectedReason: "rate_limit", }); - - const result = await runWithStoredAuth({ - cfg, - store, - provider, - run, - }); - - expect(result.result).toBe("ok"); - expect(run.mock.calls).toEqual([["fallback", "ok-model"]]); - expect(result.attempts[0]?.reason).toBe("rate_limit"); }); it("propagates disabled reason when all profiles are unavailable", async () => { - const provider = `disabled-test-${crypto.randomUUID()}`; - const profileId = `${provider}:default`; const now = Date.now(); - - const store: AuthProfileStore = { - version: AUTH_STORE_VERSION, - profiles: { - [profileId]: { - type: "api_key", - provider, - key: "test-key", - }, + await expectSkippedUnavailableProvider({ + providerPrefix: "disabled-test", + usageStat: { + disabledUntil: now + 5 * 60_000, + disabledReason: "billing", + failureCounts: { rate_limit: 4 }, }, - usageStats: { - [profileId]: { - disabledUntil: now + 5 * 60_000, - disabledReason: "billing", - failureCounts: { rate_limit: 4 }, - }, - }, - }; - - const cfg = makeProviderFallbackCfg(provider); - const run = vi.fn().mockImplementation(async (providerId, modelId) => { - if (providerId === "fallback") { - return "ok"; - } - throw new Error(`unexpected provider: ${providerId}/${modelId}`); + expectedReason: "billing", }); - - const result = await runWithStoredAuth({ - cfg, - store, - provider, - run, - }); - - expect(result.result).toBe("ok"); - expect(run.mock.calls).toEqual([["fallback", "ok-model"]]); - expect(result.attempts[0]?.reason).toBe("billing"); }); it("does not skip when any profile is available", async () => { diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 46942a52808..3f80eec7b54 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -10,6 +10,7 @@ import { withModelsTempHome as withTempHome, } from "./models-config.e2e-harness.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; installModelsConfigTestHooks(); @@ -34,11 +35,9 @@ describe("models-config", () => { await ensureOpenClawModelsJson(validated.config); - const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); - const raw = await fs.readFile(modelPath, "utf8"); - const parsed = JSON.parse(raw) as { + const parsed = await readGeneratedModelsJson<{ providers: Record }>; - }; + }>(); expect(parsed.providers.anthropic?.api).toBe("anthropic-messages"); expect(parsed.providers.anthropic?.models?.[0]?.api).toBe("anthropic-messages"); @@ -74,11 +73,9 @@ describe("models-config", () => { await ensureOpenClawModelsJson(cfg); - const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); - const raw = await fs.readFile(modelPath, "utf8"); - const parsed = JSON.parse(raw) as { + const parsed = await readGeneratedModelsJson<{ providers: Record }>; - }; + }>(); expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); const ids = parsed.providers.minimax?.models?.map((model) => model.id); expect(ids).toContain("MiniMax-VL-01"); diff --git a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts index d9ab9810a32..437b84be3a7 100644 --- a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts +++ b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts @@ -1,10 +1,8 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { installModelsConfigTestHooks, withModelsTempHome } from "./models-config.e2e-harness.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; describe("models-config", () => { installModelsConfigTestHooks(); @@ -47,11 +45,9 @@ describe("models-config", () => { await ensureOpenClawModelsJson(cfg); - const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); - const raw = await fs.readFile(modelPath, "utf8"); - const parsed = JSON.parse(raw) as { + const parsed = await readGeneratedModelsJson<{ providers: Record }>; - }; + }>(); const ids = parsed.providers.google?.models?.map((model) => model.id); expect(ids).toEqual(["gemini-3-pro-preview", "gemini-3-flash-preview"]); }); diff --git a/src/agents/models-config.test-utils.ts b/src/agents/models-config.test-utils.ts new file mode 100644 index 00000000000..bf5d3eadfc6 --- /dev/null +++ b/src/agents/models-config.test-utils.ts @@ -0,0 +1,9 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; + +export async function readGeneratedModelsJson(): Promise { + const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); + const raw = await fs.readFile(modelPath, "utf8"); + return JSON.parse(raw) as T; +} diff --git a/src/agents/ollama-stream.test.ts b/src/agents/ollama-stream.test.ts index 780f761fec0..7b085d90fa6 100644 --- a/src/agents/ollama-stream.test.ts +++ b/src/agents/ollama-stream.test.ts @@ -280,107 +280,105 @@ describe("parseNdjsonStream", () => { }); }); +async function withMockNdjsonFetch( + lines: string[], + run: (fetchMock: ReturnType) => Promise, +): Promise { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async () => { + const payload = lines.join("\n"); + return new Response(`${payload}\n`, { + status: 200, + headers: { "Content-Type": "application/x-ndjson" }, + }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + try { + await run(fetchMock); + } finally { + globalThis.fetch = originalFetch; + } +} + +async function createOllamaTestStream(params: { + baseUrl: string; + options?: { maxTokens?: number; signal?: AbortSignal }; +}) { + const streamFn = createOllamaStreamFn(params.baseUrl); + return streamFn( + { + id: "qwen3:32b", + api: "ollama", + provider: "custom-ollama", + contextWindow: 131072, + } as unknown as Parameters[0], + { + messages: [{ role: "user", content: "hello" }], + } as unknown as Parameters[1], + (params.options ?? {}) as unknown as Parameters[2], + ); +} + +async function collectStreamEvents(stream: AsyncIterable): Promise { + const events: T[] = []; + for await (const event of stream) { + events.push(event); + } + return events; +} + describe("createOllamaStreamFn", () => { it("normalizes /v1 baseUrl and maps maxTokens + signal", async () => { - const originalFetch = globalThis.fetch; - const fetchMock = vi.fn(async () => { - const payload = [ + await withMockNdjsonFetch( + [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}', '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}', - ].join("\n"); - return new Response(`${payload}\n`, { - status: 200, - headers: { "Content-Type": "application/x-ndjson" }, - }); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; + ], + async (fetchMock) => { + const signal = new AbortController().signal; + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434/v1/", + options: { maxTokens: 123, signal }, + }); - try { - const streamFn = createOllamaStreamFn("http://ollama-host:11434/v1/"); - const signal = new AbortController().signal; - const stream = await streamFn( - { - id: "qwen3:32b", - api: "ollama", - provider: "custom-ollama", - contextWindow: 131072, - } as unknown as Parameters[0], - { - messages: [{ role: "user", content: "hello" }], - } as unknown as Parameters[1], - { - maxTokens: 123, - signal, - } as unknown as Parameters[2], - ); + const events = await collectStreamEvents(stream); + expect(events.at(-1)?.type).toBe("done"); - const events = []; - for await (const event of stream) { - events.push(event); - } - expect(events.at(-1)?.type).toBe("done"); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("http://ollama-host:11434/api/chat"); + expect(requestInit.signal).toBe(signal); + if (typeof requestInit.body !== "string") { + throw new Error("Expected string request body"); + } - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; - expect(url).toBe("http://ollama-host:11434/api/chat"); - expect(requestInit.signal).toBe(signal); - if (typeof requestInit.body !== "string") { - throw new Error("Expected string request body"); - } - - const requestBody = JSON.parse(requestInit.body) as { - options: { num_ctx?: number; num_predict?: number }; - }; - expect(requestBody.options.num_ctx).toBe(131072); - expect(requestBody.options.num_predict).toBe(123); - } finally { - globalThis.fetch = originalFetch; - } + const requestBody = JSON.parse(requestInit.body) as { + options: { num_ctx?: number; num_predict?: number }; + }; + expect(requestBody.options.num_ctx).toBe(131072); + expect(requestBody.options.num_predict).toBe(123); + }, + ); }); it("accumulates reasoning chunks when content is empty", async () => { - const originalFetch = globalThis.fetch; - const fetchMock = vi.fn(async () => { - const payload = [ + await withMockNdjsonFetch( + [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":"reasoned"},"done":false}', '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":" output"},"done":false}', '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":2}', - ].join("\n"); - return new Response(`${payload}\n`, { - status: 200, - headers: { "Content-Type": "application/x-ndjson" }, - }); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; + ], + async () => { + const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }); + const events = await collectStreamEvents(stream); - try { - const streamFn = createOllamaStreamFn("http://ollama-host:11434"); - const stream = await streamFn( - { - id: "qwen3:32b", - api: "ollama", - provider: "custom-ollama", - contextWindow: 131072, - } as unknown as Parameters[0], - { - messages: [{ role: "user", content: "hello" }], - } as unknown as Parameters[1], - {} as unknown as Parameters[2], - ); + const doneEvent = events.at(-1); + if (!doneEvent || doneEvent.type !== "done") { + throw new Error("Expected done event"); + } - const events = []; - for await (const event of stream) { - events.push(event); - } - - const doneEvent = events.at(-1); - if (!doneEvent || doneEvent.type !== "done") { - throw new Error("Expected done event"); - } - - expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]); - } finally { - globalThis.fetch = originalFetch; - } + expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]); + }, + ); }); }); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index c31da1acc70..22dee7b49cd 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -43,17 +43,13 @@ vi.mock("../usage.js", () => ({ normalizeUsage: vi.fn((usage?: unknown) => usage && typeof usage === "object" ? usage : undefined, ), - derivePromptTokens: vi.fn( - (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { - if (!usage) { - return undefined; - } - const input = usage.input ?? 0; - const cacheRead = usage.cacheRead ?? 0; - const cacheWrite = usage.cacheWrite ?? 0; - const sum = input + cacheRead + cacheWrite; - return sum > 0 ? sum : undefined; - }, + derivePromptTokens: vi.fn((usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => + usage + ? (() => { + const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + return sum > 0 ? sum : undefined; + })() + : undefined, ), hasNonzeroUsage: vi.fn(() => false), })); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts index e56a29198eb..741fa96c815 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts @@ -78,6 +78,21 @@ async function emitPngMediaToolResult( }); } +async function emitUntrustedToolMediaResult( + ctx: EmbeddedPiSubscribeContext, + mediaPathOrUrl: string, +) { + await handleToolExecutionEnd(ctx, { + type: "tool_execution_end", + toolName: "plugin_tool", + toolCallId: "tc-1", + isError: false, + result: { + content: [{ type: "text", text: `MEDIA:${mediaPathOrUrl}` }], + }, + }); +} + describe("handleToolExecutionEnd media emission", () => { it("does not warn for read tool when path is provided via file_path alias", async () => { const ctx = createMockContext(); @@ -107,15 +122,7 @@ describe("handleToolExecutionEnd media emission", () => { const onToolResult = vi.fn(); const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult }); - await handleToolExecutionEnd(ctx, { - type: "tool_execution_end", - toolName: "plugin_tool", - toolCallId: "tc-1", - isError: false, - result: { - content: [{ type: "text", text: "MEDIA:/tmp/secret.png" }], - }, - }); + await emitUntrustedToolMediaResult(ctx, "/tmp/secret.png"); expect(onToolResult).not.toHaveBeenCalled(); }); @@ -124,15 +131,7 @@ describe("handleToolExecutionEnd media emission", () => { const onToolResult = vi.fn(); const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult }); - await handleToolExecutionEnd(ctx, { - type: "tool_execution_end", - toolName: "plugin_tool", - toolCallId: "tc-1", - isError: false, - result: { - content: [{ type: "text", text: "MEDIA:https://example.com/file.png" }], - }, - }); + await emitUntrustedToolMediaResult(ctx, "https://example.com/file.png"); expect(onToolResult).toHaveBeenCalledWith({ mediaUrls: ["https://example.com/file.png"], diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 82c968d23a8..2bce8b8bd69 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -41,6 +41,24 @@ describe("subscribeEmbeddedPiSession", () => { return { emit, subscription }; } + function createWriteFailureHarness(params: { + runId: string; + path: string; + content: string; + }): ReturnType { + const harness = createToolErrorHarness(params.runId); + emitToolRun({ + emit: harness.emit, + toolName: "write", + toolCallId: "w1", + args: { path: params.path, content: params.content }, + isError: true, + result: { error: "disk full" }, + }); + expect(harness.subscription.getLastToolError()?.toolName).toBe("write"); + return harness; + } + function emitToolRun(params: { emit: (evt: unknown) => void; toolName: string; @@ -389,17 +407,11 @@ describe("subscribeEmbeddedPiSession", () => { }); it("keeps unresolved mutating failure when an unrelated tool succeeds", () => { - const { emit, subscription } = createToolErrorHarness("run-tools-1"); - - emitToolRun({ - emit, - toolName: "write", - toolCallId: "w1", - args: { path: "/tmp/demo.txt", content: "next" }, - isError: true, - result: { error: "disk full" }, + const { emit, subscription } = createWriteFailureHarness({ + runId: "run-tools-1", + path: "/tmp/demo.txt", + content: "next", }); - expect(subscription.getLastToolError()?.toolName).toBe("write"); emitToolRun({ emit, @@ -414,17 +426,11 @@ describe("subscribeEmbeddedPiSession", () => { }); it("clears unresolved mutating failure when the same action succeeds", () => { - const { emit, subscription } = createToolErrorHarness("run-tools-2"); - - emitToolRun({ - emit, - toolName: "write", - toolCallId: "w1", - args: { path: "/tmp/demo.txt", content: "next" }, - isError: true, - result: { error: "disk full" }, + const { emit, subscription } = createWriteFailureHarness({ + runId: "run-tools-2", + path: "/tmp/demo.txt", + content: "next", }); - expect(subscription.getLastToolError()?.toolName).toBe("write"); emitToolRun({ emit, diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 868f7bcdc22..cf31823990b 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -113,6 +113,34 @@ describe("Agent-specific tool filtering", () => { }; } + function createExecHostDefaultsConfig( + agents: Array<{ id: string; execHost?: "gateway" | "sandbox" }>, + ): OpenClawConfig { + return { + tools: { + exec: { + host: "sandbox", + security: "full", + ask: "off", + }, + }, + agents: { + list: agents.map((agent) => ({ + id: agent.id, + ...(agent.execHost + ? { + tools: { + exec: { + host: agent.execHost, + }, + }, + } + : {}), + })), + }, + }; + } + it("should apply global tool policy when no agent-specific policy exists", () => { const cfg = createMainAgentConfig({ tools: { @@ -646,30 +674,10 @@ describe("Agent-specific tool filtering", () => { }); it("should apply agent-specific exec host defaults over global defaults", async () => { - const cfg: OpenClawConfig = { - tools: { - exec: { - host: "sandbox", - security: "full", - ask: "off", - }, - }, - agents: { - list: [ - { - id: "main", - tools: { - exec: { - host: "gateway", - }, - }, - }, - { - id: "helper", - }, - ], - }, - }; + const cfg = createExecHostDefaultsConfig([ + { id: "main", execHost: "gateway" }, + { id: "helper" }, + ]); const mainTools = createOpenClawCodingTools({ config: cfg, @@ -716,27 +724,7 @@ describe("Agent-specific tool filtering", () => { }); it("applies explicit agentId exec defaults when sessionKey is opaque", async () => { - const cfg: OpenClawConfig = { - tools: { - exec: { - host: "sandbox", - security: "full", - ask: "off", - }, - }, - agents: { - list: [ - { - id: "main", - tools: { - exec: { - host: "gateway", - }, - }, - }, - ], - }, - }; + const cfg = createExecHostDefaultsConfig([{ id: "main", execHost: "gateway" }]); const tools = createOpenClawCodingTools({ config: cfg, diff --git a/src/agents/skills-install-fallback.test.ts b/src/agents/skills-install-fallback.test.ts index 4d45ccaf9b8..7cd04aa98bb 100644 --- a/src/agents/skills-install-fallback.test.ts +++ b/src/agents/skills-install-fallback.test.ts @@ -18,13 +18,10 @@ vi.mock("../infra/net/fetch-guard.js", () => ({ fetchWithSsrFGuard: vi.fn(), })); -vi.mock("../security/skill-scanner.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), - }; -}); +vi.mock("../security/skill-scanner.js", async (importOriginal) => ({ + ...(await importOriginal()), + scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), +})); vi.mock("../shared/config-eval.js", async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/agents/skills-install.download.test.ts b/src/agents/skills-install.download.test.ts index 912b6ccb92e..2cbbe7e4227 100644 --- a/src/agents/skills-install.download.test.ts +++ b/src/agents/skills-install.download.test.ts @@ -5,10 +5,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import { createTempHomeEnv } from "../test-utils/temp-home.js"; import { setTempStateDir, writeDownloadSkill } from "./skills-install.download-test-utils.js"; import { installSkill } from "./skills-install.js"; - -const runCommandWithTimeoutMock = vi.fn(); -const scanDirectoryWithSummaryMock = vi.fn(); -const fetchWithSsrFGuardMock = vi.fn(); +import { + fetchWithSsrFGuardMock, + runCommandWithTimeoutMock, + scanDirectoryWithSummaryMock, +} from "./skills-install.test-mocks.js"; vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), @@ -18,13 +19,10 @@ vi.mock("../infra/net/fetch-guard.js", () => ({ fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), })); -vi.mock("../security/skill-scanner.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), - }; -}); +vi.mock("../security/skill-scanner.js", async (importOriginal) => ({ + ...(await importOriginal()), + scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), +})); async function fileExists(filePath: string): Promise { try { @@ -77,8 +75,8 @@ function mockTarExtractionFlow(params: { verboseListOutput: string; extract: "ok" | "reject"; }) { - runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => { - const cmd = argv as string[]; + runCommandWithTimeoutMock.mockImplementation(async (...argv: unknown[]) => { + const cmd = (argv[0] ?? []) as string[]; if (cmd[0] === "tar" && cmd[1] === "tf") { return runCommandResult({ stdout: params.listOutput }); } diff --git a/src/agents/skills-install.test.ts b/src/agents/skills-install.test.ts index 03c14808ba6..b7110ebb82a 100644 --- a/src/agents/skills-install.test.ts +++ b/src/agents/skills-install.test.ts @@ -12,13 +12,10 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); -vi.mock("../security/skill-scanner.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), - }; -}); +vi.mock("../security/skill-scanner.js", async (importOriginal) => ({ + ...(await importOriginal()), + scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), +})); async function writeInstallableSkill(workspaceDir: string, name: string): Promise { const skillDir = path.join(workspaceDir, "skills", name); diff --git a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts index ac28d9c3b5d..e063404f6cf 100644 --- a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts @@ -1,31 +1,24 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; +import { createFixtureSuite } from "../test-utils/fixture-suite.js"; import { writeSkill } from "./skills.e2e-test-helpers.js"; import { buildWorkspaceSkillsPrompt } from "./skills.js"; -let fixtureRoot = ""; -let fixtureCount = 0; - -async function createCaseDir(prefix: string): Promise { - const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); - await fs.mkdir(dir, { recursive: true }); - return dir; -} +const fixtureSuite = createFixtureSuite("openclaw-skills-prompt-suite-"); beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-prompt-suite-")); + await fixtureSuite.setup(); }); afterAll(async () => { - await fs.rm(fixtureRoot, { recursive: true, force: true }); + await fixtureSuite.cleanup(); }); describe("buildWorkspaceSkillsPrompt", () => { it("prefers workspace skills over managed skills", async () => { - const workspaceDir = await createCaseDir("workspace"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); const managedDir = path.join(workspaceDir, ".managed"); const bundledDir = path.join(workspaceDir, ".bundled"); const managedSkillDir = path.join(managedDir, "demo-skill"); @@ -62,7 +55,7 @@ describe("buildWorkspaceSkillsPrompt", () => { expect(prompt).not.toContain(path.join(bundledSkillDir, "SKILL.md")); }); it("gates by bins, config, and always", async () => { - const workspaceDir = await createCaseDir("workspace"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); const skillsDir = path.join(workspaceDir, "skills"); const binDir = path.join(workspaceDir, "bin"); @@ -130,7 +123,7 @@ describe("buildWorkspaceSkillsPrompt", () => { expect(gatedPrompt).not.toContain("config-skill"); }); it("uses skillKey for config lookups", async () => { - const workspaceDir = await createCaseDir("workspace"); + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); const skillDir = path.join(workspaceDir, "skills", "alias-skill"); await writeSkill({ dir: skillDir, diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index 34b08dac0c6..00f779c3314 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -59,106 +59,92 @@ vi.mock("./subagent-registry.js", () => ({ import { runSubagentAnnounceFlow } from "./subagent-announce.js"; +type AnnounceFlowParams = Parameters[0]; + +const defaultSessionConfig = { + mainKey: "main", + scope: "per-sender", +} as const; + +const baseAnnounceFlowParams = { + childSessionKey: "agent:main:subagent:worker", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + timeoutMs: 1_000, + cleanup: "keep", + roundOneReply: "done", + waitForCompletion: false, + outcome: { status: "ok" as const }, +} satisfies Omit; + +function setConfiguredAnnounceTimeout(timeoutMs: number): void { + configOverride = { + session: defaultSessionConfig, + agents: { + defaults: { + subagents: { + announceTimeoutMs: timeoutMs, + }, + }, + }, + }; +} + +async function runAnnounceFlowForTest( + childRunId: string, + overrides: Partial = {}, +): Promise { + await runSubagentAnnounceFlow({ + ...baseAnnounceFlowParams, + childRunId, + ...overrides, + }); +} + +function findGatewayCall(predicate: (call: GatewayCall) => boolean): GatewayCall | undefined { + return gatewayCalls.find(predicate); +} + describe("subagent announce timeout config", () => { beforeEach(() => { gatewayCalls.length = 0; sessionStore = {}; configOverride = { - session: { - mainKey: "main", - scope: "per-sender", - }, + session: defaultSessionConfig, }; }); it("uses 60s timeout by default for direct announce agent call", async () => { - await runSubagentAnnounceFlow({ - childSessionKey: "agent:main:subagent:worker", - childRunId: "run-default-timeout", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1_000, - cleanup: "keep", - roundOneReply: "done", - waitForCompletion: false, - outcome: { status: "ok" }, - }); + await runAnnounceFlowForTest("run-default-timeout"); - const directAgentCall = gatewayCalls.find( + const directAgentCall = findGatewayCall( (call) => call.method === "agent" && call.expectFinal === true, ); expect(directAgentCall?.timeoutMs).toBe(60_000); }); it("honors configured announce timeout for direct announce agent call", async () => { - configOverride = { - session: { - mainKey: "main", - scope: "per-sender", - }, - agents: { - defaults: { - subagents: { - announceTimeoutMs: 90_000, - }, - }, - }, - }; + setConfiguredAnnounceTimeout(90_000); + await runAnnounceFlowForTest("run-config-timeout-agent"); - await runSubagentAnnounceFlow({ - childSessionKey: "agent:main:subagent:worker", - childRunId: "run-config-timeout-agent", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "do thing", - timeoutMs: 1_000, - cleanup: "keep", - roundOneReply: "done", - waitForCompletion: false, - outcome: { status: "ok" }, - }); - - const directAgentCall = gatewayCalls.find( + const directAgentCall = findGatewayCall( (call) => call.method === "agent" && call.expectFinal === true, ); expect(directAgentCall?.timeoutMs).toBe(90_000); }); it("honors configured announce timeout for completion direct send call", async () => { - configOverride = { - session: { - mainKey: "main", - scope: "per-sender", - }, - agents: { - defaults: { - subagents: { - announceTimeoutMs: 90_000, - }, - }, - }, - }; - - await runSubagentAnnounceFlow({ - childSessionKey: "agent:main:subagent:worker", - childRunId: "run-config-timeout-send", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", + setConfiguredAnnounceTimeout(90_000); + await runAnnounceFlowForTest("run-config-timeout-send", { requesterOrigin: { channel: "discord", to: "12345", }, - task: "do thing", - timeoutMs: 1_000, - cleanup: "keep", - roundOneReply: "done", - waitForCompletion: false, - outcome: { status: "ok" }, expectsCompletionMessage: true, }); - const sendCall = gatewayCalls.find((call) => call.method === "send"); + const sendCall = findGatewayCall((call) => call.method === "send"); expect(sendCall?.timeoutMs).toBe(90_000); }); }); diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index c2c2fa14197..0eed4e05532 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -58,6 +58,7 @@ vi.mock("./subagent-registry.store.js", () => ({ describe("subagent registry steer restarts", () => { let mod: typeof import("./subagent-registry.js"); + type RegisterSubagentRunInput = Parameters[0]; beforeAll(async () => { mod = await import("./subagent-registry.js"); @@ -90,6 +91,42 @@ describe("subagent registry steer restarts", () => { } }; + const createDeferredAnnounceResolver = (): ((value: boolean) => void) => { + let resolveAnnounce!: (value: boolean) => void; + announceSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAnnounce = resolve; + }), + ); + return (value: boolean) => { + resolveAnnounce(value); + }; + }; + + const registerCompletionModeRun = ( + runId: string, + childSessionKey: string, + task: string, + options: Partial> = {}, + ): void => { + mod.registerSubagentRun({ + runId, + childSessionKey, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:123", + accountId: "work", + }, + task, + cleanup: "keep", + expectsCompletionMessage: true, + ...options, + }); + }; + afterEach(async () => { announceSpy.mockClear(); announceSpy.mockResolvedValue(true); @@ -159,29 +196,13 @@ describe("subagent registry steer restarts", () => { it("defers subagent_ended hook for completion-mode runs until announce delivery resolves", async () => { await withPendingAgentWait(async () => { - let resolveAnnounce!: (value: boolean) => void; - announceSpy.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveAnnounce = resolve; - }), + const resolveAnnounce = createDeferredAnnounceResolver(); + registerCompletionModeRun( + "run-completion-delayed", + "agent:main:subagent:completion-delayed", + "completion-mode task", ); - mod.registerSubagentRun({ - runId: "run-completion-delayed", - childSessionKey: "agent:main:subagent:completion-delayed", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - requesterOrigin: { - channel: "discord", - to: "channel:123", - accountId: "work", - }, - task: "completion-mode task", - cleanup: "keep", - expectsCompletionMessage: true, - }); - lifecycleHandler?.({ stream: "lifecycle", runId: "run-completion-delayed", @@ -211,30 +232,14 @@ describe("subagent registry steer restarts", () => { it("does not emit subagent_ended on completion for persistent session-mode runs", async () => { await withPendingAgentWait(async () => { - let resolveAnnounce!: (value: boolean) => void; - announceSpy.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveAnnounce = resolve; - }), + const resolveAnnounce = createDeferredAnnounceResolver(); + registerCompletionModeRun( + "run-persistent-session", + "agent:main:subagent:persistent-session", + "persistent session task", + { spawnMode: "session" }, ); - mod.registerSubagentRun({ - runId: "run-persistent-session", - childSessionKey: "agent:main:subagent:persistent-session", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - requesterOrigin: { - channel: "discord", - to: "channel:123", - accountId: "work", - }, - task: "persistent session task", - cleanup: "keep", - expectsCompletionMessage: true, - spawnMode: "session", - }); - lifecycleHandler?.({ stream: "lifecycle", runId: "run-persistent-session", diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index 4b77d68a8d6..4696de517ce 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -32,53 +32,46 @@ async function runReplyToCurrentCase(home: string, text: string) { return Array.isArray(res) ? res[0] : res; } +async function expectThinkStatusForReasoningModel(params: { + reasoning: boolean; + expectedLevel: "low" | "off"; +}): Promise { + await withTempHome(async (home) => { + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { + id: "claude-opus-4-5", + name: "Opus 4.5", + provider: "anthropic", + reasoning: params.reasoning, + }, + ]); + + const res = await getReplyFromConfig( + { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }), + ); + + const text = replyText(res); + expect(text).toContain(`Current thinking level: ${params.expectedLevel}`); + expect(text).toContain("Options: off, minimal, low, medium, high."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); it("defaults /think to low for reasoning-capable models when no default set", async () => { - await withTempHome(async (home) => { - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { - id: "claude-opus-4-5", - name: "Opus 4.5", - provider: "anthropic", - reasoning: true, - }, - ]); - - const res = await getReplyFromConfig( - { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }), - ); - - const text = replyText(res); - expect(text).toContain("Current thinking level: low"); - expect(text).toContain("Options: off, minimal, low, medium, high."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + await expectThinkStatusForReasoningModel({ + reasoning: true, + expectedLevel: "low", }); }); it("shows off when /think has no argument and model lacks reasoning", async () => { - await withTempHome(async (home) => { - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { - id: "claude-opus-4-5", - name: "Opus 4.5", - provider: "anthropic", - reasoning: false, - }, - ]); - - const res = await getReplyFromConfig( - { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }), - ); - - const text = replyText(res); - expect(text).toContain("Current thinking level: off"); - expect(text).toContain("Options: off, minimal, low, medium, high."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + await expectThinkStatusForReasoningModel({ + reasoning: false, + expectedLevel: "off", }); }); it("persists /reasoning off on discord even when model defaults reasoning on", async () => { diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts index 7581e388667..5ad163dac5d 100644 --- a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts @@ -5,46 +5,19 @@ import { installDirectiveBehaviorE2EHooks, loadModelCatalog, makeWhatsAppDirectiveConfig, - replyText, runEmbeddedPiAgent, sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; +import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js"; import { getReplyFromConfig } from "./reply.js"; -async function runModelDirective( - home: string, - body: string, - options: { - defaults?: Record; - extra?: Record; - } = {}, -): Promise { - const res = await getReplyFromConfig( - { Body: body, From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeWhatsAppDirectiveConfig( - home, - { - model: { primary: "anthropic/claude-opus-4-5" }, - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, - ...options.defaults, - }, - { session: { store: sessionStorePath(home) }, ...options.extra }, - ), - ); - return replyText(res); -} - describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); it("aliases /model list to /models", async () => { await withTempHome(async (home) => { - const text = await runModelDirective(home, "/model list"); + const text = await runModelDirectiveText(home, "/model list"); expect(text).toContain("Providers:"); expect(text).toContain("- anthropic"); expect(text).toContain("- openai"); @@ -56,7 +29,7 @@ describe("directive behavior", () => { it("shows current model when catalog is unavailable", async () => { await withTempHome(async (home) => { vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); - const text = await runModelDirective(home, "/model"); + const text = await runModelDirectiveText(home, "/model"); expect(text).toContain("Current: anthropic/claude-opus-4-5"); expect(text).toContain("Switch: /model "); expect(text).toContain("Browse: /models (providers) or /models (models)"); @@ -71,7 +44,7 @@ describe("directive behavior", () => { { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "grok-4", name: "Grok 4", provider: "xai" }, ]); - const text = await runModelDirective(home, "/model list", { + const text = await runModelDirectiveText(home, "/model list", { defaults: { model: { primary: "anthropic/claude-opus-4-5", @@ -101,7 +74,7 @@ describe("directive behavior", () => { }, { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, ]); - const text = await runModelDirective(home, "/models minimax", { + const text = await runModelDirectiveText(home, "/models minimax", { defaults: { models: { "anthropic/claude-opus-4-5": {}, @@ -129,7 +102,7 @@ describe("directive behavior", () => { }); it("does not repeat missing auth labels on /model list", async () => { await withTempHome(async (home) => { - const text = await runModelDirective(home, "/model list", { + const text = await runModelDirectiveText(home, "/model list", { defaults: { models: { "anthropic/claude-opus-4-5": {}, diff --git a/src/auto-reply/reply.directive.directive-behavior.model-directive-test-utils.ts b/src/auto-reply/reply.directive.directive-behavior.model-directive-test-utils.ts new file mode 100644 index 00000000000..dc9c175d4e1 --- /dev/null +++ b/src/auto-reply/reply.directive.directive-behavior.model-directive-test-utils.ts @@ -0,0 +1,39 @@ +import { + makeWhatsAppDirectiveConfig, + replyText, + sessionStorePath, +} from "./reply.directive.directive-behavior.e2e-harness.js"; +import { getReplyFromConfig } from "./reply.js"; + +export async function runModelDirectiveText( + home: string, + body: string, + options: { + defaults?: Record; + extra?: Record; + includeSessionStore?: boolean; + } = {}, +): Promise { + const res = await getReplyFromConfig( + { Body: body, From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeWhatsAppDirectiveConfig( + home, + { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + ...options.defaults, + }, + { + ...(options.includeSessionStore === false + ? {} + : { session: { store: sessionStorePath(home) } }), + ...options.extra, + }, + ), + ); + return replyText(res); +} diff --git a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts index b150ff8b8f2..9081566adea 100644 --- a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts @@ -5,12 +5,12 @@ import { installDirectiveBehaviorE2EHooks, makeEmbeddedTextResult, makeWhatsAppDirectiveConfig, - replyText, replyTexts, runEmbeddedPiAgent, sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; +import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js"; import { getReplyFromConfig } from "./reply.js"; function makeRunConfig(home: string, storePath: string) { @@ -69,24 +69,6 @@ async function runInFlightVerboseToggleCase(params: { return { res }; } -async function runModelDirectiveAndGetText( - home: string, - body: string, -): Promise { - const res = await getReplyFromConfig( - { Body: body, From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeWhatsAppDirectiveConfig(home, { - model: { primary: "anthropic/claude-opus-4-5" }, - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, - }), - ); - return replyText(res); -} - describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); @@ -119,7 +101,7 @@ describe("directive behavior", () => { }); it("shows summary on /model", async () => { await withTempHome(async (home) => { - const text = await runModelDirectiveAndGetText(home, "/model"); + const text = await runModelDirectiveText(home, "/model", { includeSessionStore: false }); expect(text).toContain("Current: anthropic/claude-opus-4-5"); expect(text).toContain("Switch: /model "); expect(text).toContain("Browse: /models (providers) or /models (models)"); @@ -130,7 +112,9 @@ describe("directive behavior", () => { }); it("lists allowlisted models on /model status", async () => { await withTempHome(async (home) => { - const text = await runModelDirectiveAndGetText(home, "/model status"); + const text = await runModelDirectiveText(home, "/model status", { + includeSessionStore: false, + }); expect(text).toContain("anthropic/claude-opus-4-5"); expect(text).toContain("openai/gpt-4.1-mini"); expect(text).not.toContain("claude-sonnet-4-1"); diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts index e8e2d1b27fc..1b4866aad34 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts @@ -1,21 +1,19 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; -import { beforeAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { getRunEmbeddedPiAgentMock, - installTriggerHandlingE2eTestHooks, + installTriggerHandlingReplyHarness, makeCfg, runGreetingPromptForBareNewOrReset, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; -beforeAll(async () => { - ({ getReplyFromConfig } = await import("./reply.js")); +installTriggerHandlingReplyHarness((loader) => { + getReplyFromConfig = loader; }); -installTriggerHandlingE2eTestHooks(); - async function expectResetBlockedForNonOwner(params: { home: string; commandAuthorized: boolean; @@ -68,6 +66,7 @@ describe("trigger handling", () => { expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); }); }); + it("injects group activation context into the system prompt", async () => { await withTempHome(async (home) => { getRunEmbeddedPiAgentMock().mockResolvedValue({ @@ -112,16 +111,19 @@ describe("trigger handling", () => { expect(extra).toContain("Activation: always-on"); }); }); + it("runs a greeting prompt for a bare /reset", async () => { await withTempHome(async (home) => { await runGreetingPromptForBareNewOrReset({ home, body: "/reset", getReplyFromConfig }); }); }); + it("runs a greeting prompt for a bare /new", async () => { await withTempHome(async (home) => { await runGreetingPromptForBareNewOrReset({ home, body: "/new", getReplyFromConfig }); }); }); + it("does not reset for unauthorized /reset", async () => { await withTempHome(async (home) => { await expectResetBlockedForNonOwner({ @@ -131,6 +133,7 @@ describe("trigger handling", () => { }); }); }); + it("blocks /reset for non-owner senders", async () => { await withTempHome(async (home) => { await expectResetBlockedForNonOwner({ diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 93600471690..a0d538a501b 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -222,6 +222,17 @@ export async function loadGetReplyFromConfig() { return (await import("./reply.js")).getReplyFromConfig; } +export function installTriggerHandlingReplyHarness( + setGetReplyFromConfig: ( + getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig, + ) => void, +): void { + beforeAll(async () => { + setGetReplyFromConfig(await loadGetReplyFromConfig()); + }); + installTriggerHandlingE2eTestHooks(); +} + export function requireSessionStorePath(cfg: { session?: { store?: string } }): string { const storePath = cfg.session?.store; if (!storePath) { diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 16da3ea8bc6..0aae3307fcc 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -144,6 +144,17 @@ describe("chrome extension relay server", () => { envSnapshot.restore(); }); + async function startRelayWithExtension() { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), + }); + await waitForOpen(ext); + return { port, ext }; + } + it("advertises CDP WS only when extension is connected", async () => { const port = await getFreePort(); cdpUrl = `http://127.0.0.1:${port}`; @@ -269,14 +280,7 @@ describe("chrome extension relay server", () => { it( "tracks attached page targets and exposes them via CDP + /json/list", async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext); + const { port, ext } = await startRelayWithExtension(); // Simulate a tab attach coming from the extension. ext.send( @@ -391,14 +395,7 @@ describe("chrome extension relay server", () => { ); it("rebroadcasts attach when a session id is reused for a new target", async () => { - const port = await getFreePort(); - cdpUrl = `http://127.0.0.1:${port}`; - await ensureChromeExtensionRelayServer({ cdpUrl }); - - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { - headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), - }); - await waitForOpen(ext); + const { port, ext } = await startRelayWithExtension(); const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts index f7fdf31ba6b..ebf26124688 100644 --- a/src/browser/server-context.remote-tab-ops.test.ts +++ b/src/browser/server-context.remote-tab-ops.test.ts @@ -74,6 +74,27 @@ function createSequentialPageLister(responses: T[]) { }); } +type JsonListEntry = { + id: string; + title: string; + url: string; + webSocketDebuggerUrl: string; + type: "page"; +}; + +function createJsonListFetchMock(entries: JsonListEntry[]) { + return vi.fn(async (url: unknown) => { + const u = String(url); + if (!u.includes("/json/list")) { + throw new Error(`unexpected fetch: ${u}`); + } + return { + ok: true, + json: async () => entries, + } as unknown as Response; + }); +} + describe("browser server-context remote profile tab operations", () => { it("uses Playwright tab operations when available", async () => { const listPagesViaPlaywright = vi.fn(async () => [ @@ -238,24 +259,15 @@ describe("browser server-context remote profile tab operations", () => { it("falls back to /json/list when Playwright is not available", async () => { vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null); - const fetchMock = vi.fn(async (url: unknown) => { - const u = String(url); - if (!u.includes("/json/list")) { - throw new Error(`unexpected fetch: ${u}`); - } - return { - ok: true, - json: async () => [ - { - id: "T1", - title: "Tab 1", - url: "https://example.com", - webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1", - type: "page", - }, - ], - } as unknown as Response; - }); + const fetchMock = createJsonListFetchMock([ + { + id: "T1", + title: "Tab 1", + url: "https://example.com", + webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1", + type: "page", + }, + ]); const { remote } = createRemoteRouteHarness(fetchMock); @@ -271,24 +283,15 @@ describe("browser server-context tab selection state", () => { .spyOn(cdpModule, "createTargetViaCdp") .mockResolvedValue({ targetId: "CREATED" }); - const fetchMock = vi.fn(async (url: unknown) => { - const u = String(url); - if (!u.includes("/json/list")) { - throw new Error(`unexpected fetch: ${u}`); - } - return { - ok: true, - json: async () => [ - { - id: "CREATED", - title: "New Tab", - url: "http://127.0.0.1:8080", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED", - type: "page", - }, - ], - } as unknown as Response; - }); + const fetchMock = createJsonListFetchMock([ + { + id: "CREATED", + title: "New Tab", + url: "http://127.0.0.1:8080", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED", + type: "page", + }, + ]); global.fetch = withFetchPreconnect(fetchMock); diff --git a/src/config/channel-capabilities.test.ts b/src/config/channel-capabilities.test.ts index cc97a88f3f5..423cc3e2f74 100644 --- a/src/config/channel-capabilities.test.ts +++ b/src/config/channel-capabilities.test.ts @@ -148,18 +148,4 @@ const baseRegistry = createTestRegistry([ { pluginId: "slack", source: "test", plugin: createStubPlugin("slack") }, ]); -const createMSTeamsPlugin = (): ChannelPlugin => ({ - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - blurb: "Bot Framework; enterprise support.", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, -}); +const createMSTeamsPlugin = (): ChannelPlugin => createStubPlugin("msteams"); diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index e1b9addaed6..286005b0aa2 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -536,6 +536,22 @@ const FINAL_BACKLOG_TARGET_KEYS = [ ] as const; describe("config help copy quality", () => { + function expectOperationalGuidance( + keys: readonly string[], + guidancePattern: RegExp, + minLength = 80, + ) { + for (const key of keys) { + const help = FIELD_HELP[key]; + expect(help, `missing help for ${key}`).toBeDefined(); + expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(minLength); + expect( + guidancePattern.test(help), + `help should include operational guidance for ${key}`, + ).toBe(true); + } + } + it("keeps root section labels and help complete", () => { for (const key of ROOT_SECTIONS) { expect(FIELD_LABELS[key], `missing root label for ${key}`).toBeDefined(); @@ -550,57 +566,31 @@ describe("config help copy quality", () => { }); it("covers the target confusing fields with non-trivial explanations", () => { - for (const key of TARGET_KEYS) { - const help = FIELD_HELP[key]; - expect(help, `missing help for ${key}`).toBeDefined(); - expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80); - expect( - /(default|keep|use|enable|disable|controls|selects|sets|defines)/i.test(help), - `help should include operational guidance for ${key}`, - ).toBe(true); - } + expectOperationalGuidance( + TARGET_KEYS, + /(default|keep|use|enable|disable|controls|selects|sets|defines)/i, + ); }); it("covers tools/hooks help keys with non-trivial operational guidance", () => { - for (const key of TOOLS_HOOKS_TARGET_KEYS) { - const help = FIELD_HELP[key]; - expect(help, `missing help for ${key}`).toBeDefined(); - expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80); - expect( - /(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test( - help, - ), - `help should include operational guidance for ${key}`, - ).toBe(true); - } + expectOperationalGuidance( + TOOLS_HOOKS_TARGET_KEYS, + /(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i, + ); }); it("covers channels/agents help keys with non-trivial operational guidance", () => { - for (const key of CHANNELS_AGENTS_TARGET_KEYS) { - const help = FIELD_HELP[key]; - expect(help, `missing help for ${key}`).toBeDefined(); - expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80); - expect( - /(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test( - help, - ), - `help should include operational guidance for ${key}`, - ).toBe(true); - } + expectOperationalGuidance( + CHANNELS_AGENTS_TARGET_KEYS, + /(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i, + ); }); it("covers final backlog help keys with non-trivial operational guidance", () => { - for (const key of FINAL_BACKLOG_TARGET_KEYS) { - const help = FIELD_HELP[key]; - expect(help, `missing help for ${key}`).toBeDefined(); - expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80); - expect( - /(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test( - help, - ), - `help should include operational guidance for ${key}`, - ).toBe(true); - } + expectOperationalGuidance( + FINAL_BACKLOG_TARGET_KEYS, + /(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i, + ); }); it("documents option behavior for enum-style fields", () => { diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index 2efd200441c..bcf0f07b59e 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -1,28 +1,21 @@ import crypto from "node:crypto"; 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 { createFixtureSuite } from "../../test-utils/fixture-suite.js"; import { capEntryCount, pruneStaleEntries, rotateSessionFile } from "./store.js"; import type { SessionEntry } from "./types.js"; const DAY_MS = 24 * 60 * 60 * 1000; -let fixtureRoot = ""; -let fixtureCount = 0; - -async function createCaseDir(prefix: string): Promise { - const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); - await fs.mkdir(dir, { recursive: true }); - return dir; -} +const fixtureSuite = createFixtureSuite("openclaw-pruning-suite-"); beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pruning-suite-")); + await fixtureSuite.setup(); }); afterAll(async () => { - await fs.rm(fixtureRoot, { recursive: true, force: true }); + await fixtureSuite.cleanup(); }); function makeEntry(updatedAt: number): SessionEntry { @@ -82,7 +75,7 @@ describe("rotateSessionFile", () => { let storePath: string; beforeEach(async () => { - testDir = await createCaseDir("rotate"); + testDir = await fixtureSuite.createCaseDir("rotate"); storePath = path.join(testDir, "sessions.json"); }); diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 0a3a151e5a6..de5a0b352c0 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -12,6 +12,63 @@ import { } from "./isolated-agent.test-harness.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; +async function createTelegramDeliveryFixture(home: string): Promise<{ + storePath: string; + deps: CliDeps; +}> { + const storePath = await writeSessionStore(home, { + lastProvider: "telegram", + lastChannel: "telegram", + lastTo: "123", + }); + const deps: CliDeps = { + sendMessageSlack: vi.fn(), + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + return { storePath, deps }; +} + +function mockEmbeddedAgentPayloads(payloads: Array<{ text: string; mediaUrl?: string }>) { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads, + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); +} + +async function runTelegramAnnounceTurn(params: { + home: string; + storePath: string; + deps: CliDeps; + cfg?: ReturnType; + signal?: AbortSignal; +}) { + return runCronIsolatedAgentTurn({ + cfg: params.cfg ?? makeCfg(params.home, params.storePath), + deps: params.deps, + job: { + ...makeJob({ + kind: "agentTurn", + message: "do it", + }), + delivery: { mode: "announce", channel: "telegram", to: "123" }, + }, + message: "do it", + sessionKey: "cron:job-1", + signal: params.signal, + lane: "cron", + }); +} + describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { setupIsolatedAgentTurnMocks({ fast: true }); @@ -19,45 +76,17 @@ describe("runCronIsolatedAgentTurn", () => { it("handles media heartbeat delivery and announce cleanup modes", async () => { await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { - lastProvider: "telegram", - lastChannel: "telegram", - lastTo: "123", - }); - const deps: CliDeps = { - sendMessageSlack: vi.fn(), - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn().mockResolvedValue({ - messageId: "t1", - chatId: "123", - }), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; + const { storePath, deps } = await createTelegramDeliveryFixture(home); // Media should still be delivered even if text is just HEARTBEAT_OK. - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockEmbeddedAgentPayloads([ + { text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }, + ]); - const mediaRes = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath), + const mediaRes = await runTelegramAnnounceTurn({ + home, + storePath, deps, - job: { - ...makeJob({ - kind: "agentTurn", - message: "do it", - }), - delivery: { mode: "announce", channel: "telegram", to: "123" }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", }); expect(mediaRes.status).toBe("ok"); @@ -66,13 +95,7 @@ describe("runCronIsolatedAgentTurn", () => { vi.mocked(runSubagentAnnounceFlow).mockClear(); vi.mocked(deps.sendMessageTelegram).mockClear(); - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "HEARTBEAT_OK 🦞" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]); const cfg = makeCfg(home, storePath); cfg.agents = { @@ -136,47 +159,19 @@ describe("runCronIsolatedAgentTurn", () => { it("skips structured outbound delivery when timeout abort is already set", async () => { await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { - lastProvider: "telegram", - lastChannel: "telegram", - lastTo: "123", - }); - const deps: CliDeps = { - sendMessageSlack: vi.fn(), - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn().mockResolvedValue({ - messageId: "t1", - chatId: "123", - }), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; + const { storePath, deps } = await createTelegramDeliveryFixture(home); const controller = new AbortController(); controller.abort("cron: job execution timed out"); - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + mockEmbeddedAgentPayloads([ + { text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }, + ]); - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath), + const res = await runTelegramAnnounceTurn({ + home, + storePath, deps, - job: { - ...makeJob({ - kind: "agentTurn", - message: "do it", - }), - delivery: { mode: "announce", channel: "telegram", to: "123" }, - }, - message: "do it", - sessionKey: "cron:job-1", signal: controller.signal, - lane: "cron", }); expect(res.status).toBe("error"); diff --git a/src/cron/isolated-agent.delivery.test-helpers.ts b/src/cron/isolated-agent.delivery.test-helpers.ts index be3bf03136c..72773754997 100644 --- a/src/cron/isolated-agent.delivery.test-helpers.ts +++ b/src/cron/isolated-agent.delivery.test-helpers.ts @@ -1,6 +1,8 @@ import { vi } from "vitest"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { CliDeps } from "../cli/deps.js"; +import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { makeCfg, makeJob } from "./isolated-agent.test-harness.js"; export function createCliDeps(overrides: Partial = {}): CliDeps { return { @@ -27,3 +29,29 @@ export function mockAgentPayloads( ...extra, }); } + +export async function runTelegramAnnounceTurn(params: { + home: string; + storePath: string; + deps: CliDeps; + delivery: { + mode: "announce"; + channel: string; + to?: string; + bestEffort?: boolean; + }; +}): Promise>> { + return runCronIsolatedAgentTurn({ + cfg: makeCfg(params.home, params.storePath, { + channels: { telegram: { botToken: "t-1" } }, + }), + deps: params.deps, + job: { + ...makeJob({ kind: "agentTurn", message: "do it" }), + delivery: params.delivery, + }, + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); +} diff --git a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts index a96ffdeb754..176a8cd5a66 100644 --- a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts +++ b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts @@ -1,14 +1,12 @@ import "./isolated-agent.mocks.js"; import { beforeEach, describe, expect, it } from "vitest"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; -import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js"; -import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { - makeCfg, - makeJob, - withTempCronHome, - writeSessionStore, -} from "./isolated-agent.test-harness.js"; + createCliDeps, + mockAgentPayloads, + runTelegramAnnounceTurn, +} from "./isolated-agent.delivery.test-helpers.js"; +import { withTempCronHome, writeSessionStore } from "./isolated-agent.test-harness.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; describe("runCronIsolatedAgentTurn forum topic delivery", () => { @@ -22,18 +20,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => { const deps = createCliDeps(); mockAgentPayloads([{ text: "forum message" }]); - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), + const res = await runTelegramAnnounceTurn({ + home, + storePath, deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { mode: "announce", channel: "telegram", to: "123:topic:42" }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + delivery: { mode: "announce", channel: "telegram", to: "123:topic:42" }, }); expect(res.status).toBe("ok"); @@ -56,18 +47,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => { const deps = createCliDeps(); mockAgentPayloads([{ text: "plain message" }]); - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), + const res = await runTelegramAnnounceTurn({ + home, + storePath, deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { mode: "announce", channel: "telegram", to: "123" }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + delivery: { mode: "announce", channel: "telegram", to: "123" }, }); expect(res.status).toBe("ok"); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 3e0a30d34f4..eaff473fdee 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -3,7 +3,11 @@ import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { CliDeps } from "../cli/deps.js"; -import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js"; +import { + createCliDeps, + mockAgentPayloads, + runTelegramAnnounceTurn, +} from "./isolated-agent.delivery.test-helpers.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, @@ -13,32 +17,22 @@ import { } from "./isolated-agent.test-harness.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; -async function runTelegramAnnounceTurn(params: { +async function runExplicitTelegramAnnounceTurn(params: { home: string; storePath: string; deps: CliDeps; - delivery: { - mode: "announce"; - channel: string; - to?: string; - bestEffort?: boolean; - }; }): Promise>> { - return runCronIsolatedAgentTurn({ - cfg: makeCfg(params.home, params.storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), - deps: params.deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: params.delivery, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + return runTelegramAnnounceTurn({ + ...params, + delivery: { mode: "announce", channel: "telegram", to: "123" }, }); } +function expectDeliveredOk(result: Awaited>): void { + expect(result.status).toBe("ok"); + expect(result.delivered).toBe(true); +} + async function expectBestEffortTelegramNotDelivered( payload: Record, ): Promise { @@ -75,15 +69,13 @@ async function expectExplicitTelegramTargetAnnounce(params: { const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps = createCliDeps(); mockAgentPayloads(params.payloads); - const res = await runTelegramAnnounceTurn({ + const res = await runExplicitTelegramAnnounceTurn({ home, storePath, deps, - delivery: { mode: "announce", channel: "telegram", to: "123" }, }); - expect(res.status).toBe("ok"); - expect(res.delivered).toBe(true); + expectDeliveredOk(res); expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as | { @@ -210,15 +202,13 @@ describe("runCronIsolatedAgentTurn", () => { messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }], }); - const res = await runTelegramAnnounceTurn({ + const res = await runExplicitTelegramAnnounceTurn({ home, storePath, deps, - delivery: { mode: "announce", channel: "telegram", to: "123" }, }); - expect(res.status).toBe("ok"); - expect(res.delivered).toBe(true); + expectDeliveredOk(res); expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); }); diff --git a/src/cron/isolated-agent.test-harness.ts b/src/cron/isolated-agent.test-harness.ts index c5c0ccc39b5..d6d15c31ed7 100644 --- a/src/cron/isolated-agent.test-harness.ts +++ b/src/cron/isolated-agent.test-harness.ts @@ -11,25 +11,24 @@ export async function withTempCronHome(fn: (home: string) => Promise): Pro export async function writeSessionStore( home: string, session: { lastProvider: string; lastTo: string; lastChannel?: string }, +): Promise { + return writeSessionStoreEntries(home, { + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now(), + ...session, + }, + }); +} + +export async function writeSessionStoreEntries( + home: string, + entries: Record>, ): Promise { const dir = path.join(home, ".openclaw", "sessions"); await fs.mkdir(dir, { recursive: true }); const storePath = path.join(dir, "sessions.json"); - await fs.writeFile( - storePath, - JSON.stringify( - { - "agent:main:main": { - sessionId: "main-session", - updatedAt: Date.now(), - ...session, - }, - }, - null, - 2, - ), - "utf-8", - ); + await fs.writeFile(storePath, JSON.stringify(entries, null, 2), "utf-8"); return storePath; } diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 7842d55b5c4..abb27177a54 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -6,7 +6,13 @@ import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { CliDeps } from "../cli/deps.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; -import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js"; +import { + makeCfg, + makeJob, + withTempCronHome, + writeSessionStore, + writeSessionStoreEntries, +} from "./isolated-agent.test-harness.js"; import type { CronJob } from "./types.js"; const withTempHome = withTempCronHome; @@ -44,33 +50,6 @@ function expectEmbeddedProviderModel(expected: { provider: string; model: string expect(call?.model).toBe(expected.model); } -async function writeSessionStore( - home: string, - entries: Record> = {}, -) { - const dir = path.join(home, ".openclaw", "sessions"); - await fs.mkdir(dir, { recursive: true }); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile( - storePath, - JSON.stringify( - { - "agent:main:main": { - sessionId: "main-session", - updatedAt: Date.now(), - lastProvider: "webchat", - lastTo: "", - }, - ...entries, - }, - null, - 2, - ), - "utf-8", - ); - return storePath; -} - async function readSessionEntry(storePath: string, key: string) { const raw = await fs.readFile(storePath, "utf-8"); const store = JSON.parse(raw) as Record; @@ -98,7 +77,17 @@ type RunCronTurnOptions = { }; async function runCronTurn(home: string, options: RunCronTurnOptions = {}) { - const storePath = options.storePath ?? (await writeSessionStore(home, options.storeEntries)); + const storePath = + options.storePath ?? + (await writeSessionStoreEntries(home, { + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now(), + lastProvider: "webchat", + lastTo: "", + }, + ...options.storeEntries, + })); const deps = options.deps ?? makeDeps(); if (options.mockTexts === null) { vi.mocked(runEmbeddedPiAgent).mockClear(); @@ -468,7 +457,7 @@ describe("runCronIsolatedAgentTurn", () => { it("starts a fresh session id for each cron run", async () => { await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps = makeDeps(); const first = ( @@ -502,7 +491,7 @@ describe("runCronIsolatedAgentTurn", () => { it("preserves an existing cron session label", async () => { await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const raw = await fs.readFile(storePath, "utf-8"); const store = JSON.parse(raw) as Record>; store["agent:main:cron:job-1"] = { diff --git a/src/cron/service.delivery-plan.test.ts b/src/cron/service.delivery-plan.test.ts index 0337c063461..55614ced525 100644 --- a/src/cron/service.delivery-plan.test.ts +++ b/src/cron/service.delivery-plan.test.ts @@ -1,26 +1,14 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { ChannelId } from "../channels/plugins/types.js"; import { CronService, type CronServiceDeps } from "./service.js"; +import { + createCronStoreHarness, + createNoopLogger, + withCronServiceForTest, +} from "./service.test-harness.js"; -const noopLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; - -async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-delivery-")); - return { - storePath: path.join(dir, "cron", "jobs.json"), - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; -} +const noopLogger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-delivery-" }); type DeliveryMode = "none" | "announce"; @@ -40,27 +28,15 @@ async function withCronService( requestHeartbeatNow: ReturnType; }) => Promise, ) { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const cron = new CronService({ - cronEnabled: true, - storePath: store.storePath, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: - params.runIsolatedAgentJob ?? - (vi.fn(async () => ({ status: "ok" as const, summary: "done" })) as never), - }); - - await cron.start(); - try { - await run({ cron, enqueueSystemEvent, requestHeartbeatNow }); - } finally { - cron.stop(); - await store.cleanup(); - } + await withCronServiceForTest( + { + makeStorePath, + logger: noopLogger, + cronEnabled: true, + runIsolatedAgentJob: params.runIsolatedAgentJob, + }, + run, + ); } async function addIsolatedAgentTurnJob( diff --git a/src/cron/service.issue-13992-regression.test.ts b/src/cron/service.issue-13992-regression.test.ts index c3891207540..58db3962f65 100644 --- a/src/cron/service.issue-13992-regression.test.ts +++ b/src/cron/service.issue-13992-regression.test.ts @@ -3,25 +3,35 @@ import { createMockCronStateForJobs } from "./service.test-harness.js"; import { recomputeNextRunsForMaintenance } from "./service/jobs.js"; import type { CronJob } from "./types.js"; +function createCronSystemEventJob(now: number, overrides: Partial = {}): CronJob { + const { state, ...jobOverrides } = overrides; + return { + id: "test-job", + name: "test job", + enabled: true, + schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, + payload: { kind: "systemEvent", text: "test" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + createdAtMs: now, + updatedAtMs: now, + ...jobOverrides, + state: state ? { ...state } : {}, + }; +} + describe("issue #13992 regression - cron jobs skip execution", () => { it("should NOT recompute nextRunAtMs for past-due jobs during maintenance", () => { const now = Date.now(); const pastDue = now - 60_000; // 1 minute ago - const job: CronJob = { - id: "test-job", - name: "test job", - enabled: true, - schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, - payload: { kind: "systemEvent", text: "test" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", + const job = createCronSystemEventJob(now, { createdAtMs: now - 3600_000, updatedAtMs: now - 3600_000, state: { nextRunAtMs: pastDue, // This is in the past and should NOT be recomputed }, - }; + }); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state); @@ -33,20 +43,11 @@ describe("issue #13992 regression - cron jobs skip execution", () => { it("should compute missing nextRunAtMs during maintenance", () => { const now = Date.now(); - const job: CronJob = { - id: "test-job", - name: "test job", - enabled: true, - schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, - payload: { kind: "systemEvent", text: "test" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - createdAtMs: now, - updatedAtMs: now, + const job = createCronSystemEventJob(now, { state: { // nextRunAtMs is missing }, - }; + }); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state); @@ -60,20 +61,12 @@ describe("issue #13992 regression - cron jobs skip execution", () => { const now = Date.now(); const futureTime = now + 3600_000; - const job: CronJob = { - id: "test-job", - name: "test job", + const job = createCronSystemEventJob(now, { enabled: false, // Disabled - schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, - payload: { kind: "systemEvent", text: "test" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - createdAtMs: now, - updatedAtMs: now, state: { nextRunAtMs: futureTime, }, - }; + }); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state); @@ -87,21 +80,12 @@ describe("issue #13992 regression - cron jobs skip execution", () => { const stuckTime = now - 3 * 60 * 60_000; // 3 hours ago (> 2 hour threshold) const futureTime = now + 3600_000; - const job: CronJob = { - id: "test-job", - name: "test job", - enabled: true, - schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, - payload: { kind: "systemEvent", text: "test" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", - createdAtMs: now, - updatedAtMs: now, + const job = createCronSystemEventJob(now, { state: { nextRunAtMs: futureTime, runningAtMs: stuckTime, // Stuck running marker }, - }; + }); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state); diff --git a/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts b/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts index 7ebbd1c9632..4818677b135 100644 --- a/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts +++ b/src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; -import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js"; +import { + createCronStoreHarness, + createNoopLogger, + withCronServiceForTest, +} from "./service.test-harness.js"; import type { CronJob } from "./types.js"; const noopLogger = createNoopLogger(); @@ -30,25 +34,15 @@ async function withCronService( requestHeartbeatNow: ReturnType; }) => Promise, ) { - const store = await makeStorePath(); - const enqueueSystemEvent = vi.fn(); - const requestHeartbeatNow = vi.fn(); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled, - log: noopLogger, - enqueueSystemEvent, - requestHeartbeatNow, - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), - }); - - await cron.start(); - try { - await run({ cron, enqueueSystemEvent, requestHeartbeatNow }); - } finally { - cron.stop(); - await store.cleanup(); - } + await withCronServiceForTest( + { + makeStorePath, + logger: noopLogger, + cronEnabled, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }, + run, + ); } describe("CronService", () => { diff --git a/src/cron/service.store.migration.test.ts b/src/cron/service.store.migration.test.ts index fa3ff201034..db7f1d0bcb3 100644 --- a/src/cron/service.store.migration.test.ts +++ b/src/cron/service.store.migration.test.ts @@ -1,28 +1,13 @@ 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 { CronService } from "./service.js"; +import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js"; import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js"; import { loadCronStore } from "./store.js"; -const noopLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; - -async function makeStorePath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-migrate-")); - return { - dir, - storePath: path.join(dir, "cron", "jobs.json"), - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; -} +const noopLogger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-migrate-" }); async function writeLegacyStore(storePath: string, legacyJob: Record) { await fs.mkdir(path.dirname(storePath), { recursive: true }); diff --git a/src/cron/service.test-harness.ts b/src/cron/service.test-harness.ts index 3143000d1ec..fcc62637892 100644 --- a/src/cron/service.test-harness.ts +++ b/src/cron/service.test-harness.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; -import type { CronEvent } from "./service.js"; +import type { CronEvent, CronServiceDeps } from "./service.js"; import { CronService } from "./service.js"; import { createCronServiceState, type CronServiceState } from "./service/state.js"; import type { CronJob } from "./types.js"; @@ -140,6 +140,42 @@ export function createStartedCronServiceWithFinishedBarrier(params: { return { cron, enqueueSystemEvent, requestHeartbeatNow, finished }; } +export async function withCronServiceForTest( + params: { + makeStorePath: () => Promise<{ storePath: string; cleanup: () => Promise }>; + logger: ReturnType; + cronEnabled: boolean; + runIsolatedAgentJob?: CronServiceDeps["runIsolatedAgentJob"]; + }, + run: (context: { + cron: CronService; + enqueueSystemEvent: ReturnType; + requestHeartbeatNow: ReturnType; + }) => Promise, +): Promise { + const store = await params.makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + const cron = new CronService({ + cronEnabled: params.cronEnabled, + storePath: store.storePath, + log: params.logger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: + params.runIsolatedAgentJob ?? + (vi.fn(async () => ({ status: "ok" as const, summary: "done" })) as never), + }); + + await cron.start(); + try { + await run({ cron, enqueueSystemEvent, requestHeartbeatNow }); + } finally { + cron.stop(); + await store.cleanup(); + } +} + export function createRunningCronServiceState(params: { storePath: string; log: ReturnType; diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 5d41c7f4f60..586ce0cdc5b 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../test-utils/env.js"; - -const loadConfig = vi.fn(); -const resolveGatewayPort = vi.fn(); -const pickPrimaryTailnetIPv4 = vi.fn(); -const pickPrimaryLanIPv4 = vi.fn(); +import { + loadConfigMock as loadConfig, + pickPrimaryLanIPv4Mock as pickPrimaryLanIPv4, + pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4, + resolveGatewayPortMock as resolveGatewayPort, +} from "./gateway-connection.test-mocks.js"; let lastClientOptions: { url?: string; @@ -19,27 +20,6 @@ let startMode: StartMode = "hello"; let closeCode = 1006; let closeReason = ""; -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig, - resolveGatewayPort, - }; -}); - -vi.mock("../infra/tailnet.js", () => ({ - pickPrimaryTailnetIPv4, -})); - -vi.mock("./net.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - pickPrimaryLanIPv4, - }; -}); - vi.mock("./client.js", () => ({ describeGatewayCloseCode: (code: number) => { if (code === 1000) { diff --git a/src/gateway/gateway-connection.test-mocks.ts b/src/gateway/gateway-connection.test-mocks.ts new file mode 100644 index 00000000000..20b0009f075 --- /dev/null +++ b/src/gateway/gateway-connection.test-mocks.ts @@ -0,0 +1,27 @@ +import { vi } from "vitest"; + +export const loadConfigMock = vi.fn(); +export const resolveGatewayPortMock = vi.fn(); +export const pickPrimaryTailnetIPv4Mock = vi.fn(); +export const pickPrimaryLanIPv4Mock = vi.fn(); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: loadConfigMock, + resolveGatewayPort: resolveGatewayPortMock, + }; +}); + +vi.mock("../infra/tailnet.js", () => ({ + pickPrimaryTailnetIPv4: pickPrimaryTailnetIPv4Mock, +})); + +vi.mock("./net.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + pickPrimaryLanIPv4: pickPrimaryLanIPv4Mock, + }; +}); diff --git a/src/gateway/server-http.hooks-request-timeout.test.ts b/src/gateway/server-http.hooks-request-timeout.test.ts index 87d5ecf011a..448707eb1c7 100644 --- a/src/gateway/server-http.hooks-request-timeout.test.ts +++ b/src/gateway/server-http.hooks-request-timeout.test.ts @@ -17,6 +17,8 @@ vi.mock("./hooks.js", async (importOriginal) => { import { createHooksRequestHandler } from "./server-http.js"; +type HooksHandlerDeps = Parameters[0]; + function createHooksConfig(): HooksConfigResolved { return { basePath: "/hooks", @@ -66,6 +68,30 @@ function createResponse(): { return { res, end, setHeader }; } +function createHandler(params?: { + dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"]; + dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"]; +}) { + return createHooksRequestHandler({ + getHooksConfig: () => createHooksConfig(), + bindHost: "127.0.0.1", + port: 18789, + logHooks: { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType, + dispatchWakeHook: + params?.dispatchWakeHook ?? + ((() => { + return; + }) as HooksHandlerDeps["dispatchWakeHook"]), + dispatchAgentHook: + params?.dispatchAgentHook ?? ((() => "run-1") as HooksHandlerDeps["dispatchAgentHook"]), + }); +} + describe("createHooksRequestHandler timeout status mapping", () => { beforeEach(() => { readJsonBodyMock.mockClear(); @@ -75,19 +101,7 @@ describe("createHooksRequestHandler timeout status mapping", () => { readJsonBodyMock.mockResolvedValue({ ok: false, error: "request body timeout" }); const dispatchWakeHook = vi.fn(); const dispatchAgentHook = vi.fn(() => "run-1"); - const handler = createHooksRequestHandler({ - getHooksConfig: () => createHooksConfig(), - bindHost: "127.0.0.1", - port: 18789, - logHooks: { - warn: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - error: vi.fn(), - } as unknown as ReturnType, - dispatchWakeHook, - dispatchAgentHook, - }); + const handler = createHandler({ dispatchWakeHook, dispatchAgentHook }); const req = createRequest(); const { res, end } = createResponse(); @@ -101,19 +115,7 @@ describe("createHooksRequestHandler timeout status mapping", () => { }); test("shares hook auth rate-limit bucket across ipv4 and ipv4-mapped ipv6 forms", async () => { - const handler = createHooksRequestHandler({ - getHooksConfig: () => createHooksConfig(), - bindHost: "127.0.0.1", - port: 18789, - logHooks: { - warn: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - error: vi.fn(), - } as unknown as ReturnType, - dispatchWakeHook: vi.fn(), - dispatchAgentHook: vi.fn(() => "run-1"), - }); + const handler = createHandler(); for (let i = 0; i < 20; i++) { const req = createRequest({ diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 922a059a3a7..cf9bfd95d25 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -124,6 +124,40 @@ function createChatContext(): Pick< }; } +type ChatContext = ReturnType; + +async function runNonStreamingChatSend(params: { + context: ChatContext; + respond: ReturnType; + idempotencyKey: string; + message?: string; +}) { + await chatHandlers["chat.send"]({ + params: { + sessionKey: "main", + message: params.message ?? "hello", + idempotencyKey: params.idempotencyKey, + }, + respond: params.respond as unknown as Parameters< + (typeof chatHandlers)["chat.send"] + >[0]["respond"], + req: {} as never, + client: null, + isWebchatConnect: () => false, + context: params.context as GatewayRequestContext, + }); + + await vi.waitFor(() => { + expect( + (params.context.broadcast as unknown as ReturnType).mock.calls.length, + ).toBe(1); + }); + + const chatCall = (params.context.broadcast as unknown as ReturnType).mock.calls[0]; + expect(chatCall?.[0]).toBe("chat"); + return chatCall?.[1]; +} + describe("chat directive tag stripping for non-streaming final payloads", () => { it("chat.inject keeps message defined when directive tag is the only content", async () => { createTranscriptFixture("openclaw-chat-inject-directive-only-"); @@ -160,33 +194,20 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const respond = vi.fn(); const context = createChatContext(); - await chatHandlers["chat.send"]({ - params: { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-directive-only", - }, + const payload = await runNonStreamingChatSend({ + context, respond, - req: {} as never, - client: null, - isWebchatConnect: () => false, - context: context as GatewayRequestContext, + idempotencyKey: "idem-directive-only", }); - await vi.waitFor(() => { - expect((context.broadcast as unknown as ReturnType).mock.calls.length).toBe(1); - }); - - const chatCall = (context.broadcast as unknown as ReturnType).mock.calls[0]; - expect(chatCall?.[0]).toBe("chat"); - expect(chatCall?.[1]).toEqual( + expect(payload).toEqual( expect.objectContaining({ runId: "idem-directive-only", state: "final", message: expect.any(Object), }), ); - expect(extractFirstTextBlock(chatCall?.[1])).toBe(""); + expect(extractFirstTextBlock(payload)).toBe(""); }); it("chat.inject strips external untrusted wrapper metadata from final payload text", async () => { @@ -218,25 +239,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => const respond = vi.fn(); const context = createChatContext(); - await chatHandlers["chat.send"]({ - params: { - sessionKey: "main", - message: "hello", - idempotencyKey: "idem-untrusted-context", - }, + const payload = await runNonStreamingChatSend({ + context, respond, - req: {} as never, - client: null, - isWebchatConnect: () => false, - context: context as GatewayRequestContext, + idempotencyKey: "idem-untrusted-context", }); - - await vi.waitFor(() => { - expect((context.broadcast as unknown as ReturnType).mock.calls.length).toBe(1); - }); - - const chatCall = (context.broadcast as unknown as ReturnType).mock.calls[0]; - expect(chatCall?.[0]).toBe("chat"); - expect(extractFirstTextBlock(chatCall?.[1])).toBe("hello"); + expect(extractFirstTextBlock(payload)).toBe("hello"); }); }); diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index a4b487b834f..5eb3b975eba 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -13,24 +13,32 @@ import { installGatewayTestHooks({ scope: "suite" }); -let server: Awaited>["server"]; -let ws: Awaited>["ws"]; +let startedServer: Awaited> | null = null; + +function requireWs(): Awaited>["ws"] { + if (!startedServer) { + throw new Error("gateway test server not started"); + } + return startedServer.ws; +} beforeAll(async () => { - const started = await startServerWithClient(undefined, { controlUiEnabled: true }); - server = started.server; - ws = started.ws; - await connectOk(ws); + startedServer = await startServerWithClient(undefined, { controlUiEnabled: true }); + await connectOk(requireWs()); }); afterAll(async () => { - ws.close(); - await server.close(); + if (!startedServer) { + return; + } + startedServer.ws.close(); + await startedServer.server.close(); + startedServer = null; }); describe("gateway config methods", () => { it("rejects config.patch when raw is not an object", async () => { - const res = await rpcReq<{ ok?: boolean }>(ws, "config.patch", { + const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { raw: "[]", }); expect(res.ok).toBe(false); @@ -78,7 +86,7 @@ describe("gateway server sessions", () => { const homeSessions = await rpcReq<{ sessions: Array<{ key: string }>; - }>(ws, "sessions.list", { + }>(requireWs(), "sessions.list", { includeGlobal: false, includeUnknown: false, agentId: "home", @@ -91,7 +99,7 @@ describe("gateway server sessions", () => { const workSessions = await rpcReq<{ sessions: Array<{ key: string }>; - }>(ws, "sessions.list", { + }>(requireWs(), "sessions.list", { includeGlobal: false, includeUnknown: false, agentId: "work", @@ -119,13 +127,13 @@ describe("gateway server sessions", () => { }, }); - const resolved = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", { + const resolved = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.resolve", { key: "main", }); expect(resolved.ok).toBe(true); expect(resolved.payload?.key).toBe("agent:ops:work"); - const patched = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", { + const patched = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.patch", { key: "main", thinkingLevel: "medium", }); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 94d6afbae5e..10cd9dcefde 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -85,6 +85,28 @@ async function waitForCondition(check: () => boolean, timeoutMs = 2000) { } } +async function cleanupCronTestRun(params: { + ws: { close: () => void }; + server: { close: () => Promise }; + dir: string; + prevSkipCron: string | undefined; + clearSessionConfig?: boolean; +}) { + params.ws.close(); + await params.server.close(); + await rmTempDir(params.dir); + testState.cronStorePath = undefined; + if (params.clearSessionConfig) { + testState.sessionConfig = undefined; + } + testState.cronEnabled = undefined; + if (params.prevSkipCron === undefined) { + delete process.env.OPENCLAW_SKIP_CRON; + return; + } + process.env.OPENCLAW_SKIP_CRON = params.prevSkipCron; +} + describe("gateway server cron", () => { test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => { const prevSkipCron = process.env.OPENCLAW_SKIP_CRON; @@ -352,17 +374,13 @@ describe("gateway server cron", () => { const disabled = disableUpdateRes.payload as { enabled?: unknown } | undefined; expect(disabled?.enabled).toBe(false); } finally { - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - testState.sessionConfig = undefined; - testState.cronEnabled = undefined; - if (prevSkipCron === undefined) { - delete process.env.OPENCLAW_SKIP_CRON; - } else { - process.env.OPENCLAW_SKIP_CRON = prevSkipCron; - } + await cleanupCronTestRun({ + ws, + server, + dir, + prevSkipCron, + clearSessionConfig: true, + }); } }); @@ -466,16 +484,7 @@ describe("gateway server cron", () => { const runs = autoEntries?.entries ?? []; expect(runs.at(-1)?.jobId).toBe(autoJobId); } finally { - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - testState.cronEnabled = undefined; - if (prevSkipCron === undefined) { - delete process.env.OPENCLAW_SKIP_CRON; - } else { - process.env.OPENCLAW_SKIP_CRON = prevSkipCron; - } + await cleanupCronTestRun({ ws, server, dir, prevSkipCron }); } }, 45_000); @@ -650,16 +659,7 @@ describe("gateway server cron", () => { await yieldToEventLoop(); expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2); } finally { - ws.close(); - await server.close(); - await rmTempDir(dir); - testState.cronStorePath = undefined; - testState.cronEnabled = undefined; - if (prevSkipCron === undefined) { - delete process.env.OPENCLAW_SKIP_CRON; - } else { - process.env.OPENCLAW_SKIP_CRON = prevSkipCron; - } + await cleanupCronTestRun({ ws, server, dir, prevSkipCron }); } }, 60_000); }); diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index c21d0814b8e..6902d846157 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -51,6 +51,43 @@ function createForwarder(params: { return { deliver, forwarder }; } +function makeSessionCfg(options: { discordExecApprovalsEnabled?: boolean } = {}): OpenClawConfig { + return { + ...(options.discordExecApprovalsEnabled + ? { + channels: { + discord: { + execApprovals: { + enabled: true, + approvers: ["123"], + }, + }, + }, + } + : {}), + approvals: { exec: { enabled: true, mode: "session" } }, + } as OpenClawConfig; +} + +async function expectDiscordSessionTargetRequest(params: { + cfg: OpenClawConfig; + expectedAccepted: boolean; + expectedDeliveryCount: number; +}) { + vi.useFakeTimers(); + const { deliver, forwarder } = createForwarder({ + cfg: params.cfg, + resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }), + }); + + await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(params.expectedAccepted); + if (params.expectedDeliveryCount === 0) { + expect(deliver).not.toHaveBeenCalled(); + return; + } + expect(deliver).toHaveBeenCalledTimes(params.expectedDeliveryCount); +} + describe("exec approval forwarder", () => { it("forwards to session target and resolves", async () => { vi.useFakeTimers(); @@ -124,66 +161,27 @@ describe("exec approval forwarder", () => { }); it("returns false when all targets are skipped", async () => { - vi.useFakeTimers(); - const cfg = { - channels: { - discord: { - execApprovals: { - enabled: true, - approvers: ["123"], - }, - }, - }, - approvals: { exec: { enabled: true, mode: "session" } }, - } as OpenClawConfig; - - const { deliver, forwarder } = createForwarder({ - cfg, - resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }), + await expectDiscordSessionTargetRequest({ + cfg: makeSessionCfg({ discordExecApprovalsEnabled: true }), + expectedAccepted: false, + expectedDeliveryCount: 0, }); - - await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(false); - expect(deliver).not.toHaveBeenCalled(); }); it("forwards to discord when discord exec approvals handler is disabled", async () => { - vi.useFakeTimers(); - const cfg = { - approvals: { exec: { enabled: true, mode: "session" } }, - } as OpenClawConfig; - - const { deliver, forwarder } = createForwarder({ - cfg, - resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }), + await expectDiscordSessionTargetRequest({ + cfg: makeSessionCfg(), + expectedAccepted: true, + expectedDeliveryCount: 1, }); - - await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true); - - expect(deliver).toHaveBeenCalledTimes(1); }); it("skips discord forwarding when discord exec approvals handler is enabled", async () => { - vi.useFakeTimers(); - const cfg = { - channels: { - discord: { - execApprovals: { - enabled: true, - approvers: ["123"], - }, - }, - }, - approvals: { exec: { enabled: true, mode: "session" } }, - } as OpenClawConfig; - - const { deliver, forwarder } = createForwarder({ - cfg, - resolveSessionTarget: () => ({ channel: "discord", to: "channel:123" }), + await expectDiscordSessionTargetRequest({ + cfg: makeSessionCfg({ discordExecApprovalsEnabled: true }), + expectedAccepted: false, + expectedDeliveryCount: 0, }); - - await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(false); - - expect(deliver).not.toHaveBeenCalled(); }); it("can forward resolved notices without pending cache when request payload is present", async () => { diff --git a/src/infra/gateway-lock.test.ts b/src/infra/gateway-lock.test.ts index 086d37552a5..8fd1804c930 100644 --- a/src/infra/gateway-lock.test.ts +++ b/src/infra/gateway-lock.test.ts @@ -115,6 +115,28 @@ function createEaccesProcStatSpy() { }); } +function createPortProbeConnectionSpy(result: "connect" | "refused") { + return vi.spyOn(net, "createConnection").mockImplementation(() => { + const socket = new EventEmitter() as net.Socket; + socket.destroy = vi.fn(); + setImmediate(() => { + if (result === "connect") { + socket.emit("connect"); + return; + } + socket.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" })); + }); + return socket; + }); +} + +async function writeRecentLockFile(env: NodeJS.ProcessEnv, startTime = 111) { + await writeLockFile(env, { + startTime, + createdAt: new Date().toISOString(), + }); +} + describe("gateway lock", () => { beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-")); @@ -214,18 +236,8 @@ describe("gateway lock", () => { it("treats lock as stale when owner pid is alive but configured port is free", async () => { vi.useRealTimers(); const env = await makeEnv(); - await writeLockFile(env, { - startTime: 111, - createdAt: new Date().toISOString(), - }); - const connectSpy = vi.spyOn(net, "createConnection").mockImplementation(() => { - const socket = new EventEmitter() as net.Socket; - socket.destroy = vi.fn(); - setImmediate(() => { - socket.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" })); - }); - return socket; - }); + await writeRecentLockFile(env); + const connectSpy = createPortProbeConnectionSpy("refused"); const lock = await acquireForTest(env, { timeoutMs: 80, @@ -242,18 +254,8 @@ describe("gateway lock", () => { it("keeps lock when configured port is busy and owner pid is alive", async () => { vi.useRealTimers(); const env = await makeEnv(); - await writeLockFile(env, { - startTime: 111, - createdAt: new Date().toISOString(), - }); - const connectSpy = vi.spyOn(net, "createConnection").mockImplementation(() => { - const socket = new EventEmitter() as net.Socket; - socket.destroy = vi.fn(); - setImmediate(() => { - socket.emit("connect"); - }); - return socket; - }); + await writeRecentLockFile(env); + const connectSpy = createPortProbeConnectionSpy("connect"); try { const pending = acquireForTest(env, { timeoutMs: 20, diff --git a/src/node-host/exec-policy.test.ts b/src/node-host/exec-policy.test.ts index c74ce5ad239..f76a2891840 100644 --- a/src/node-host/exec-policy.test.ts +++ b/src/node-host/exec-policy.test.ts @@ -5,6 +5,40 @@ import { resolveExecApprovalDecision, } from "./exec-policy.js"; +type EvaluatePolicyParams = Parameters[0]; +type EvaluatePolicyDecision = ReturnType; + +const buildPolicyParams = (overrides: Partial): EvaluatePolicyParams => { + return { + security: "allowlist", + ask: "off", + analysisOk: true, + allowlistSatisfied: true, + approvalDecision: null, + approved: false, + isWindows: false, + cmdInvocation: false, + shellWrapperInvocation: false, + ...overrides, + }; +}; + +const expectDeniedDecision = (decision: EvaluatePolicyDecision) => { + expect(decision.allowed).toBe(false); + if (decision.allowed) { + throw new Error("expected denied decision"); + } + return decision; +}; + +const expectAllowedDecision = (decision: EvaluatePolicyDecision) => { + expect(decision.allowed).toBe(true); + if (!decision.allowed) { + throw new Error("expected allowed decision"); + } + return decision; +}; + describe("resolveExecApprovalDecision", () => { it("accepts known approval decisions", () => { expect(resolveExecApprovalDecision("allow-once")).toBe("allow-once"); @@ -42,143 +76,68 @@ describe("formatSystemRunAllowlistMissMessage", () => { describe("evaluateSystemRunPolicy", () => { it("denies when security mode is deny", () => { - const decision = evaluateSystemRunPolicy({ - security: "deny", - ask: "off", - analysisOk: true, - allowlistSatisfied: true, - approvalDecision: null, - approved: false, - isWindows: false, - cmdInvocation: false, - shellWrapperInvocation: false, - }); - expect(decision.allowed).toBe(false); - if (decision.allowed) { - throw new Error("expected denied decision"); - } - expect(decision.eventReason).toBe("security=deny"); - expect(decision.errorMessage).toBe("SYSTEM_RUN_DISABLED: security=deny"); + const denied = expectDeniedDecision( + evaluateSystemRunPolicy(buildPolicyParams({ security: "deny" })), + ); + expect(denied.eventReason).toBe("security=deny"); + expect(denied.errorMessage).toBe("SYSTEM_RUN_DISABLED: security=deny"); }); it("requires approval when ask policy requires it", () => { - const decision = evaluateSystemRunPolicy({ - security: "allowlist", - ask: "always", - analysisOk: true, - allowlistSatisfied: true, - approvalDecision: null, - approved: false, - isWindows: false, - cmdInvocation: false, - shellWrapperInvocation: false, - }); - expect(decision.allowed).toBe(false); - if (decision.allowed) { - throw new Error("expected denied decision"); - } - expect(decision.eventReason).toBe("approval-required"); - expect(decision.requiresAsk).toBe(true); + const denied = expectDeniedDecision( + evaluateSystemRunPolicy(buildPolicyParams({ ask: "always" })), + ); + expect(denied.eventReason).toBe("approval-required"); + expect(denied.requiresAsk).toBe(true); }); it("allows allowlist miss when explicit approval is provided", () => { - const decision = evaluateSystemRunPolicy({ - security: "allowlist", - ask: "on-miss", - analysisOk: false, - allowlistSatisfied: false, - approvalDecision: "allow-once", - approved: false, - isWindows: false, - cmdInvocation: false, - shellWrapperInvocation: false, - }); - expect(decision.allowed).toBe(true); - if (!decision.allowed) { - throw new Error("expected allowed decision"); - } - expect(decision.approvedByAsk).toBe(true); + const allowed = expectAllowedDecision( + evaluateSystemRunPolicy( + buildPolicyParams({ + ask: "on-miss", + analysisOk: false, + allowlistSatisfied: false, + approvalDecision: "allow-once", + }), + ), + ); + expect(allowed.approvedByAsk).toBe(true); }); it("denies allowlist misses without approval", () => { - const decision = evaluateSystemRunPolicy({ - security: "allowlist", - ask: "off", - analysisOk: false, - allowlistSatisfied: false, - approvalDecision: null, - approved: false, - isWindows: false, - cmdInvocation: false, - shellWrapperInvocation: false, - }); - expect(decision.allowed).toBe(false); - if (decision.allowed) { - throw new Error("expected denied decision"); - } - expect(decision.eventReason).toBe("allowlist-miss"); - expect(decision.errorMessage).toBe("SYSTEM_RUN_DENIED: allowlist miss"); + const denied = expectDeniedDecision( + evaluateSystemRunPolicy(buildPolicyParams({ analysisOk: false, allowlistSatisfied: false })), + ); + expect(denied.eventReason).toBe("allowlist-miss"); + expect(denied.errorMessage).toBe("SYSTEM_RUN_DENIED: allowlist miss"); }); it("treats shell wrappers as allowlist misses", () => { - const decision = evaluateSystemRunPolicy({ - security: "allowlist", - ask: "off", - analysisOk: true, - allowlistSatisfied: true, - approvalDecision: null, - approved: false, - isWindows: false, - cmdInvocation: false, - shellWrapperInvocation: true, - }); - expect(decision.allowed).toBe(false); - if (decision.allowed) { - throw new Error("expected denied decision"); - } - expect(decision.shellWrapperBlocked).toBe(true); - expect(decision.errorMessage).toContain("shell wrappers like sh/bash/zsh -c"); + const denied = expectDeniedDecision( + evaluateSystemRunPolicy(buildPolicyParams({ shellWrapperInvocation: true })), + ); + expect(denied.shellWrapperBlocked).toBe(true); + expect(denied.errorMessage).toContain("shell wrappers like sh/bash/zsh -c"); }); it("keeps Windows-specific guidance for cmd.exe wrappers", () => { - const decision = evaluateSystemRunPolicy({ - security: "allowlist", - ask: "off", - analysisOk: true, - allowlistSatisfied: true, - approvalDecision: null, - approved: false, - isWindows: true, - cmdInvocation: true, - shellWrapperInvocation: true, - }); - expect(decision.allowed).toBe(false); - if (decision.allowed) { - throw new Error("expected denied decision"); - } - expect(decision.shellWrapperBlocked).toBe(true); - expect(decision.windowsShellWrapperBlocked).toBe(true); - expect(decision.errorMessage).toContain("Windows shell wrappers like cmd.exe /c"); + const denied = expectDeniedDecision( + evaluateSystemRunPolicy( + buildPolicyParams({ isWindows: true, cmdInvocation: true, shellWrapperInvocation: true }), + ), + ); + expect(denied.shellWrapperBlocked).toBe(true); + expect(denied.windowsShellWrapperBlocked).toBe(true); + expect(denied.errorMessage).toContain("Windows shell wrappers like cmd.exe /c"); }); it("allows execution when policy checks pass", () => { - const decision = evaluateSystemRunPolicy({ - security: "allowlist", - ask: "on-miss", - analysisOk: true, - allowlistSatisfied: true, - approvalDecision: null, - approved: false, - isWindows: false, - cmdInvocation: false, - shellWrapperInvocation: false, - }); - expect(decision.allowed).toBe(true); - if (!decision.allowed) { - throw new Error("expected allowed decision"); - } - expect(decision.requiresAsk).toBe(false); - expect(decision.analysisOk).toBe(true); - expect(decision.allowlistSatisfied).toBe(true); + const allowed = expectAllowedDecision( + evaluateSystemRunPolicy(buildPolicyParams({ ask: "on-miss" })), + ); + expect(allowed.requiresAsk).toBe(false); + expect(allowed.analysisOk).toBe(true); + expect(allowed.allowlistSatisfied).toBe(true); }); }); diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 9ff4f435129..457f13586bc 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -67,6 +67,25 @@ describe("monitorSlackProvider tool results", () => { return (replyMock.mock.calls[0]?.[0] ?? {}) as { WasMentioned?: boolean }; } + function setRequireMentionChannelConfig(mentionPatterns?: string[]) { + slackTestState.config = { + ...(mentionPatterns + ? { + messages: { + responsePrefix: "PFX", + groupChat: { mentionPatterns }, + }, + } + : {}), + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + channels: { C1: { allow: true, requireMention: true } }, + }, + }, + }; + } + async function runDirectMessageEvent(ts: string, extraEvent: Record = {}) { await runSlackMessageOnce(monitorSlackProvider, { event: makeSlackMessageEvent({ ts, ...extraEvent }), @@ -325,18 +344,7 @@ describe("monitorSlackProvider tool results", () => { }); async function expectMentionPatternMessageAccepted(text: string): Promise { - slackTestState.config = { - messages: { - responsePrefix: "PFX", - groupChat: { mentionPatterns: ["\\bopenclaw\\b"] }, - }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { C1: { allow: true, requireMention: true } }, - }, - }, - }; + setRequireMentionChannelConfig(["\\bopenclaw\\b"]); replyMock.mockResolvedValue({ text: "hi" }); await runSlackMessageOnce(monitorSlackProvider, { @@ -359,14 +367,7 @@ describe("monitorSlackProvider tool results", () => { }); it("treats replies to bot threads as implicit mentions", async () => { - slackTestState.config = { - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { C1: { allow: true, requireMention: true } }, - }, - }, - }; + setRequireMentionChannelConfig(); replyMock.mockResolvedValue({ text: "hi" }); await runSlackMessageOnce(monitorSlackProvider, { diff --git a/src/slack/send.upload.test.ts b/src/slack/send.upload.test.ts index 1b534db6438..bc5f5c08ff7 100644 --- a/src/slack/send.upload.test.ts +++ b/src/slack/send.upload.test.ts @@ -1,20 +1,9 @@ import type { WebClient } from "@slack/web-api"; import { describe, expect, it, vi } from "vitest"; +import { installSlackBlockTestMocks } from "./blocks.test-helpers.js"; // --- Module mocks (must precede dynamic import) --- - -vi.mock("../config/config.js", () => ({ - loadConfig: () => ({}), -})); - -vi.mock("./accounts.js", () => ({ - resolveSlackAccount: () => ({ - accountId: "default", - botToken: "xoxb-test", - botTokenSource: "config", - config: {}, - }), -})); +installSlackBlockTestMocks(); vi.mock("../web/media.js", () => ({ loadWebMedia: vi.fn(async () => ({ diff --git a/src/test-utils/fixture-suite.ts b/src/test-utils/fixture-suite.ts new file mode 100644 index 00000000000..0a3a9498b40 --- /dev/null +++ b/src/test-utils/fixture-suite.ts @@ -0,0 +1,29 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export function createFixtureSuite(rootPrefix: string) { + let fixtureRoot = ""; + let fixtureCount = 0; + + return { + async setup(): Promise { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), rootPrefix)); + }, + async cleanup(): Promise { + if (!fixtureRoot) { + return; + } + await fs.rm(fixtureRoot, { recursive: true, force: true }); + fixtureRoot = ""; + }, + async createCaseDir(prefix: string): Promise { + if (!fixtureRoot) { + throw new Error("Fixture suite not initialized"); + } + const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); + await fs.mkdir(dir, { recursive: true }); + return dir; + }, + }; +} diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index f349f07b71f..0b7a719d16c 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -1,34 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + loadConfigMock as loadConfig, + pickPrimaryLanIPv4Mock as pickPrimaryLanIPv4, + pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4, + resolveGatewayPortMock as resolveGatewayPort, +} from "../gateway/gateway-connection.test-mocks.js"; import { captureEnv, withEnv } from "../test-utils/env.js"; -const loadConfig = vi.fn(); -const resolveGatewayPort = vi.fn(); -const pickPrimaryTailnetIPv4 = vi.fn(); -const pickPrimaryLanIPv4 = vi.fn(); - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig, - resolveGatewayPort, - }; -}); - -vi.mock("../infra/tailnet.js", () => ({ - pickPrimaryTailnetIPv4, -})); - -vi.mock("../gateway/net.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - pickPrimaryLanIPv4, - // Allow all URLs in tests - security validation is tested separately - isSecureWebSocketUrl: () => true, - }; -}); - const { resolveGatewayConnection } = await import("./gateway-chat.js"); describe("resolveGatewayConnection", () => { diff --git a/src/web/monitor-inbox.captures-media-path-image-messages.test.ts b/src/web/monitor-inbox.captures-media-path-image-messages.test.ts index 1a78e8e4234..23c7003cae3 100644 --- a/src/web/monitor-inbox.captures-media-path-image-messages.test.ts +++ b/src/web/monitor-inbox.captures-media-path-image-messages.test.ts @@ -26,6 +26,23 @@ describe("web monitor inbox", () => { }); } + async function runSingleUpsertAndCapture(upsert: unknown) { + const onMessage = vi.fn(); + const listener = await openMonitor(onMessage); + const sock = getSock(); + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + return { onMessage, listener }; + } + + function expectSingleGroupMessage( + onMessage: ReturnType, + expected: Record, + ) { + expect(onMessage).toHaveBeenCalledTimes(1); + expect(onMessage).toHaveBeenCalledWith(expect.objectContaining(expected)); + } + it("captures media path for image messages", async () => { const onMessage = vi.fn(); const listener = await openMonitor(onMessage); @@ -203,10 +220,7 @@ describe("web monitor inbox", () => { }); it("unwraps ephemeral messages, preserves mentions, and still delivers group pings", async () => { - const onMessage = vi.fn(); - const listener = await openMonitor(onMessage); - const sock = getSock(); - const upsert = { + const { onMessage, listener } = await runSingleUpsertAndCapture({ type: "notify", messages: [ { @@ -228,22 +242,14 @@ describe("web monitor inbox", () => { }, }, ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - expect(onMessage).toHaveBeenCalledTimes(1); - expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ - chatType: "group", - conversationId: "424242@g.us", - body: "oh hey @Clawd UK !", - mentionedJids: ["123@s.whatsapp.net"], - senderE164: "+888", - }), - ); - + }); + expectSingleGroupMessage(onMessage, { + chatType: "group", + conversationId: "424242@g.us", + body: "oh hey @Clawd UK !", + mentionedJids: ["123@s.whatsapp.net"], + senderE164: "+888", + }); await listener.close(); }); @@ -262,10 +268,7 @@ describe("web monitor inbox", () => { }, }); - const onMessage = vi.fn(); - const listener = await openMonitor(onMessage); - const sock = getSock(); - const upsert = { + const { onMessage, listener } = await runSingleUpsertAndCapture({ type: "notify", messages: [ { @@ -283,24 +286,16 @@ describe("web monitor inbox", () => { }, }, ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - expect(onMessage).toHaveBeenCalledTimes(1); - expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ - chatType: "group", - from: "55555@g.us", - senderE164: "+777", - senderJid: "777@s.whatsapp.net", - mentionedJids: ["123@s.whatsapp.net"], - selfE164: "+123", - selfJid: "123@s.whatsapp.net", - }), - ); - + }); + expectSingleGroupMessage(onMessage, { + chatType: "group", + from: "55555@g.us", + senderE164: "+777", + senderJid: "777@s.whatsapp.net", + mentionedJids: ["123@s.whatsapp.net"], + selfE164: "+123", + selfJid: "123@s.whatsapp.net", + }); await listener.close(); }); });