diff --git a/extensions/discord/src/monitor/message-handler.queue.test.ts b/extensions/discord/src/monitor/message-handler.queue.test.ts index c69fbb24f52..0aea16f89ec 100644 --- a/extensions/discord/src/monitor/message-handler.queue.test.ts +++ b/extensions/discord/src/monitor/message-handler.queue.test.ts @@ -63,6 +63,43 @@ function installDefaultDiscordPreflight() { ); } +function createAbortOnTimeoutProcessImplementation() { + return async (ctx: { abortSignal?: AbortSignal }) => { + await new Promise((resolve) => { + if (ctx.abortSignal?.aborted) { + resolve(); + return; + } + ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); + }); + }; +} + +async function queueTimedMessages(params?: { + workerRunTimeoutMs?: number; + beforeCreateHandler?: () => void; +}) { + preflightDiscordMessageMock.mockReset(); + processDiscordMessageMock.mockReset(); + deliverDiscordReplyMock.mockClear(); + + processDiscordMessageMock + .mockImplementationOnce(createAbortOnTimeoutProcessImplementation()) + .mockImplementationOnce(async () => undefined); + installDefaultDiscordPreflight(); + params?.beforeCreateHandler?.(); + + const handlerParams = createDiscordHandlerParams({ + workerRunTimeoutMs: params?.workerRunTimeoutMs ?? 50, + }); + const handler = createDiscordMessageHandler(handlerParams); + + await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); + await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined(); + + return { handlerParams }; +} + async function runSingleMessageTimeout(params: { processImpl: Parameters[0]; workerRunTimeoutMs?: number; @@ -225,34 +262,7 @@ describe("createDiscordMessageHandler queue behavior", () => { it("applies explicit inbound worker timeout to queued runs so stalled runs do not block the queue", async () => { vi.useFakeTimers(); try { - preflightDiscordMessageMock.mockReset(); - processDiscordMessageMock.mockReset(); - deliverDiscordReplyMock.mockClear(); - - processDiscordMessageMock - .mockImplementationOnce(async (ctx: { abortSignal?: AbortSignal }) => { - await new Promise((resolve) => { - if (ctx.abortSignal?.aborted) { - resolve(); - return; - } - ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); - }); - }) - .mockImplementationOnce(async () => undefined); - const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 }); - preflightDiscordMessageMock.mockImplementation( - async (preflightParams: { data: { channel_id: string } }) => - createPreflightContext(preflightParams.data.channel_id), - ); - const handler = createDiscordMessageHandler(params); - - await expect( - handler(createMessageData("m-1") as never, {} as never), - ).resolves.toBeUndefined(); - await expect( - handler(createMessageData("m-2") as never, {} as never), - ).resolves.toBeUndefined(); + const { handlerParams } = await queueTimedMessages(); await vi.advanceTimersByTimeAsync(60); await vi.waitFor(() => { @@ -263,7 +273,7 @@ describe("createDiscordMessageHandler queue behavior", () => { | { abortSignal?: AbortSignal } | undefined; expect(firstCtx?.abortSignal?.aborted).toBe(true); - expect(params.runtime.error).toHaveBeenCalledWith( + expect(handlerParams.runtime.error).toHaveBeenCalledWith( expect.stringContaining("discord inbound worker timed out after"), ); expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1); @@ -287,40 +297,15 @@ describe("createDiscordMessageHandler queue behavior", () => { it("waits for the timeout fallback reply before starting the next queued run", async () => { vi.useFakeTimers(); try { - preflightDiscordMessageMock.mockReset(); - processDiscordMessageMock.mockReset(); - deliverDiscordReplyMock.mockReset(); - const deliverTimeoutReply = createDeferred(); - deliverDiscordReplyMock.mockImplementationOnce(async () => { - await deliverTimeoutReply.promise; - }); - processDiscordMessageMock - .mockImplementationOnce(async (ctx: { abortSignal?: AbortSignal }) => { - await new Promise((resolve) => { - if (ctx.abortSignal?.aborted) { - resolve(); - return; - } - ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); + const { handlerParams } = await queueTimedMessages({ + beforeCreateHandler: () => { + deliverDiscordReplyMock.mockReset(); + deliverDiscordReplyMock.mockImplementationOnce(async () => { + await deliverTimeoutReply.promise; }); - }) - .mockImplementationOnce(async () => undefined); - preflightDiscordMessageMock.mockImplementation( - async (preflightParams: { data: { channel_id: string } }) => - createPreflightContext(preflightParams.data.channel_id), - ); - - const handler = createDiscordMessageHandler( - createDiscordHandlerParams({ workerRunTimeoutMs: 50 }), - ); - - await expect( - handler(createMessageData("m-1") as never, {} as never), - ).resolves.toBeUndefined(); - await expect( - handler(createMessageData("m-2") as never, {} as never), - ).resolves.toBeUndefined(); + }, + }); await vi.advanceTimersByTimeAsync(60); await vi.waitFor(() => { @@ -328,6 +313,9 @@ describe("createDiscordMessageHandler queue behavior", () => { }); expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + expect(handlerParams.runtime.error).toHaveBeenCalledWith( + expect.stringContaining("discord inbound worker timed out after"), + ); deliverTimeoutReply.resolve(); await deliverTimeoutReply.promise; diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index dcd9f41a7d8..85b3151076e 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -187,6 +187,23 @@ describe("runDiscordGatewayLifecycle", () => { }; } + function expectGatewaySessionStateCleared(gateway: { + state?: { + sessionId?: string | null; + resumeGatewayUrl?: string | null; + sequence?: number | null; + }; + sequence?: number | null; + }) { + if (!gateway.state) { + throw new Error("gateway state was not initialized"); + } + expect(gateway.state.sessionId).toBeNull(); + expect(gateway.state.resumeGatewayUrl).toBeNull(); + expect(gateway.state.sequence).toBeNull(); + expect(gateway.sequence).toBeNull(); + } + it("cleans up thread bindings when exec approvals startup fails", async () => { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({ @@ -350,13 +367,7 @@ describe("runDiscordGatewayLifecycle", () => { expect(runtimeError).not.toHaveBeenCalledWith( expect.stringContaining("WebSocket was closed before the connection was established"), ); - if (!gateway.state) { - throw new Error("gateway state was not initialized"); - } - expect(gateway.state.sessionId).toBeNull(); - expect(gateway.state.resumeGatewayUrl).toBeNull(); - expect(gateway.state.sequence).toBeNull(); - expect(gateway.sequence).toBeNull(); + expectGatewaySessionStateCleared(gateway); } finally { vi.useRealTimers(); } @@ -677,13 +688,7 @@ describe("runDiscordGatewayLifecycle", () => { expect(gateway.connect).toHaveBeenNthCalledWith(1, true); expect(gateway.connect).toHaveBeenNthCalledWith(2, true); expect(gateway.connect).toHaveBeenNthCalledWith(3, false); - if (!gateway.state) { - throw new Error("gateway state was not initialized"); - } - expect(gateway.state.sessionId).toBeNull(); - expect(gateway.state.resumeGatewayUrl).toBeNull(); - expect(gateway.state.sequence).toBeNull(); - expect(gateway.sequence).toBeNull(); + expectGatewaySessionStateCleared(gateway); } finally { vi.useRealTimers(); } diff --git a/src/cli/browser-cli-manage.test-helpers.ts b/src/cli/browser-cli-manage.test-helpers.ts new file mode 100644 index 00000000000..1d3f8547f9c --- /dev/null +++ b/src/cli/browser-cli-manage.test-helpers.ts @@ -0,0 +1,65 @@ +import { vi } from "vitest"; +import { registerBrowserManageCommands } from "./browser-cli-manage.js"; +import { createBrowserProgram } from "./browser-cli-test-helpers.js"; + +type BrowserRequest = { path?: string }; +type BrowserRuntimeOptions = { timeoutMs?: number }; + +export type BrowserManageCall = [unknown, BrowserRequest, BrowserRuntimeOptions | undefined]; + +const browserManageMocks = vi.hoisted(() => ({ + callBrowserRequest: vi.fn< + ( + opts: unknown, + req: BrowserRequest, + runtimeOpts?: BrowserRuntimeOptions, + ) => Promise> + >(async (_opts: unknown, req: BrowserRequest) => + req.path === "/" + ? { + enabled: true, + running: true, + pid: 1, + cdpPort: 18800, + chosenBrowser: "chrome", + userDataDir: "/tmp/openclaw", + color: "blue", + headless: true, + attachOnly: false, + } + : {}, + ), +})); + +vi.mock("./browser-cli-shared.js", () => ({ + callBrowserRequest: browserManageMocks.callBrowserRequest, +})); + +vi.mock("./cli-utils.js", async () => ({ + ...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()), +})); + +vi.mock( + "../runtime.js", + async () => + await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(), +); + +export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }) { + const { program, browser, parentOpts } = createBrowserProgram(); + if (params?.withParentTimeout) { + browser.option("--timeout ", "Timeout in ms", "30000"); + } + registerBrowserManageCommands(browser, parentOpts); + return program; +} + +export function getBrowserManageCallBrowserRequestMock() { + return browserManageMocks.callBrowserRequest; +} + +export function findBrowserManageCall(path: string): BrowserManageCall | undefined { + return browserManageMocks.callBrowserRequest.mock.calls.find( + (call) => (call[1] ?? {}).path === path, + ) as BrowserManageCall | undefined; +} diff --git a/src/cli/browser-cli-manage.test.ts b/src/cli/browser-cli-manage.test.ts index 861c3bdfe22..43e66a2c267 100644 --- a/src/cli/browser-cli-manage.test.ts +++ b/src/cli/browser-cli-manage.test.ts @@ -1,51 +1,18 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerBrowserManageCommands } from "./browser-cli-manage.js"; +import { beforeEach, describe, expect, it } from "vitest"; import { - createBrowserProgram, - getBrowserCliRuntime, - getBrowserCliRuntimeCapture, -} from "./browser-cli-test-helpers.js"; - -const mocks = vi.hoisted(() => { - return { - callBrowserRequest: vi.fn< - ( - opts: unknown, - req: { path?: string }, - runtimeOpts?: { timeoutMs?: number }, - ) => Promise> - >(async () => ({})), - }; -}); - -vi.mock("./browser-cli-shared.js", () => ({ - callBrowserRequest: mocks.callBrowserRequest, -})); - -vi.mock("./cli-utils.js", async () => ({ - ...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()), -})); - -vi.mock( - "../runtime.js", - async () => - await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(), -); - -function createProgram() { - const { program, browser, parentOpts } = createBrowserProgram(); - registerBrowserManageCommands(browser, parentOpts); - return program; -} + createBrowserManageProgram, + getBrowserManageCallBrowserRequestMock, +} from "./browser-cli-manage.test-helpers.js"; +import { getBrowserCliRuntime, getBrowserCliRuntimeCapture } from "./browser-cli-test-helpers.js"; describe("browser manage output", () => { beforeEach(() => { - mocks.callBrowserRequest.mockClear(); + getBrowserManageCallBrowserRequestMock().mockClear(); getBrowserCliRuntimeCapture().resetRuntimeCapture(); }); it("shows chrome-mcp transport for existing-session status without fake CDP fields", async () => { - mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => req.path === "/" ? { enabled: true, @@ -69,7 +36,7 @@ describe("browser manage output", () => { : {}, ); - const program = createProgram(); + const program = createBrowserManageProgram(); await program.parseAsync(["browser", "--browser-profile", "chrome-live", "status"], { from: "user", }); @@ -81,7 +48,7 @@ describe("browser manage output", () => { }); it("shows configured userDataDir for existing-session status", async () => { - mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => req.path === "/" ? { enabled: true, @@ -105,7 +72,7 @@ describe("browser manage output", () => { : {}, ); - const program = createProgram(); + const program = createBrowserManageProgram(); await program.parseAsync(["browser", "--browser-profile", "brave-live", "status"], { from: "user", }); @@ -117,7 +84,7 @@ describe("browser manage output", () => { }); it("shows chrome-mcp transport in browser profiles output", async () => { - mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => req.path === "/profiles" ? { profiles: [ @@ -138,7 +105,7 @@ describe("browser manage output", () => { : {}, ); - const program = createProgram(); + const program = createBrowserManageProgram(); await program.parseAsync(["browser", "profiles"], { from: "user" }); const output = getBrowserCliRuntime().log.mock.calls.at(-1)?.[0] as string; @@ -148,7 +115,7 @@ describe("browser manage output", () => { }); it("shows chrome-mcp transport after creating an existing-session profile", async () => { - mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => req.path === "/profiles/create" ? { ok: true, @@ -163,7 +130,7 @@ describe("browser manage output", () => { : {}, ); - const program = createProgram(); + const program = createBrowserManageProgram(); await program.parseAsync( ["browser", "create-profile", "--name", "chrome-live", "--driver", "existing-session"], { from: "user" }, @@ -176,7 +143,7 @@ describe("browser manage output", () => { }); it("redacts sensitive remote cdpUrl details in status output", async () => { - mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) => + getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => req.path === "/" ? { enabled: true, @@ -201,7 +168,7 @@ describe("browser manage output", () => { : {}, ); - const program = createProgram(); + const program = createBrowserManageProgram(); await program.parseAsync(["browser", "--browser-profile", "remote", "status"], { from: "user", }); diff --git a/src/cli/browser-cli-manage.timeout-option.test.ts b/src/cli/browser-cli-manage.timeout-option.test.ts index d7b0ee2f3b0..d2dd3e4ece7 100644 --- a/src/cli/browser-cli-manage.timeout-option.test.ts +++ b/src/cli/browser-cli-manage.timeout-option.test.ts @@ -1,108 +1,56 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerBrowserManageCommands } from "./browser-cli-manage.js"; -import { createBrowserProgram, getBrowserCliRuntimeCapture } from "./browser-cli-test-helpers.js"; - -const mocks = vi.hoisted(() => { - return { - callBrowserRequest: vi.fn(async (_opts: unknown, req: { path?: string }) => - req.path === "/" - ? { - enabled: true, - running: true, - pid: 1, - cdpPort: 18800, - chosenBrowser: "chrome", - userDataDir: "/tmp/openclaw", - color: "blue", - headless: true, - attachOnly: false, - } - : {}, - ), - }; -}); - -vi.mock("./browser-cli-shared.js", () => ({ - callBrowserRequest: mocks.callBrowserRequest, -})); - -vi.mock("./cli-utils.js", async () => ({ - ...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()), -})); - -vi.mock( - "../runtime.js", - async () => - await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(), -); +import { beforeEach, describe, expect, it } from "vitest"; +import { + createBrowserManageProgram, + findBrowserManageCall, + getBrowserManageCallBrowserRequestMock, +} from "./browser-cli-manage.test-helpers.js"; +import { getBrowserCliRuntimeCapture } from "./browser-cli-test-helpers.js"; describe("browser manage start timeout option", () => { - function createProgram() { - const { program, browser, parentOpts } = createBrowserProgram(); - browser.option("--timeout ", "Timeout in ms", "30000"); - registerBrowserManageCommands(browser, parentOpts); - return program; - } - beforeEach(() => { - mocks.callBrowserRequest.mockClear(); + getBrowserManageCallBrowserRequestMock().mockClear(); getBrowserCliRuntimeCapture().resetRuntimeCapture(); }); it("uses parent --timeout for browser start instead of hardcoded 15s", async () => { - const program = createProgram(); + const program = createBrowserManageProgram({ withParentTimeout: true }); await program.parseAsync(["browser", "--timeout", "60000", "start"], { from: "user" }); - const startCall = mocks.callBrowserRequest.mock.calls.find( - (call) => ((call[1] ?? {}) as { path?: string }).path === "/start", - ) as [Record, { path?: string }, unknown] | undefined; - + const startCall = findBrowserManageCall("/start"); expect(startCall).toBeDefined(); expect(startCall?.[0]).toMatchObject({ timeout: "60000" }); expect(startCall?.[2]).toBeUndefined(); }); it("uses a longer built-in timeout for browser status", async () => { - const program = createProgram(); + const program = createBrowserManageProgram({ withParentTimeout: true }); await program.parseAsync(["browser", "status"], { from: "user" }); - const statusCall = mocks.callBrowserRequest.mock.calls.find( - (call) => ((call[1] ?? {}) as { path?: string }).path === "/", - ) as [Record, { path?: string }, { timeoutMs?: number }] | undefined; - + const statusCall = findBrowserManageCall("/"); expect(statusCall?.[2]).toEqual({ timeoutMs: 45_000 }); }); it("uses a longer built-in timeout for browser tabs", async () => { - const program = createProgram(); + const program = createBrowserManageProgram({ withParentTimeout: true }); await program.parseAsync(["browser", "tabs"], { from: "user" }); - const tabsCall = mocks.callBrowserRequest.mock.calls.find( - (call) => ((call[1] ?? {}) as { path?: string }).path === "/tabs", - ) as [Record, { path?: string }, { timeoutMs?: number }] | undefined; - + const tabsCall = findBrowserManageCall("/tabs"); expect(tabsCall?.[2]).toEqual({ timeoutMs: 45_000 }); }); it("uses a longer built-in timeout for browser profiles", async () => { - const program = createProgram(); + const program = createBrowserManageProgram({ withParentTimeout: true }); await program.parseAsync(["browser", "profiles"], { from: "user" }); - const profilesCall = mocks.callBrowserRequest.mock.calls.find( - (call) => ((call[1] ?? {}) as { path?: string }).path === "/profiles", - ) as [Record, { path?: string }, { timeoutMs?: number }] | undefined; - + const profilesCall = findBrowserManageCall("/profiles"); expect(profilesCall?.[2]).toEqual({ timeoutMs: 45_000 }); }); it("uses a longer built-in timeout for browser open", async () => { - const program = createProgram(); + const program = createBrowserManageProgram({ withParentTimeout: true }); await program.parseAsync(["browser", "open", "https://example.com"], { from: "user" }); - const openCall = mocks.callBrowserRequest.mock.calls.find( - (call) => ((call[1] ?? {}) as { path?: string }).path === "/tabs/open", - ) as [Record, { path?: string }, { timeoutMs?: number }] | undefined; - + const openCall = findBrowserManageCall("/tabs/open"); expect(openCall?.[2]).toEqual({ timeoutMs: 45_000 }); }); }); diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 7d845b53a86..3ec242ec1e8 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -21,6 +21,59 @@ import { writeConfigFile, } from "./plugins-cli-test-helpers.js"; +function createEnabledPluginConfig(pluginId: string): OpenClawConfig { + return { + plugins: { + entries: { + [pluginId]: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; +} + +function createClawHubInstalledConfig(params: { + pluginId: string; + install: Record; +}): OpenClawConfig { + const enabledCfg = createEnabledPluginConfig(params.pluginId); + return { + ...enabledCfg, + plugins: { + ...enabledCfg.plugins, + installs: { + [params.pluginId]: params.install, + }, + }, + } as OpenClawConfig; +} + +function createClawHubInstallResult(params: { + pluginId: string; + packageName: string; + version: string; + channel: string; +}): Awaited> { + return { + ok: true, + pluginId: params.pluginId, + targetDir: `/tmp/openclaw-state/extensions/${params.pluginId}`, + version: params.version, + packageName: params.packageName, + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: params.packageName, + clawhubFamily: "code-plugin", + clawhubChannel: params.channel, + version: params.version, + integrity: "sha256-abc", + resolvedAt: "2026-03-22T00:00:00.000Z", + }, + }; +} + describe("plugins cli install", () => { beforeEach(() => { resetPluginsCliTestState(); @@ -142,51 +195,29 @@ describe("plugins cli install", () => { entries: {}, }, } as OpenClawConfig; - const enabledCfg = { - plugins: { - entries: { - demo: { - enabled: true, - }, - }, - }, - } as OpenClawConfig; - const installedCfg = { - ...enabledCfg, - plugins: { - ...enabledCfg.plugins, - installs: { - demo: { - source: "clawhub", - spec: "clawhub:demo@1.2.3", - installPath: "/tmp/openclaw-state/extensions/demo", - clawhubPackage: "demo", - clawhubFamily: "code-plugin", - clawhubChannel: "official", - }, - }, - }, - } as OpenClawConfig; - - loadConfig.mockReturnValue(cfg); - parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); - installPluginFromClawHub.mockResolvedValue({ - ok: true, + const enabledCfg = createEnabledPluginConfig("demo"); + const installedCfg = createClawHubInstalledConfig({ pluginId: "demo", - targetDir: "/tmp/openclaw-state/extensions/demo", - version: "1.2.3", - packageName: "demo", - clawhub: { + install: { source: "clawhub", - clawhubUrl: "https://clawhub.ai", + spec: "clawhub:demo@1.2.3", + installPath: "/tmp/openclaw-state/extensions/demo", clawhubPackage: "demo", clawhubFamily: "code-plugin", clawhubChannel: "official", - version: "1.2.3", - integrity: "sha256-abc", - resolvedAt: "2026-03-22T00:00:00.000Z", }, }); + + loadConfig.mockReturnValue(cfg); + parseClawHubPluginSpec.mockReturnValue({ name: "demo" }); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "official", + }), + ); enablePluginInConfig.mockReturnValue({ config: enabledCfg }); recordPluginInstall.mockReturnValue(installedCfg); applyExclusiveSlotSelection.mockReturnValue({ @@ -223,48 +254,26 @@ describe("plugins cli install", () => { entries: {}, }, } as OpenClawConfig; - const enabledCfg = { - plugins: { - entries: { - demo: { - enabled: true, - }, - }, - }, - } as OpenClawConfig; - const installedCfg = { - ...enabledCfg, - plugins: { - ...enabledCfg.plugins, - installs: { - demo: { - source: "clawhub", - spec: "clawhub:demo@1.2.3", - installPath: "/tmp/openclaw-state/extensions/demo", - clawhubPackage: "demo", - }, - }, - }, - } as OpenClawConfig; - - loadConfig.mockReturnValue(cfg); - installPluginFromClawHub.mockResolvedValue({ - ok: true, + const enabledCfg = createEnabledPluginConfig("demo"); + const installedCfg = createClawHubInstalledConfig({ pluginId: "demo", - targetDir: "/tmp/openclaw-state/extensions/demo", - version: "1.2.3", - packageName: "demo", - clawhub: { + install: { source: "clawhub", - clawhubUrl: "https://clawhub.ai", + spec: "clawhub:demo@1.2.3", + installPath: "/tmp/openclaw-state/extensions/demo", clawhubPackage: "demo", - clawhubFamily: "code-plugin", - clawhubChannel: "community", - version: "1.2.3", - integrity: "sha256-abc", - resolvedAt: "2026-03-22T00:00:00.000Z", }, }); + + loadConfig.mockReturnValue(cfg); + installPluginFromClawHub.mockResolvedValue( + createClawHubInstallResult({ + pluginId: "demo", + packageName: "demo", + version: "1.2.3", + channel: "community", + }), + ); enablePluginInConfig.mockReturnValue({ config: enabledCfg }); recordPluginInstall.mockReturnValue(installedCfg); applyExclusiveSlotSelection.mockReturnValue({ diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 0d6ca57d4e4..24e376568d2 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createDoctorPrompter } from "./doctor-prompter.js"; +import { + readEmbeddedGatewayTokenForTest, + testServiceAuditCodes, +} from "./doctor-service-audit.test-helpers.js"; const fsMocks = vi.hoisted(() => ({ realpath: vi.fn(), @@ -57,17 +61,9 @@ vi.mock("../daemon/runtime-paths.js", () => ({ vi.mock("../daemon/service-audit.js", () => ({ auditGatewayServiceConfig: mocks.auditGatewayServiceConfig, needsNodeRuntimeMigration: vi.fn(() => false), - readEmbeddedGatewayToken: ( - command: { - environment?: Record; - environmentValueSources?: Record; - } | null, - ) => - command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file" - ? undefined - : command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined, + readEmbeddedGatewayToken: readEmbeddedGatewayTokenForTest, SERVICE_AUDIT_CODES: { - gatewayEntrypointMismatch: "gateway-entrypoint-mismatch", + gatewayEntrypointMismatch: testServiceAuditCodes.gatewayEntrypointMismatch, }, })); diff --git a/src/commands/doctor-service-audit.test-helpers.ts b/src/commands/doctor-service-audit.test-helpers.ts new file mode 100644 index 00000000000..f1e1b8fd6ed --- /dev/null +++ b/src/commands/doctor-service-audit.test-helpers.ts @@ -0,0 +1,15 @@ +export const testServiceAuditCodes = { + gatewayEntrypointMismatch: "gateway-entrypoint-mismatch", + gatewayTokenMismatch: "gateway-token-mismatch", +} as const; + +export function readEmbeddedGatewayTokenForTest( + command: { + environment?: Record; + environmentValueSources?: Record; + } | null, +) { + return command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file" + ? undefined + : command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined; +} diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index f41fa4a6c47..e1cee060139 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -4,6 +4,10 @@ import path from "node:path"; import { afterEach, beforeEach, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import { + readEmbeddedGatewayTokenForTest, + testServiceAuditCodes, +} from "./doctor-service-audit.test-helpers.js"; import type { LegacyStateDetection } from "./doctor-state-migrations.js"; let originalIsTTY: boolean | undefined; @@ -224,19 +228,8 @@ vi.mock("../daemon/inspect.js", () => ({ vi.mock("../daemon/service-audit.js", () => ({ auditGatewayServiceConfig, needsNodeRuntimeMigration: vi.fn(() => false), - readEmbeddedGatewayToken: ( - command: { - environment?: Record; - environmentValueSources?: Record; - } | null, - ) => - command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file" - ? undefined - : command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined, - SERVICE_AUDIT_CODES: { - gatewayEntrypointMismatch: "gateway-entrypoint-mismatch", - gatewayTokenMismatch: "gateway-token-mismatch", - }, + readEmbeddedGatewayToken: readEmbeddedGatewayTokenForTest, + SERVICE_AUDIT_CODES: testServiceAuditCodes, })); vi.mock("../daemon/program-args.js", () => ({ diff --git a/src/commands/doctor.note-test-helpers.ts b/src/commands/doctor.note-test-helpers.ts index dbf30fc661a..1c5830921ea 100644 --- a/src/commands/doctor.note-test-helpers.ts +++ b/src/commands/doctor.note-test-helpers.ts @@ -8,6 +8,9 @@ vi.mock("../terminal/note.js", () => ({ export async function loadDoctorCommandForTest(params?: { unmockModules?: string[] }) { vi.resetModules(); + vi.doMock("../terminal/note.js", () => ({ + note: (...args: unknown[]) => terminalNoteMock(...args), + })); for (const modulePath of params?.unmockModules ?? []) { vi.doUnmock(modulePath); } diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 25dfdad6696..c627a173ba3 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -96,6 +96,71 @@ function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig } as OpenClawConfig; } +function createMSTeamsCatalogEntry(): ChannelPluginCatalogEntry { + return { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + }; +} + +function createMSTeamsPluginRegistryEntry(params?: { includeSetupWizard?: boolean }) { + return { + pluginId: "@openclaw/msteams-plugin", + source: "test", + plugin: { + id: "msteams", + meta: createMSTeamsCatalogEntry().meta, + capabilities: { chatTypes: ["direct"] as const }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + ...(params?.includeSetupWizard + ? { + setupWizard: { + channel: "msteams", + status: { + configuredLabel: "configured", + unconfiguredLabel: "installed", + resolveConfigured: () => false, + resolveStatusLines: async () => [], + resolveSelectionHint: async () => "installed", + }, + credentials: [], + }, + } + : {}), + outbound: { deliveryMode: "direct" as const }, + }, + }; +} + +function mockMSTeamsRegistrySnapshot(params?: { includeSetupWizard?: boolean }) { + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockImplementation( + ({ channel }: { channel: string }) => { + const registry = createEmptyPluginRegistry(); + if (channel === "msteams") { + if (params?.includeSetupWizard) { + registry.channelSetups.push(createMSTeamsPluginRegistryEntry(params) as never); + } else { + registry.channels.push(createMSTeamsPluginRegistryEntry(params) as never); + } + } + return registry; + }, + ); +} + function patchTelegramAdapter(overrides: Parameters[1]) { return patchChannelSetupWizardAdapter("telegram", { ...overrides, @@ -374,50 +439,8 @@ describe("setupChannels", () => { it("keeps configured external plugin channels visible when the active registry starts empty", async () => { setActivePluginRegistry(createEmptyPluginRegistry()); - catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ - { - id: "msteams", - pluginId: "@openclaw/msteams-plugin", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams", - docsPath: "/channels/msteams", - blurb: "teams channel", - }, - install: { - npmSpec: "@openclaw/msteams", - }, - } satisfies ChannelPluginCatalogEntry, - ]); - vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockImplementation( - ({ channel }: { channel: string }) => { - const registry = createEmptyPluginRegistry(); - if (channel === "msteams") { - registry.channels.push({ - pluginId: "@openclaw/msteams-plugin", - source: "test", - plugin: { - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams", - docsPath: "/channels/msteams", - blurb: "teams channel", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, - } as never); - } - return registry; - }, - ); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]); + mockMSTeamsRegistrySnapshot(); const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => { if (message === "Select a channel") { const entries = options as Array<{ value: string; hint?: string }>; @@ -463,22 +486,7 @@ describe("setupChannels", () => { it("treats installed external plugin channels as installed without reinstall prompts", async () => { setActivePluginRegistry(createEmptyPluginRegistry()); - catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ - { - id: "msteams", - pluginId: "@openclaw/msteams-plugin", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams", - docsPath: "/channels/msteams", - blurb: "teams channel", - }, - install: { - npmSpec: "@openclaw/msteams", - }, - } satisfies ChannelPluginCatalogEntry, - ]); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]); manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [ { @@ -488,45 +496,7 @@ describe("setupChannels", () => { ], diagnostics: [], }); - vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockImplementation( - ({ channel }: { channel: string }) => { - const registry = createEmptyPluginRegistry(); - if (channel === "msteams") { - registry.channelSetups.push({ - pluginId: "@openclaw/msteams-plugin", - source: "test", - plugin: { - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams", - docsPath: "/channels/msteams", - blurb: "teams channel", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - setupWizard: { - channel: "msteams", - status: { - configuredLabel: "configured", - unconfiguredLabel: "installed", - resolveConfigured: () => false, - resolveStatusLines: async () => [], - resolveSelectionHint: async () => "installed", - }, - credentials: [], - }, - outbound: { deliveryMode: "direct" }, - }, - } as never); - } - return registry; - }, - ); + mockMSTeamsRegistrySnapshot({ includeSetupWizard: true }); let channelSelectionCount = 0; const select = vi.fn(async ({ message }: { message: string }) => {