diff --git a/extensions/discord/src/monitor/provider.allowlist.test.ts b/extensions/discord/src/monitor/provider.allowlist.test.ts index 0d34b65c1f7..d478e39abcf 100644 --- a/extensions/discord/src/monitor/provider.allowlist.test.ts +++ b/extensions/discord/src/monitor/provider.allowlist.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { createRuntimeEnv } from "../../../../test/helpers/extensions/runtime-env.js"; const { resolveDiscordChannelAllowlistMock, resolveDiscordUserAllowlistMock } = vi.hoisted(() => ({ resolveDiscordChannelAllowlistMock: vi.fn( @@ -35,7 +36,7 @@ import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js"; describe("resolveDiscordAllowlistConfig", () => { it("canonicalizes resolved user names to ids in runtime config", async () => { - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv; + const runtime = createRuntimeEnv({ throwOnExit: false }) as unknown as RuntimeEnv; const result = await resolveDiscordAllowlistConfig({ token: "token", allowFrom: ["Alice", "111", "*"], @@ -69,7 +70,7 @@ describe("resolveDiscordAllowlistConfig", () => { channelName: "missing-room", }, ]); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv; + const runtime = createRuntimeEnv({ throwOnExit: false }) as unknown as RuntimeEnv; await resolveDiscordAllowlistConfig({ token: "token", diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 0d6ae54e05d..457e1be3e8d 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import type { FeishuMessageEvent } from "./bot.js"; import { @@ -99,16 +100,6 @@ vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ }), })); -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - } as RuntimeEnv; -} - async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) { const runtime = createRuntimeEnv(); await handleFeishuMessage({ diff --git a/extensions/feishu/src/monitor.acp-init-failure.lifecycle.test.ts b/extensions/feishu/src/monitor.acp-init-failure.lifecycle.test.ts index d98bbec9e7c..540ee579d98 100644 --- a/extensions/feishu/src/monitor.acp-init-failure.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.acp-init-failure.lifecycle.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { monitorSingleAccount } from "./monitor.account.js"; import { setFeishuRuntime } from "./runtime.js"; @@ -139,14 +140,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount { } as unknown as ResolvedFeishuAccount; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - } as RuntimeEnv; -} - function createTopicEvent(messageId: string) { return { sender: { diff --git a/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts b/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts index e235af4d8ec..2f3f061b73a 100644 --- a/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { monitorSingleAccount } from "./monitor.account.js"; import { setFeishuRuntime } from "./runtime.js"; @@ -133,14 +134,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount { } as unknown as ResolvedFeishuAccount; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - } as RuntimeEnv; -} - function createBotMenuEvent(params: { eventKey: string; timestamp: string }) { return { event_key: params.eventKey, diff --git a/extensions/feishu/src/monitor.broadcast.reply-once.lifecycle.test.ts b/extensions/feishu/src/monitor.broadcast.reply-once.lifecycle.test.ts index 839ea934454..04554eebe53 100644 --- a/extensions/feishu/src/monitor.broadcast.reply-once.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.broadcast.reply-once.lifecycle.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { monitorSingleAccount } from "./monitor.account.js"; import { setFeishuRuntime } from "./runtime.js"; @@ -159,14 +160,6 @@ function createLifecycleAccount(accountId: "account-A" | "account-B"): ResolvedF } as unknown as ResolvedFeishuAccount; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - } as RuntimeEnv; -} - function createBroadcastEvent(messageId: string) { return { sender: { @@ -190,7 +183,7 @@ async function setupLifecycleMonitor(accountId: "account-A" | "account-B") { }); createEventDispatcherMock.mockReturnValueOnce({ register }); - const runtime = createRuntimeEnv(); + const runtime = createRuntimeEnv({ throwOnExit: false }); runtimesByAccount.set(accountId, runtime); await monitorSingleAccount({ diff --git a/extensions/feishu/src/monitor.card-action.lifecycle.test.ts b/extensions/feishu/src/monitor.card-action.lifecycle.test.ts index c5908b29487..bff24ab8789 100644 --- a/extensions/feishu/src/monitor.card-action.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.card-action.lifecycle.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { resetProcessedFeishuCardActionTokensForTests } from "./card-action.js"; import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; @@ -135,14 +136,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount { } as unknown as ResolvedFeishuAccount; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - } as RuntimeEnv; -} - function createCardActionEvent(params: { token: string; action: string; diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index 5765577441f..c13dfa38360 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -5,6 +5,7 @@ import { resolveInboundDebounceMs, } from "../../../src/auto-reply/inbound-debounce.js"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js"; import * as dedup from "./dedup.js"; @@ -172,11 +173,7 @@ async function setupDebounceMonitor(params?: { await monitorSingleAccount({ cfg: buildDebounceConfig(), account: buildDebounceAccount(), - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - } as RuntimeEnv, + runtime: createRuntimeEnv({ throwOnExit: false }) as RuntimeEnv, botOpenIdSource: { kind: "prefetched", botOpenId: params?.botOpenId ?? "ou_bot", @@ -452,11 +449,7 @@ describe("monitorSingleAccount lifecycle", () => { await monitorSingleAccount({ cfg: buildDebounceConfig(), account: buildDebounceAccount(), - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - } as RuntimeEnv, + runtime: createRuntimeEnv({ throwOnExit: false }) as RuntimeEnv, botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot", @@ -493,11 +486,7 @@ describe("monitorSingleAccount lifecycle", () => { monitorSingleAccount({ cfg: buildDebounceConfig(), account: buildDebounceAccount(), - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - } as RuntimeEnv, + runtime: createRuntimeEnv({ throwOnExit: false }) as RuntimeEnv, botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot", diff --git a/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts b/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts index 4a965110613..a579c27fdb1 100644 --- a/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { monitorSingleAccount } from "./monitor.account.js"; import { setFeishuRuntime } from "./runtime.js"; @@ -140,14 +141,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount { } as unknown as ResolvedFeishuAccount; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - } as RuntimeEnv; -} - function createTextEvent(messageId: string) { return { sender: { diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index 601df225263..1ee572cf890 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ClawdbotConfig } from "../runtime-api.js"; import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; @@ -134,7 +135,7 @@ describe("Feishu monitor startup preflight", () => { }); const abortController = new AbortController(); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const runtime = createRuntimeEnv({ throwOnExit: false }); const monitorPromise = monitorFeishuProvider({ config: buildMultiAccountWebsocketConfig(["alpha", "beta"]), runtime, diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 2c4c8a254bf..57fc1e64e6b 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -1,26 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import type { RuntimeEnv } from "../runtime-api.js"; import { matrixPlugin } from "./channel.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { resolveMatrixConfigForAccount } from "./matrix/client/config.js"; -import { setMatrixRuntime } from "./runtime.js"; +import { installMatrixTestRuntime } from "./test-runtime.js"; import type { CoreConfig } from "./types.js"; describe("matrix directory", () => { - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; + const runtimeEnv: RuntimeEnv = createRuntimeEnv(); beforeEach(() => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(), - }, - } as PluginRuntime); + installMatrixTestRuntime(); }); it("lists peers and groups from config", async () => { diff --git a/extensions/matrix/src/channel.resolve.test.ts b/extensions/matrix/src/channel.resolve.test.ts index aff3b30119f..b39c754c07e 100644 --- a/extensions/matrix/src/channel.resolve.test.ts +++ b/extensions/matrix/src/channel.resolve.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; const resolveMatrixTargetsMock = vi.hoisted(() => vi.fn(async () => [])); @@ -19,11 +20,7 @@ describe("matrix resolver adapter", () => { accountId: "ops", inputs: ["Alice"], kind: "user", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }, + runtime: createRuntimeEnv({ throwOnExit: false }), }); expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({ diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts index ba065fba792..ca482668ace 100644 --- a/extensions/matrix/src/channel.setup.test.ts +++ b/extensions/matrix/src/channel.setup.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js"; +import type { RuntimeEnv } from "../runtime-api.js"; const verificationMocks = vi.hoisted(() => ({ bootstrapMatrixVerification: vi.fn(), @@ -10,7 +10,7 @@ vi.mock("./matrix/actions/verification.js", () => ({ })); import { matrixPlugin } from "./channel.js"; -import { setMatrixRuntime } from "./runtime.js"; +import { installMatrixTestRuntime } from "./test-runtime.js"; import type { CoreConfig } from "./types.js"; describe("matrix setup post-write bootstrap", () => { @@ -30,11 +30,7 @@ describe("matrix setup post-write bootstrap", () => { log.mockClear(); error.mockClear(); exit.mockClear(); - setMatrixRuntime({ - state: { - resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(), - }, - } as PluginRuntime); + installMatrixTestRuntime(); }); it("bootstraps verification for newly added encrypted accounts", async () => { diff --git a/extensions/matrix/src/matrix/client/storage.test.ts b/extensions/matrix/src/matrix/client/storage.test.ts index f0749dd5bef..a4cb5b20964 100644 --- a/extensions/matrix/src/matrix/client/storage.test.ts +++ b/extensions/matrix/src/matrix/client/storage.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js"; -import { setMatrixRuntime } from "../../runtime.js"; +import { installMatrixTestRuntime } from "../../test-runtime.js"; const createBackupArchiveMock = vi.hoisted(() => vi.fn(async (_params: unknown) => ({ @@ -67,10 +67,8 @@ describe("matrix client storage paths", () => { const stateDir = path.join(homeDir, ".openclaw"); fs.mkdirSync(stateDir, { recursive: true }); tempDirs.push(homeDir); - setMatrixRuntime({ - config: { - loadConfig: () => cfg, - }, + installMatrixTestRuntime({ + cfg, logging: { getChildLogger: () => ({ info: () => {}, @@ -78,10 +76,8 @@ describe("matrix client storage paths", () => { error: () => {}, }), }, - state: { - resolveStateDir: () => stateDir, - }, - } as never); + stateDir, + }); return stateDir; } diff --git a/extensions/matrix/src/matrix/credentials.test.ts b/extensions/matrix/src/matrix/credentials.test.ts index eb05a1ed2d2..34adac370a6 100644 --- a/extensions/matrix/src/matrix/credentials.test.ts +++ b/extensions/matrix/src/matrix/credentials.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { setMatrixRuntime } from "../runtime.js"; +import { installMatrixTestRuntime } from "../test-runtime.js"; import { credentialsMatchConfig, loadMatrixCredentials, @@ -30,14 +30,7 @@ describe("matrix credentials storage", () => { ): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-creds-")); tempDirs.push(dir); - setMatrixRuntime({ - config: { - loadConfig: () => cfg, - }, - state: { - resolveStateDir: () => dir, - }, - } as never); + installMatrixTestRuntime({ cfg, stateDir: dir }); return dir; } diff --git a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts index cbfaeac7a2e..8d6b050ece6 100644 --- a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { setMatrixRuntime } from "../../runtime.js"; +import { installMatrixMonitorTestRuntime } from "../../test-runtime.js"; import type { MatrixClient } from "../sdk.js"; import { createMatrixHandlerTestHarness, @@ -9,22 +9,10 @@ import type { MatrixRawEvent } from "./types.js"; describe("createMatrixRoomMessageHandler inbound body formatting", () => { beforeEach(() => { - setMatrixRuntime({ - channel: { - mentions: { - matchesMentionPatterns: () => false, - }, - media: { - saveMediaBuffer: vi.fn(), - }, - }, - config: { - loadConfig: () => ({}), - }, - state: { - resolveStateDir: () => "/tmp", - }, - } as never); + installMatrixMonitorTestRuntime({ + matchesMentionPatterns: () => false, + saveMediaBuffer: vi.fn(), + }); }); it("records thread metadata for group thread messages", async () => { diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts index d545e479564..cbe14bba19b 100644 --- a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "../../../runtime-api.js"; -import { setMatrixRuntime } from "../../runtime.js"; +import { installMatrixMonitorTestRuntime } from "../../test-runtime.js"; import type { MatrixClient } from "../sdk.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; @@ -143,23 +143,7 @@ function createImageEvent(content: Record): MatrixRawEvent { describe("createMatrixRoomMessageHandler media failures", () => { beforeEach(() => { downloadMatrixMediaMock.mockReset(); - setMatrixRuntime({ - channel: { - mentions: { - matchesMentionPatterns: (text: string, patterns: RegExp[]) => - patterns.some((pattern) => pattern.test(text)), - }, - media: { - saveMediaBuffer: vi.fn(), - }, - }, - config: { - loadConfig: () => ({}), - }, - state: { - resolveStateDir: () => "/tmp", - }, - } as unknown as PluginRuntime); + installMatrixMonitorTestRuntime(); }); it("replaces bare image filenames with an unavailable marker when unencrypted download fails", async () => { diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index a2acddafb28..8512f7b7cdd 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -3,7 +3,7 @@ import { __testing as sessionBindingTesting, registerSessionBindingAdapter, } from "../../../../../src/infra/outbound/session-binding-service.js"; -import { setMatrixRuntime } from "../../runtime.js"; +import { installMatrixMonitorTestRuntime } from "../../test-runtime.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; import { createMatrixHandlerTestHarness, @@ -26,23 +26,7 @@ vi.mock("../send.js", () => ({ beforeEach(() => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); - setMatrixRuntime({ - channel: { - mentions: { - matchesMentionPatterns: (text: string, patterns: RegExp[]) => - patterns.some((pattern) => pattern.test(text)), - }, - media: { - saveMediaBuffer: vi.fn(), - }, - }, - config: { - loadConfig: () => ({}), - }, - state: { - resolveStateDir: () => "/tmp", - }, - } as never); + installMatrixMonitorTestRuntime(); }); function createReactionHarness(params?: { diff --git a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts index c8dfe5ddc8b..d55117db8c9 100644 --- a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts @@ -1,29 +1,13 @@ import { describe, expect, it, vi } from "vitest"; import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "../../../runtime-api.js"; -import { setMatrixRuntime } from "../../runtime.js"; +import { installMatrixMonitorTestRuntime } from "../../test-runtime.js"; import type { MatrixClient } from "../sdk.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; import { EventType, type MatrixRawEvent } from "./types.js"; describe("createMatrixRoomMessageHandler thread root media", () => { it("keeps image-only thread roots visible via attachment markers", async () => { - setMatrixRuntime({ - channel: { - mentions: { - matchesMentionPatterns: (text: string, patterns: RegExp[]) => - patterns.some((pattern) => pattern.test(text)), - }, - media: { - saveMediaBuffer: vi.fn(), - }, - }, - config: { - loadConfig: () => ({}), - }, - state: { - resolveStateDir: () => "/tmp", - }, - } as unknown as PluginRuntime); + installMatrixMonitorTestRuntime(); const recordInboundSession = vi.fn().mockResolvedValue(undefined); const formatAgentEnvelope = vi diff --git a/extensions/matrix/src/onboarding.resolve.test.ts b/extensions/matrix/src/onboarding.resolve.test.ts index 270343b7509..0b9ef1f645c 100644 --- a/extensions/matrix/src/onboarding.resolve.test.ts +++ b/extensions/matrix/src/onboarding.resolve.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js"; +import { matrixOnboardingAdapter } from "./onboarding.js"; +import { installMatrixTestRuntime } from "./test-runtime.js"; import type { CoreConfig } from "./types.js"; const resolveMatrixTargetsMock = vi.hoisted(() => @@ -10,20 +13,9 @@ vi.mock("./resolve-targets.js", () => ({ resolveMatrixTargets: resolveMatrixTargetsMock, })); -import { matrixOnboardingAdapter } from "./onboarding.js"; -import { setMatrixRuntime } from "./runtime.js"; - describe("matrix onboarding account-scoped resolution", () => { beforeEach(() => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => - (homeDir ?? (() => "/tmp"))(), - }, - config: { - loadConfig: () => ({}), - }, - } as never); + installMatrixTestRuntime(); resolveMatrixTargetsMock.mockClear(); }); @@ -91,7 +83,7 @@ describe("matrix onboarding account-scoped resolution", () => { }, }, } as CoreConfig, - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + runtime: createRuntimeEnv({ throwOnExit: false }) as unknown as RuntimeEnv, prompter, options: undefined, accountOverrides: {}, diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index b27dbf8189f..f49596bcb53 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js"; import { matrixOnboardingAdapter } from "./onboarding.js"; -import { setMatrixRuntime } from "./runtime.js"; +import { installMatrixTestRuntime } from "./test-runtime.js"; import type { CoreConfig } from "./types.js"; vi.mock("./matrix/deps.js", () => ({ @@ -32,15 +33,7 @@ describe("matrix onboarding", () => { }); it("offers env shortcut for non-default account when scoped env vars are present", async () => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => - (homeDir ?? (() => "/tmp"))(), - }, - config: { - loadConfig: () => ({}), - }, - } as never); + installMatrixTestRuntime(); process.env.MATRIX_HOMESERVER = "https://matrix.env.example.org"; process.env.MATRIX_USER_ID = "@env:example.org"; @@ -89,7 +82,7 @@ describe("matrix onboarding", () => { }, }, } as CoreConfig, - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + runtime: createRuntimeEnv({ throwOnExit: false }) as unknown as RuntimeEnv, prompter, options: undefined, accountOverrides: {}, @@ -118,15 +111,7 @@ describe("matrix onboarding", () => { }); it("promotes legacy top-level Matrix config before adding a named account", async () => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => - (homeDir ?? (() => "/tmp"))(), - }, - config: { - loadConfig: () => ({}), - }, - } as never); + installMatrixTestRuntime(); const prompter = { note: vi.fn(async () => {}), @@ -167,7 +152,7 @@ describe("matrix onboarding", () => { }, }, } as CoreConfig, - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + runtime: createRuntimeEnv({ throwOnExit: false }) as unknown as RuntimeEnv, prompter, options: undefined, accountOverrides: {}, @@ -197,15 +182,7 @@ describe("matrix onboarding", () => { }); it("includes device env var names in auth help text", async () => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => - (homeDir ?? (() => "/tmp"))(), - }, - config: { - loadConfig: () => ({}), - }, - } as never); + installMatrixTestRuntime(); const notes: string[] = []; const prompter = { @@ -222,7 +199,7 @@ describe("matrix onboarding", () => { await expect( matrixOnboardingAdapter.configureInteractive!({ cfg: { channels: {} } as CoreConfig, - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + runtime: createRuntimeEnv({ throwOnExit: false }) as unknown as RuntimeEnv, prompter, options: undefined, accountOverrides: {}, @@ -241,15 +218,7 @@ describe("matrix onboarding", () => { }); it("prompts for private-network access when onboarding an internal http homeserver", async () => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => - (homeDir ?? (() => "/tmp"))(), - }, - config: { - loadConfig: () => ({}), - }, - } as never); + installMatrixTestRuntime(); const prompter = { note: vi.fn(async () => {}), @@ -284,7 +253,7 @@ describe("matrix onboarding", () => { const result = await matrixOnboardingAdapter.configureInteractive!({ cfg: {} as CoreConfig, - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + runtime: createRuntimeEnv({ throwOnExit: false }) as unknown as RuntimeEnv, prompter, options: undefined, accountOverrides: {}, @@ -336,15 +305,7 @@ describe("matrix onboarding", () => { }); it("writes allowlists and room access to the selected Matrix account", async () => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => - (homeDir ?? (() => "/tmp"))(), - }, - config: { - loadConfig: () => ({}), - }, - } as never); + installMatrixTestRuntime(); const prompter = { note: vi.fn(async () => {}), @@ -405,7 +366,7 @@ describe("matrix onboarding", () => { }, }, } as CoreConfig, - runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + runtime: createRuntimeEnv({ throwOnExit: false }) as unknown as RuntimeEnv, prompter, options: undefined, accountOverrides: {}, @@ -470,15 +431,7 @@ describe("matrix onboarding", () => { }); it("reports configured when only the effective default Matrix account is configured", async () => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => - (homeDir ?? (() => "/tmp"))(), - }, - config: { - loadConfig: () => ({}), - }, - } as never); + installMatrixTestRuntime(); const status = await matrixOnboardingAdapter.getStatus({ cfg: { @@ -503,15 +456,7 @@ describe("matrix onboarding", () => { }); it("asks for defaultAccount when multiple named Matrix accounts exist", async () => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => - (homeDir ?? (() => "/tmp"))(), - }, - config: { - loadConfig: () => ({}), - }, - } as never); + installMatrixTestRuntime(); const status = await matrixOnboardingAdapter.getStatus({ cfg: { diff --git a/extensions/matrix/src/test-runtime.ts b/extensions/matrix/src/test-runtime.ts new file mode 100644 index 00000000000..cd1dccb8ab4 --- /dev/null +++ b/extensions/matrix/src/test-runtime.ts @@ -0,0 +1,66 @@ +import { vi } from "vitest"; +import type { PluginRuntime } from "./runtime-api.js"; +import { setMatrixRuntime } from "./runtime.js"; + +type MatrixTestRuntimeOptions = { + cfg?: Record; + logging?: Partial; + channel?: Partial; + stateDir?: string; +}; + +export function installMatrixTestRuntime(options: MatrixTestRuntimeOptions = {}): void { + const defaultStateDirResolver: NonNullable["resolveStateDir"] = ( + _env, + homeDir, + ) => options.stateDir ?? (homeDir ?? (() => "/tmp"))(); + const logging: PluginRuntime["logging"] | undefined = options.logging + ? ({ + shouldLogVerbose: () => false, + getChildLogger: () => ({ + info: () => {}, + warn: () => {}, + error: () => {}, + }), + ...options.logging, + } as PluginRuntime["logging"]) + : undefined; + + setMatrixRuntime({ + config: { + loadConfig: () => options.cfg ?? {}, + }, + ...(options.channel ? { channel: options.channel as PluginRuntime["channel"] } : {}), + ...(logging ? { logging } : {}), + state: { + resolveStateDir: defaultStateDirResolver, + }, + } as PluginRuntime); +} + +type MatrixMonitorTestRuntimeOptions = Pick & { + matchesMentionPatterns?: (text: string, patterns: RegExp[]) => boolean; + saveMediaBuffer?: NonNullable["media"]>["saveMediaBuffer"]; +}; + +export function installMatrixMonitorTestRuntime( + options: MatrixMonitorTestRuntimeOptions = {}, +): void { + installMatrixTestRuntime({ + cfg: options.cfg, + stateDir: options.stateDir, + channel: { + mentions: { + buildMentionRegexes: () => [], + matchesMentionPatterns: + options.matchesMentionPatterns ?? + ((text: string, patterns: RegExp[]) => patterns.some((pattern) => pattern.test(text))), + matchesMentionWithExplicit: () => false, + }, + media: { + fetchRemoteMedia: vi.fn(), + saveMediaBuffer: options.saveMediaBuffer ?? vi.fn(), + }, + }, + }); +} diff --git a/extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts b/extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts index cefe06a19ee..756eea07bab 100644 --- a/extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts @@ -1,14 +1,14 @@ import "./monitor-inbox.test-harness.js"; import { describe, expect, it, vi } from "vitest"; -import { monitorWebInbox } from "./inbound.js"; import { - DEFAULT_ACCOUNT_ID, + buildNotifyMessageUpsert, expectPairingPromptSent, - getAuthDir, - getSock, installWebMonitorInboxUnitTestHooks, mockLoadConfig, + settleInboundWork, + startInboxMonitor, upsertPairingRequestMock, + waitForMessageCalls, } from "./monitor-inbox.test-harness.js"; const nowSeconds = (offsetMs = 0) => Math.floor((Date.now() + offsetMs) / 1000); @@ -29,26 +29,8 @@ function createAllowListConfig(allowFrom: string[]) { } async function openInboxMonitor(onMessage = vi.fn()) { - const listener = await monitorWebInbox({ - verbose: false, - accountId: DEFAULT_ACCOUNT_ID, - authDir: getAuthDir(), - onMessage, - }); - return { onMessage, listener, sock: getSock() }; -} - -async function settleInboundWork() { - await new Promise((resolve) => setTimeout(resolve, 25)); -} - -async function waitForMessageCalls(onMessage: ReturnType, count: number) { - await vi.waitFor( - () => { - expect(onMessage).toHaveBeenCalledTimes(count); - }, - { timeout: 2_000, interval: 5 }, - ); + const { listener, sock } = await startInboxMonitor(onMessage); + return { onMessage, listener, sock }; } async function expectOutboundDmSkipsPairing(params: { @@ -67,13 +49,7 @@ async function expectOutboundDmSkipsPairing(params: { }); const onMessage = vi.fn(); - const listener = await monitorWebInbox({ - verbose: false, - accountId: DEFAULT_ACCOUNT_ID, - authDir: getAuthDir(), - onMessage, - }); - const sock = getSock(); + const { listener, sock } = await startInboxMonitor(onMessage); try { sock.ev.emit("messages.upsert", { @@ -112,16 +88,12 @@ describe("web monitor inbox", () => { const { onMessage, listener, sock } = await openInboxMonitor(); - const upsert = { - type: "notify", - messages: [ - { - key: { id: "auth1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "authorized message" }, - messageTimestamp: nowSeconds(60_000), - }, - ], - }; + const upsert = buildNotifyMessageUpsert({ + id: "auth1", + remoteJid: "999@s.whatsapp.net", + text: "authorized message", + timestamp: nowSeconds(60_000), + }); sock.ev.emit("messages.upsert", upsert); await waitForMessageCalls(onMessage, 1); @@ -146,16 +118,12 @@ describe("web monitor inbox", () => { const { onMessage, listener, sock } = await openInboxMonitor(); // Message from self (sock.user.id is "123@s.whatsapp.net" in mock) - const upsert = { - type: "notify", - messages: [ - { - key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" }, - message: { conversation: "self message" }, - messageTimestamp: nowSeconds(60_000), - }, - ], - }; + const upsert = buildNotifyMessageUpsert({ + id: "self1", + remoteJid: "123@s.whatsapp.net", + text: "self message", + timestamp: nowSeconds(60_000), + }); sock.ev.emit("messages.upsert", upsert); await waitForMessageCalls(onMessage, 1); @@ -178,20 +146,12 @@ describe("web monitor inbox", () => { const { onMessage, listener, sock } = await openInboxMonitor(); // Message from someone else should be blocked - const upsertBlocked = { - type: "notify", - messages: [ - { - key: { - id: "no-config-1", - fromMe: false, - remoteJid: "999@s.whatsapp.net", - }, - message: { conversation: "ping" }, - messageTimestamp: nowSeconds(), - }, - ], - }; + const upsertBlocked = buildNotifyMessageUpsert({ + id: "no-config-1", + remoteJid: "999@s.whatsapp.net", + text: "ping", + timestamp: nowSeconds(), + }); sock.ev.emit("messages.upsert", upsertBlocked); await vi.waitFor( @@ -203,20 +163,12 @@ describe("web monitor inbox", () => { expect(onMessage).not.toHaveBeenCalled(); expectPairingPromptSent(sock, "999@s.whatsapp.net", "+999"); - const upsertBlockedAgain = { - type: "notify", - messages: [ - { - key: { - id: "no-config-1b", - fromMe: false, - remoteJid: "999@s.whatsapp.net", - }, - message: { conversation: "ping again" }, - messageTimestamp: nowSeconds(), - }, - ], - }; + const upsertBlockedAgain = buildNotifyMessageUpsert({ + id: "no-config-1b", + remoteJid: "999@s.whatsapp.net", + text: "ping again", + timestamp: nowSeconds(), + }); sock.ev.emit("messages.upsert", upsertBlockedAgain); await settleInboundWork(); @@ -224,20 +176,12 @@ describe("web monitor inbox", () => { expect(sock.sendMessage).toHaveBeenCalledTimes(1); // Message from self should be allowed - const upsertSelf = { - type: "notify", - messages: [ - { - key: { - id: "no-config-2", - fromMe: false, - remoteJid: "123@s.whatsapp.net", - }, - message: { conversation: "self ping" }, - messageTimestamp: nowSeconds(), - }, - ], - }; + const upsertSelf = buildNotifyMessageUpsert({ + id: "no-config-2", + remoteJid: "123@s.whatsapp.net", + text: "self ping", + timestamp: nowSeconds(), + }); sock.ev.emit("messages.upsert", upsertSelf); await waitForMessageCalls(onMessage, 1); @@ -312,13 +256,7 @@ describe("web monitor inbox", () => { }); it("normalizes participant phone numbers to JIDs in sendReaction", async () => { - const listener = await monitorWebInbox({ - verbose: false, - onMessage: vi.fn(), - accountId: DEFAULT_ACCOUNT_ID, - authDir: getAuthDir(), - }); - const sock = getSock(); + const { listener, sock } = await startInboxMonitor(vi.fn()); await listener.sendReaction("12345@g.us", "msg123", "👍", false, "+6421000000"); diff --git a/extensions/whatsapp/src/monitor-inbox.append-upsert.test.ts b/extensions/whatsapp/src/monitor-inbox.append-upsert.test.ts index 1ccdd3e77b2..af106a10a42 100644 --- a/extensions/whatsapp/src/monitor-inbox.append-upsert.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.append-upsert.test.ts @@ -1,39 +1,14 @@ import "./monitor-inbox.test-harness.js"; import { describe, expect, it, vi } from "vitest"; -import { monitorWebInbox } from "./inbound.js"; import { - DEFAULT_ACCOUNT_ID, - getAuthDir, - getSock, installWebMonitorInboxUnitTestHooks, + settleInboundWork, + startInboxMonitor, + waitForMessageCalls, } from "./monitor-inbox.test-harness.js"; describe("append upsert handling (#20952)", () => { installWebMonitorInboxUnitTestHooks(); - type InboxOnMessage = NonNullable[0]["onMessage"]>; - - async function settleInboundWork() { - await new Promise((resolve) => setTimeout(resolve, 25)); - } - - async function waitForMessageCalls(onMessage: ReturnType, count: number) { - await vi.waitFor( - () => { - expect(onMessage).toHaveBeenCalledTimes(count); - }, - { timeout: 2_000, interval: 5 }, - ); - } - - async function startInboxMonitor(onMessage: InboxOnMessage) { - const listener = await monitorWebInbox({ - verbose: false, - onMessage, - accountId: DEFAULT_ACCOUNT_ID, - authDir: getAuthDir(), - }); - return { listener, sock: getSock() }; - } it("processes recent append messages (within 60s of connect)", async () => { const onMessage = vi.fn(async () => {}); diff --git a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts index 9274abd0135..de57ecdae55 100644 --- a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts @@ -2,69 +2,24 @@ import fsSync from "node:fs"; import path from "node:path"; import "./monitor-inbox.test-harness.js"; import { describe, expect, it, vi } from "vitest"; -import { monitorWebInbox } from "./inbound.js"; import { - DEFAULT_ACCOUNT_ID, + InboxOnMessage, + buildNotifyMessageUpsert, getAuthDir, - getSock, installWebMonitorInboxUnitTestHooks, + startInboxMonitor, + waitForMessageCalls, } from "./monitor-inbox.test-harness.js"; describe("web monitor inbox", () => { installWebMonitorInboxUnitTestHooks(); - type InboxOnMessage = NonNullable[0]["onMessage"]>; - - async function waitForMessageCalls(onMessage: ReturnType, count: number) { - await vi.waitFor( - () => { - expect(onMessage).toHaveBeenCalledTimes(count); - }, - { timeout: 2_000, interval: 5 }, - ); - } - - async function startInboxMonitor(onMessage: InboxOnMessage) { - const listener = await monitorWebInbox({ - verbose: false, - onMessage, - accountId: DEFAULT_ACCOUNT_ID, - authDir: getAuthDir(), - }); - return { listener, sock: getSock() }; - } - - function buildMessageUpsert(params: { - id: string; - remoteJid: string; - text: string; - timestamp: number; - pushName?: string; - participant?: string; - }) { - return { - type: "notify", - messages: [ - { - key: { - id: params.id, - fromMe: false, - remoteJid: params.remoteJid, - participant: params.participant, - }, - message: { conversation: params.text }, - messageTimestamp: params.timestamp, - pushName: params.pushName, - }, - ], - }; - } async function expectQuotedReplyContext(quotedMessage: unknown) { const onMessage = vi.fn(async (msg) => { await msg.reply("pong"); }); - const { listener, sock } = await startInboxMonitor(onMessage); + const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); const upsert = { type: "notify", messages: [ @@ -109,9 +64,9 @@ describe("web monitor inbox", () => { await msg.reply("pong"); }); - const { listener, sock } = await startInboxMonitor(onMessage); + const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); - const upsert = buildMessageUpsert({ + const upsert = buildNotifyMessageUpsert({ id: "abc", remoteJid: "999@s.whatsapp.net", text: "ping", @@ -147,8 +102,8 @@ describe("web monitor inbox", () => { return; }); - const { listener, sock } = await startInboxMonitor(onMessage); - const upsert = buildMessageUpsert({ + const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); + const upsert = buildNotifyMessageUpsert({ id: "abc", remoteJid: "999@s.whatsapp.net", text: "ping", @@ -170,10 +125,10 @@ describe("web monitor inbox", () => { return; }); - const { listener, sock } = await startInboxMonitor(onMessage); + const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID"); sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce("999:0@s.whatsapp.net"); - const upsert = buildMessageUpsert({ + const upsert = buildNotifyMessageUpsert({ id: "abc", remoteJid: "999@lid", text: "ping", @@ -201,9 +156,9 @@ describe("web monitor inbox", () => { JSON.stringify("1555"), ); - const { listener, sock } = await startInboxMonitor(onMessage); + const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID"); - const upsert = buildMessageUpsert({ + const upsert = buildNotifyMessageUpsert({ id: "abc", remoteJid: "555@lid", text: "ping", @@ -227,10 +182,10 @@ describe("web monitor inbox", () => { return; }); - const { listener, sock } = await startInboxMonitor(onMessage); + const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID"); sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce("444:0@s.whatsapp.net"); - const upsert = buildMessageUpsert({ + const upsert = buildNotifyMessageUpsert({ id: "abc", remoteJid: "123@g.us", participant: "444@lid", @@ -264,7 +219,7 @@ describe("web monitor inbox", () => { } }); - const { listener, sock } = await startInboxMonitor(onMessage); + const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); const upsert = { type: "notify", messages: [ diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index 719602b57eb..ea7ed502d57 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { afterEach, beforeEach, expect, vi } from "vitest"; +import { monitorWebInbox } from "./inbound.js"; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // oxlint-disable-next-line typescript/no-explicit-any @@ -47,6 +48,10 @@ export type MockSock = { user: { id: string }; }; +const sessionState = vi.hoisted(() => ({ + sock: undefined as MockSock | undefined, +})); + function createResolvedMock() { return vi.fn().mockResolvedValue(undefined); } @@ -71,6 +76,7 @@ function createMockSock(): MockSock { } const sock: MockSock = createMockSock(); +sessionState.sock = sock; vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { const actual = await importOriginal(); @@ -117,7 +123,12 @@ vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { }); vi.mock("./session.js", () => ({ - createWaSocket: vi.fn().mockResolvedValue(sock), + createWaSocket: vi.fn().mockImplementation(async () => { + if (!sessionState.sock) { + throw new Error("mock WhatsApp socket not initialized"); + } + return sessionState.sock; + }), waitForWaConnection: vi.fn().mockResolvedValue(undefined), getStatusCode: vi.fn(() => 500), })); @@ -126,6 +137,57 @@ export function getSock(): MockSock { return sock; } +export type InboxOnMessage = NonNullable[0]["onMessage"]>; + +export async function settleInboundWork() { + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +export async function waitForMessageCalls(onMessage: ReturnType, count: number) { + await vi.waitFor( + () => { + expect(onMessage).toHaveBeenCalledTimes(count); + }, + { timeout: 2_000, interval: 5 }, + ); +} + +export async function startInboxMonitor(onMessage: InboxOnMessage) { + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + accountId: DEFAULT_ACCOUNT_ID, + authDir: getAuthDir(), + }); + return { listener, sock: getSock() }; +} + +export function buildNotifyMessageUpsert(params: { + id: string; + remoteJid: string; + text: string; + timestamp: number; + pushName?: string; + participant?: string; +}) { + return { + type: "notify", + messages: [ + { + key: { + id: params.id, + fromMe: false, + remoteJid: params.remoteJid, + participant: params.participant, + }, + message: { conversation: params.text }, + messageTimestamp: params.timestamp, + pushName: params.pushName, + }, + ], + }; +} + export function expectPairingPromptSent(sock: MockSock, jid: string, senderE164: string) { expect(sock.sendMessage).toHaveBeenCalledTimes(1); expect(sock.sendMessage).toHaveBeenCalledWith(jid, { diff --git a/extensions/zalo/src/monitor.image.polling.test.ts b/extensions/zalo/src/monitor.image.polling.test.ts index 1ba44cbe306..84ffd5c1cac 100644 --- a/extensions/zalo/src/monitor.image.polling.test.ts +++ b/extensions/zalo/src/monitor.image.polling.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import type { ResolvedZaloAccount } from "./accounts.js"; @@ -48,13 +49,6 @@ const TEST_CONFIG = { }, } as OpenClawConfig; -function createRuntimeEnv() { - return { - log: vi.fn<(message: string) => void>(), - error: vi.fn<(message: string) => void>(), - }; -} - describe("Zalo polling image handling", () => { const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); const recordInboundSessionMock = vi.fn(async () => undefined); diff --git a/extensions/zalo/src/monitor.lifecycle.test.ts b/extensions/zalo/src/monitor.lifecycle.test.ts index f0a5f1eefcb..96ffc0daad3 100644 --- a/extensions/zalo/src/monitor.lifecycle.test.ts +++ b/extensions/zalo/src/monitor.lifecycle.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { OpenClawConfig } from "../runtime-api.js"; import type { ResolvedZaloAccount } from "./accounts.js"; @@ -39,13 +40,6 @@ const TEST_ACCOUNT = { const TEST_CONFIG = {} as OpenClawConfig; -function createLifecycleRuntime() { - return { - log: vi.fn<(message: string) => void>(), - error: vi.fn<(message: string) => void>(), - }; -} - async function startLifecycleMonitor( options: { useWebhook?: boolean; @@ -55,7 +49,7 @@ async function startLifecycleMonitor( ) { const { monitorZaloProvider } = await import("./monitor.js"); const abort = new AbortController(); - const runtime = createLifecycleRuntime(); + const runtime = createRuntimeEnv(); const run = monitorZaloProvider({ token: "test-token", account: TEST_ACCOUNT, diff --git a/extensions/zalo/src/monitor.pairing.lifecycle.test.ts b/extensions/zalo/src/monitor.pairing.lifecycle.test.ts index 383474114cf..b9c3f563c93 100644 --- a/extensions/zalo/src/monitor.pairing.lifecycle.test.ts +++ b/extensions/zalo/src/monitor.pairing.lifecycle.test.ts @@ -1,141 +1,24 @@ -import { createServer, type RequestListener } from "node:http"; -import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; -import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; -import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; -import type { ResolvedZaloAccount } from "./accounts.js"; -import { clearZaloWebhookSecurityStateForTest, monitorZaloProvider } from "./monitor.js"; - -const setWebhookMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, result: { url: "" } }))); -const deleteWebhookMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, result: { url: "" } }))); -const getWebhookInfoMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, result: { url: "" } }))); -const getUpdatesMock = vi.hoisted(() => vi.fn(() => new Promise(() => {}))); -const sendChatActionMock = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); -const sendMessageMock = vi.hoisted(() => - vi.fn(async () => ({ ok: true, result: { message_id: "pairing-zalo-1" } })), -); -const sendPhotoMock = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); -const getZaloRuntimeMock = vi.hoisted(() => vi.fn()); - -vi.mock("./api.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - deleteWebhook: deleteWebhookMock, - getUpdates: getUpdatesMock, - getWebhookInfo: getWebhookInfoMock, - sendChatAction: sendChatActionMock, - sendMessage: sendMessageMock, - sendPhoto: sendPhotoMock, - setWebhook: setWebhookMock, - }; -}); - -vi.mock("./runtime.js", () => ({ - getZaloRuntime: getZaloRuntimeMock, -})); - -async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise) { - const server = createServer(handler); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address() as AddressInfo | null; - if (!address) { - throw new Error("missing server address"); - } - try { - await fn(`http://127.0.0.1:${address.port}`); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } -} - -function createLifecycleConfig(): OpenClawConfig { - return { - channels: { - zalo: { - enabled: true, - accounts: { - "acct-zalo-pairing": { - enabled: true, - webhookUrl: "https://example.com/hooks/zalo", - webhookSecret: "supersecret", // pragma: allowlist secret - dmPolicy: "pairing", - allowFrom: [], - }, - }, - }, - }, - } as OpenClawConfig; -} - -function createLifecycleAccount(): ResolvedZaloAccount { - return { - accountId: "acct-zalo-pairing", - enabled: true, - token: "zalo-token", - tokenSource: "config", - config: { - webhookUrl: "https://example.com/hooks/zalo", - webhookSecret: "supersecret", // pragma: allowlist secret - dmPolicy: "pairing", - allowFrom: [], - }, - } as ResolvedZaloAccount; -} - -function createRuntimeEnv() { - return { - log: vi.fn<(message: string) => void>(), - error: vi.fn<(message: string) => void>(), - }; -} - -function createTextUpdate(messageId: string) { - return { - event_name: "message.text.received", - message: { - from: { id: "user-unauthorized", name: "Unauthorized User" }, - chat: { id: "dm-pairing-1", chat_type: "PRIVATE" as const }, - message_id: messageId, - date: Math.floor(Date.now() / 1000), - text: "hello from zalo", - }, - }; -} - -async function settleAsyncWork(): Promise { - for (let i = 0; i < 6; i += 1) { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} - -async function postWebhookUpdate(params: { - baseUrl: string; - path: string; - secret: string; - payload: Record; -}) { - return await fetch(`${params.baseUrl}${params.path}`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-bot-api-secret-token": params.secret, - }, - body: JSON.stringify(params.payload), - }); -} +import { + createLifecycleAccount, + createLifecycleConfig, + createTextUpdate, + getZaloRuntimeMock, + postWebhookUpdate, + resetLifecycleTestState, + sendMessageMock, + settleAsyncWork, + startWebhookLifecycleMonitor, +} from "../../../test/helpers/extensions/zalo-lifecycle.js"; +import { withServer } from "../../../test/helpers/http-test-server.js"; +import type { PluginRuntime } from "../runtime-api.js"; describe("Zalo pairing lifecycle", () => { const readAllowFromStoreMock = vi.fn(async () => [] as string[]); const upsertPairingRequestMock = vi.fn(async () => ({ code: "PAIRCODE", created: true })); beforeEach(() => { - vi.clearAllMocks(); - clearZaloWebhookSecurityStateForTest(); + resetLifecycleTestState(); getZaloRuntimeMock.mockReturnValue( createPluginRuntimeMock({ @@ -156,38 +39,32 @@ describe("Zalo pairing lifecycle", () => { }); afterEach(() => { - setActivePluginRegistry(createEmptyPluginRegistry()); + resetLifecycleTestState(); }); it("emits one pairing reply across duplicate webhook replay and scopes reads and writes to accountId", async () => { - const registry = createEmptyPluginRegistry(); - setActivePluginRegistry(registry); - const abort = new AbortController(); - const runtime = createRuntimeEnv(); - const run = monitorZaloProvider({ - token: "zalo-token", - account: createLifecycleAccount(), - config: createLifecycleConfig(), - runtime, - abortSignal: abort.signal, - useWebhook: true, - webhookUrl: "https://example.com/hooks/zalo", - webhookSecret: "supersecret", + const { abort, route, run } = await startWebhookLifecycleMonitor({ + account: createLifecycleAccount({ + accountId: "acct-zalo-pairing", + dmPolicy: "pairing", + allowFrom: [], + }), + config: createLifecycleConfig({ + accountId: "acct-zalo-pairing", + dmPolicy: "pairing", + allowFrom: [], + }), }); - await vi.waitFor(() => { - expect(setWebhookMock).toHaveBeenCalledTimes(1); - expect(registry.httpRoutes).toHaveLength(1); - }); - const route = registry.httpRoutes[0]; - if (!route) { - throw new Error("missing plugin HTTP route"); - } - await withServer( (req, res) => route.handler(req, res), async (baseUrl) => { - const payload = createTextUpdate(`zalo-pairing-${Date.now()}`); + const payload = createTextUpdate({ + messageId: `zalo-pairing-${Date.now()}`, + userId: "user-unauthorized", + userName: "Unauthorized User", + chatId: "dm-pairing-1", + }); const first = await postWebhookUpdate({ baseUrl, path: "/hooks/zalo", @@ -239,34 +116,28 @@ describe("Zalo pairing lifecycle", () => { it("does not emit a second pairing reply when replay arrives after the first send fails", async () => { sendMessageMock.mockRejectedValueOnce(new Error("pairing send failed")); - const registry = createEmptyPluginRegistry(); - setActivePluginRegistry(registry); - const abort = new AbortController(); - const runtime = createRuntimeEnv(); - const run = monitorZaloProvider({ - token: "zalo-token", - account: createLifecycleAccount(), - config: createLifecycleConfig(), - runtime, - abortSignal: abort.signal, - useWebhook: true, - webhookUrl: "https://example.com/hooks/zalo", - webhookSecret: "supersecret", + const { abort, route, run, runtime } = await startWebhookLifecycleMonitor({ + account: createLifecycleAccount({ + accountId: "acct-zalo-pairing", + dmPolicy: "pairing", + allowFrom: [], + }), + config: createLifecycleConfig({ + accountId: "acct-zalo-pairing", + dmPolicy: "pairing", + allowFrom: [], + }), }); - await vi.waitFor(() => { - expect(setWebhookMock).toHaveBeenCalledTimes(1); - expect(registry.httpRoutes).toHaveLength(1); - }); - const route = registry.httpRoutes[0]; - if (!route) { - throw new Error("missing plugin HTTP route"); - } - await withServer( (req, res) => route.handler(req, res), async (baseUrl) => { - const payload = createTextUpdate(`zalo-pairing-retry-${Date.now()}`); + const payload = createTextUpdate({ + messageId: `zalo-pairing-retry-${Date.now()}`, + userId: "user-unauthorized", + userName: "Unauthorized User", + chatId: "dm-pairing-1", + }); const first = await postWebhookUpdate({ baseUrl, path: "/hooks/zalo", diff --git a/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts b/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts index 8b16869c0a2..9b1820832c0 100644 --- a/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts +++ b/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts @@ -1,132 +1,18 @@ -import { createServer, type RequestListener } from "node:http"; -import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; -import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; -import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; -import type { ResolvedZaloAccount } from "./accounts.js"; -import { clearZaloWebhookSecurityStateForTest, monitorZaloProvider } from "./monitor.js"; - -const setWebhookMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, result: { url: "" } }))); -const deleteWebhookMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, result: { url: "" } }))); -const getWebhookInfoMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, result: { url: "" } }))); -const getUpdatesMock = vi.hoisted(() => vi.fn(() => new Promise(() => {}))); -const sendChatActionMock = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); -const sendMessageMock = vi.hoisted(() => - vi.fn(async () => ({ ok: true, result: { message_id: "reply-zalo-1" } })), -); -const sendPhotoMock = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); -const getZaloRuntimeMock = vi.hoisted(() => vi.fn()); - -vi.mock("./api.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - deleteWebhook: deleteWebhookMock, - getUpdates: getUpdatesMock, - getWebhookInfo: getWebhookInfoMock, - sendChatAction: sendChatActionMock, - sendMessage: sendMessageMock, - sendPhoto: sendPhotoMock, - setWebhook: setWebhookMock, - }; -}); - -vi.mock("./runtime.js", () => ({ - getZaloRuntime: getZaloRuntimeMock, -})); - -async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise) { - const server = createServer(handler); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address() as AddressInfo | null; - if (!address) { - throw new Error("missing server address"); - } - try { - await fn(`http://127.0.0.1:${address.port}`); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } -} - -function createLifecycleConfig(): OpenClawConfig { - return { - channels: { - zalo: { - enabled: true, - accounts: { - "acct-zalo-lifecycle": { - enabled: true, - webhookUrl: "https://example.com/hooks/zalo", - webhookSecret: "supersecret", // pragma: allowlist secret - dmPolicy: "open", - }, - }, - }, - }, - } as OpenClawConfig; -} - -function createLifecycleAccount(): ResolvedZaloAccount { - return { - accountId: "acct-zalo-lifecycle", - enabled: true, - token: "zalo-token", - tokenSource: "config", - config: { - webhookUrl: "https://example.com/hooks/zalo", - webhookSecret: "supersecret", // pragma: allowlist secret - dmPolicy: "open", - }, - } as ResolvedZaloAccount; -} - -function createRuntimeEnv() { - return { - log: vi.fn<(message: string) => void>(), - error: vi.fn<(message: string) => void>(), - }; -} - -function createTextUpdate(messageId: string) { - return { - event_name: "message.text.received", - message: { - from: { id: "user-1", name: "User One" }, - chat: { id: "dm-chat-1", chat_type: "PRIVATE" as const }, - message_id: messageId, - date: Math.floor(Date.now() / 1000), - text: "hello from zalo", - }, - }; -} - -async function settleAsyncWork(): Promise { - for (let i = 0; i < 6; i += 1) { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} - -async function postWebhookUpdate(params: { - baseUrl: string; - path: string; - secret: string; - payload: Record; -}) { - return await fetch(`${params.baseUrl}${params.path}`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-bot-api-secret-token": params.secret, - }, - body: JSON.stringify(params.payload), - }); -} +import { + createLifecycleAccount, + createLifecycleConfig, + createTextUpdate, + getZaloRuntimeMock, + postWebhookUpdate, + resetLifecycleTestState, + sendMessageMock, + settleAsyncWork, + startWebhookLifecycleMonitor, +} from "../../../test/helpers/extensions/zalo-lifecycle.js"; +import { withServer } from "../../../test/helpers/http-test-server.js"; +import type { PluginRuntime } from "../runtime-api.js"; describe("Zalo reply-once lifecycle", () => { const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); @@ -142,8 +28,7 @@ describe("Zalo reply-once lifecycle", () => { const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn(); beforeEach(() => { - vi.clearAllMocks(); - clearZaloWebhookSecurityStateForTest(); + resetLifecycleTestState(); getZaloRuntimeMock.mockReturnValue( createPluginRuntimeMock({ @@ -168,7 +53,7 @@ describe("Zalo reply-once lifecycle", () => { }); afterEach(() => { - setActivePluginRegistry(createEmptyPluginRegistry()); + resetLifecycleTestState(); }); it("routes one accepted webhook event to one visible reply across duplicate replay", async () => { @@ -178,32 +63,26 @@ describe("Zalo reply-once lifecycle", () => { }, ); - const registry = createEmptyPluginRegistry(); - setActivePluginRegistry(registry); - const abort = new AbortController(); - const runtime = createRuntimeEnv(); - const run = monitorZaloProvider({ - token: "zalo-token", - account: createLifecycleAccount(), - config: createLifecycleConfig(), - runtime, - abortSignal: abort.signal, - useWebhook: true, - webhookUrl: "https://example.com/hooks/zalo", - webhookSecret: "supersecret", + const { abort, route, run } = await startWebhookLifecycleMonitor({ + account: createLifecycleAccount({ + accountId: "acct-zalo-lifecycle", + dmPolicy: "open", + }), + config: createLifecycleConfig({ + accountId: "acct-zalo-lifecycle", + dmPolicy: "open", + }), }); - await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1)); - expect(registry.httpRoutes).toHaveLength(1); - const route = registry.httpRoutes[0]; - if (!route) { - throw new Error("missing plugin HTTP route"); - } - await withServer( (req, res) => route.handler(req, res), async (baseUrl) => { - const payload = createTextUpdate(`zalo-replay-${Date.now()}`); + const payload = createTextUpdate({ + messageId: `zalo-replay-${Date.now()}`, + userId: "user-1", + userName: "User One", + chatId: "dm-chat-1", + }); const first = await postWebhookUpdate({ baseUrl, path: "/hooks/zalo", @@ -265,31 +144,26 @@ describe("Zalo reply-once lifecycle", () => { }, ); - const registry = createEmptyPluginRegistry(); - setActivePluginRegistry(registry); - const abort = new AbortController(); - const runtime = createRuntimeEnv(); - const run = monitorZaloProvider({ - token: "zalo-token", - account: createLifecycleAccount(), - config: createLifecycleConfig(), - runtime, - abortSignal: abort.signal, - useWebhook: true, - webhookUrl: "https://example.com/hooks/zalo", - webhookSecret: "supersecret", + const { abort, route, run, runtime } = await startWebhookLifecycleMonitor({ + account: createLifecycleAccount({ + accountId: "acct-zalo-lifecycle", + dmPolicy: "open", + }), + config: createLifecycleConfig({ + accountId: "acct-zalo-lifecycle", + dmPolicy: "open", + }), }); - await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1)); - const route = registry.httpRoutes[0]; - if (!route) { - throw new Error("missing plugin HTTP route"); - } - await withServer( (req, res) => route.handler(req, res), async (baseUrl) => { - const payload = createTextUpdate(`zalo-retry-${Date.now()}`); + const payload = createTextUpdate({ + messageId: `zalo-retry-${Date.now()}`, + userId: "user-1", + userName: "User One", + chatId: "dm-chat-1", + }); const first = await postWebhookUpdate({ baseUrl, path: "/hooks/zalo", diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 318aff6a64e..d1cca9bb2e9 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -1,9 +1,9 @@ -import { createServer, type RequestListener } from "node:http"; -import type { AddressInfo } from "node:net"; +import type { RequestListener } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import { withServer } from "../../../test/helpers/http-test-server.js"; import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import { clearZaloWebhookSecurityStateForTest, @@ -14,22 +14,6 @@ import { } from "./monitor.js"; import type { ResolvedZaloAccount } from "./types.js"; -async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise) { - const server = createServer(handler); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address() as AddressInfo | null; - if (!address) { - throw new Error("missing server address"); - } - try { - await fn(`http://127.0.0.1:${address.port}`); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } -} - const DEFAULT_ACCOUNT: ResolvedZaloAccount = { accountId: "default", enabled: true, diff --git a/scripts/check-file-utils.ts b/scripts/check-file-utils.ts new file mode 100644 index 00000000000..88b20e73ad7 --- /dev/null +++ b/scripts/check-file-utils.ts @@ -0,0 +1,55 @@ +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_SKIPPED_DIR_NAMES = new Set(["node_modules", "dist", "coverage"]); + +export function isCodeFile(filePath: string): boolean { + if (filePath.endsWith(".d.ts")) { + return false; + } + return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath); +} + +export function collectFilesSync( + rootDir: string, + options: { + includeFile: (filePath: string) => boolean; + skipDirNames?: ReadonlySet; + }, +): string[] { + const skipDirNames = options.skipDirNames ?? DEFAULT_SKIPPED_DIR_NAMES; + const files: string[] = []; + const stack = [rootDir]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (skipDirNames.has(entry.name)) { + continue; + } + stack.push(fullPath); + continue; + } + if (entry.isFile() && options.includeFile(fullPath)) { + files.push(fullPath); + } + } + } + + return files; +} + +export function relativeToCwd(filePath: string): string { + return path.relative(process.cwd(), filePath) || filePath; +} diff --git a/scripts/check-no-extension-src-imports.ts b/scripts/check-no-extension-src-imports.ts index 04f4d074dcf..2204016045f 100644 --- a/scripts/check-no-extension-src-imports.ts +++ b/scripts/check-no-extension-src-imports.ts @@ -1,15 +1,9 @@ import fs from "node:fs"; import path from "node:path"; +import { collectFilesSync, isCodeFile, relativeToCwd } from "./check-file-utils.js"; const FORBIDDEN_REPO_SRC_IMPORT = /["'](?:\.\.\/)+(?:src\/)[^"']+["']/; -function isSourceFile(filePath: string): boolean { - if (filePath.endsWith(".d.ts")) { - return false; - } - return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath); -} - function isProductionExtensionFile(filePath: string): boolean { return !( filePath.endsWith("/runtime-api.ts") || @@ -28,34 +22,9 @@ function isProductionExtensionFile(filePath: string): boolean { } function collectExtensionSourceFiles(rootDir: string): string[] { - const files: string[] = []; - const stack = [rootDir]; - while (stack.length > 0) { - const current = stack.pop(); - if (!current) { - continue; - } - let entries: fs.Dirent[] = []; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { - continue; - } - stack.push(fullPath); - continue; - } - if (entry.isFile() && isSourceFile(fullPath) && isProductionExtensionFile(fullPath)) { - files.push(fullPath); - } - } - } - return files; + return collectFilesSync(rootDir, { + includeFile: (filePath) => isCodeFile(filePath) && isProductionExtensionFile(filePath), + }); } function main() { @@ -73,8 +42,7 @@ function main() { if (offenders.length > 0) { console.error("Production extension files must not import the repo src/ tree directly."); for (const offender of offenders.toSorted()) { - const relative = path.relative(process.cwd(), offender) || offender; - console.error(`- ${relative}`); + console.error(`- ${relativeToCwd(offender)}`); } console.error( "Publish a focused openclaw/plugin-sdk/ surface or use the extension's own public barrel instead.", diff --git a/scripts/check-no-extension-test-core-imports.ts b/scripts/check-no-extension-test-core-imports.ts index af65c8387a9..252d356479d 100644 --- a/scripts/check-no-extension-test-core-imports.ts +++ b/scripts/check-no-extension-test-core-imports.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { collectFilesSync, relativeToCwd } from "./check-file-utils.js"; const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [ { @@ -33,34 +34,9 @@ function isExtensionTestFile(filePath: string): boolean { } function collectExtensionTestFiles(rootDir: string): string[] { - const files: string[] = []; - const stack = [rootDir]; - while (stack.length > 0) { - const current = stack.pop(); - if (!current) { - continue; - } - let entries: fs.Dirent[] = []; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { - continue; - } - stack.push(fullPath); - continue; - } - if (entry.isFile() && isExtensionTestFile(fullPath)) { - files.push(fullPath); - } - } - } - return files; + return collectFilesSync(rootDir, { + includeFile: (filePath) => isExtensionTestFile(filePath), + }); } function main() { @@ -84,8 +60,7 @@ function main() { "Extension test files must stay on extension test bridges or public plugin-sdk surfaces.", ); for (const offender of offenders.toSorted((a, b) => a.file.localeCompare(b.file))) { - const relative = path.relative(process.cwd(), offender.file) || offender.file; - console.error(`- ${relative}: ${offender.hint}`); + console.error(`- ${relativeToCwd(offender.file)}: ${offender.hint}`); } process.exit(1); } diff --git a/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts b/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts index bc24087ace3..09c0d829aae 100644 --- a/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts +++ b/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { discoverOpenClawPlugins } from "../src/plugins/discovery.js"; +import { collectFilesSync, isCodeFile, relativeToCwd } from "./check-file-utils.js"; // Match exact monolithic-root specifier in any code path: // imports/exports, require/dynamic import, and test mocks (vi.mock/jest.mock). @@ -15,53 +16,15 @@ function hasLegacyCompatImport(content: string): boolean { return LEGACY_COMPAT_IMPORT_PATTERN.test(content); } -function isSourceFile(filePath: string): boolean { - if (filePath.endsWith(".d.ts")) { - return false; - } - return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath); -} - function collectPluginSourceFiles(rootDir: string): string[] { const srcDir = path.join(rootDir, "src"); if (!fs.existsSync(srcDir)) { return []; } - - const files: string[] = []; - const stack: string[] = [srcDir]; - while (stack.length > 0) { - const current = stack.pop(); - if (!current) { - continue; - } - let entries: fs.Dirent[] = []; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if ( - entry.name === "node_modules" || - entry.name === "dist" || - entry.name === ".git" || - entry.name === "coverage" - ) { - continue; - } - stack.push(fullPath); - continue; - } - if (entry.isFile() && isSourceFile(fullPath)) { - files.push(fullPath); - } - } - } - - return files; + return collectFilesSync(srcDir, { + includeFile: (filePath) => isCodeFile(filePath), + skipDirNames: new Set(["node_modules", "dist", ".git", "coverage"]), + }); } function collectSharedExtensionSourceFiles(): string[] { @@ -127,8 +90,7 @@ function main() { if (monolithicOffenders.length > 0) { console.error("Bundled plugin source files must not import monolithic openclaw/plugin-sdk."); for (const file of monolithicOffenders.toSorted()) { - const relative = path.relative(process.cwd(), file) || file; - console.error(`- ${relative}`); + console.error(`- ${relativeToCwd(file)}`); } } if (legacyCompatOffenders.length > 0) { @@ -136,8 +98,7 @@ function main() { "Bundled plugin source files must not import legacy openclaw/plugin-sdk/compat.", ); for (const file of legacyCompatOffenders.toSorted()) { - const relative = path.relative(process.cwd(), file) || file; - console.error(`- ${relative}`); + console.error(`- ${relativeToCwd(file)}`); } } if (monolithicOffenders.length > 0 || legacyCompatOffenders.length > 0) { diff --git a/scripts/test-hotspots.mjs b/scripts/test-hotspots.mjs index 82e7de87b17..66d56c11cd8 100644 --- a/scripts/test-hotspots.mjs +++ b/scripts/test-hotspots.mjs @@ -1,7 +1,8 @@ -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { + collectVitestFileDurations, + readJsonFile, + runVitestJsonReport, +} from "./test-report-utils.mjs"; function parseArgs(argv) { const args = { @@ -38,37 +39,15 @@ function formatMs(value) { } const opts = parseArgs(process.argv.slice(2)); -const reportPath = - opts.reportPath || path.join(os.tmpdir(), `openclaw-vitest-hotspots-${Date.now()}.json`); - -if (!(opts.reportPath && fs.existsSync(reportPath))) { - const run = spawnSync( - "pnpm", - ["vitest", "run", "--config", opts.config, "--reporter=json", "--outputFile", reportPath], - { - stdio: "inherit", - env: process.env, - }, - ); - - if (run.status !== 0) { - process.exit(run.status ?? 1); - } -} - -const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); -const fileResults = (report.testResults ?? []) - .map((result) => { - const start = typeof result.startTime === "number" ? result.startTime : 0; - const end = typeof result.endTime === "number" ? result.endTime : 0; - const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0; - return { - file: typeof result.name === "string" ? result.name : "unknown", - durationMs: Math.max(0, end - start), - testCount, - }; - }) - .toSorted((a, b) => b.durationMs - a.durationMs); +const reportPath = runVitestJsonReport({ + config: opts.config, + reportPath: opts.reportPath, + prefix: "openclaw-vitest-hotspots", +}); +const report = readJsonFile(reportPath); +const fileResults = collectVitestFileDurations(report).toSorted( + (a, b) => b.durationMs - a.durationMs, +); const top = fileResults.slice(0, opts.limit); const totalDurationMs = fileResults.reduce((sum, item) => sum + item.durationMs, 0); diff --git a/scripts/test-perf-budget.mjs b/scripts/test-perf-budget.mjs index 44f73ffd2c4..49aead698c4 100644 --- a/scripts/test-perf-budget.mjs +++ b/scripts/test-perf-budget.mjs @@ -1,7 +1,4 @@ -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { readJsonFile, runVitestJsonReport } from "./test-report-utils.mjs"; function readEnvNumber(name) { const raw = process.env[name]?.trim(); @@ -59,32 +56,17 @@ function formatMs(ms) { } const opts = parseArgs(process.argv.slice(2)); -const reportPath = path.join(os.tmpdir(), `openclaw-vitest-perf-${Date.now()}.json`); -const cmd = [ - "vitest", - "run", - "--config", - opts.config, - "--reporter=json", - "--outputFile", - reportPath, -]; - const startedAt = process.hrtime.bigint(); -const run = spawnSync("pnpm", cmd, { - stdio: "inherit", - env: process.env, +const reportPath = runVitestJsonReport({ + config: opts.config, + prefix: "openclaw-vitest-perf", }); const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; -if (run.status !== 0) { - process.exit(run.status ?? 1); -} - let totalFileDurationMs = 0; let fileCount = 0; try { - const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); + const report = readJsonFile(reportPath); for (const result of report.testResults ?? []) { if (typeof result.startTime === "number" && typeof result.endTime === "number") { totalFileDurationMs += Math.max(0, result.endTime - result.startTime); diff --git a/scripts/test-report-utils.mjs b/scripts/test-report-utils.mjs new file mode 100644 index 00000000000..af4c5d6dbdc --- /dev/null +++ b/scripts/test-report-utils.mjs @@ -0,0 +1,75 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const normalizeRepoPath = (value) => value.split(path.sep).join("/"); +const repoRoot = path.resolve(process.cwd()); + +export function normalizeTrackedRepoPath(value) { + const normalizedValue = typeof value === "string" ? value : String(value ?? ""); + const repoRelative = path.isAbsolute(normalizedValue) + ? path.relative(repoRoot, path.resolve(normalizedValue)) + : normalizedValue; + if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { + return normalizeRepoPath(normalizedValue); + } + return normalizeRepoPath(repoRelative); +} + +export function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +export function tryReadJsonFile(filePath, fallback) { + try { + return readJsonFile(filePath); + } catch { + return fallback; + } +} + +export function writeJsonFile(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +export function runVitestJsonReport({ + config, + reportPath = "", + prefix = "openclaw-vitest-report", +}) { + const resolvedReportPath = reportPath || path.join(os.tmpdir(), `${prefix}-${Date.now()}.json`); + + if (!(reportPath && fs.existsSync(resolvedReportPath))) { + const run = spawnSync( + "pnpm", + ["vitest", "run", "--config", config, "--reporter=json", "--outputFile", resolvedReportPath], + { + stdio: "inherit", + env: process.env, + }, + ); + + if (run.status !== 0) { + process.exit(run.status ?? 1); + } + } + + return resolvedReportPath; +} + +export function collectVitestFileDurations(report, normalizeFile = (value) => value) { + return (report.testResults ?? []) + .map((result) => { + const file = typeof result.name === "string" ? normalizeFile(result.name) : ""; + const start = typeof result.startTime === "number" ? result.startTime : 0; + const end = typeof result.endTime === "number" ? result.endTime : 0; + const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0; + return { + file, + durationMs: Math.max(0, end - start), + testCount, + }; + }) + .filter((entry) => entry.file.length > 0 && entry.durationMs > 0); +} diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs index ce34d28c59b..fe149839679 100644 --- a/scripts/test-runner-manifest.mjs +++ b/scripts/test-runner-manifest.mjs @@ -1,5 +1,4 @@ -import fs from "node:fs"; -import path from "node:path"; +import { normalizeTrackedRepoPath, tryReadJsonFile } from "./test-report-utils.mjs"; export const behaviorManifestPath = "test/fixtures/test-parallel.behavior.json"; export const unitTimingManifestPath = "test/fixtures/test-timings.unit.json"; @@ -16,27 +15,6 @@ const defaultMemoryHotspotManifest = { files: {}, }; -const readJson = (filePath, fallback) => { - try { - return JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { - return fallback; - } -}; - -const normalizeRepoPath = (value) => value.split(path.sep).join("/"); -const repoRoot = path.resolve(process.cwd()); -const normalizeTrackedRepoPath = (value) => { - const normalizedValue = typeof value === "string" ? value : String(value ?? ""); - const repoRelative = path.isAbsolute(normalizedValue) - ? path.relative(repoRoot, path.resolve(normalizedValue)) - : normalizedValue; - if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { - return normalizeRepoPath(normalizedValue); - } - return normalizeRepoPath(repoRelative); -}; - const normalizeManifestEntries = (entries) => entries .map((entry) => @@ -50,7 +28,7 @@ const normalizeManifestEntries = (entries) => .filter((entry) => entry.file.length > 0); export function loadTestRunnerBehavior() { - const raw = readJson(behaviorManifestPath, {}); + const raw = tryReadJsonFile(behaviorManifestPath, {}); const unit = raw.unit ?? {}; return { unit: { @@ -63,7 +41,7 @@ export function loadTestRunnerBehavior() { } export function loadUnitTimingManifest() { - const raw = readJson(unitTimingManifestPath, defaultTimingManifest); + const raw = tryReadJsonFile(unitTimingManifestPath, defaultTimingManifest); const defaultDurationMs = Number.isFinite(raw.defaultDurationMs) && raw.defaultDurationMs > 0 ? raw.defaultDurationMs @@ -100,7 +78,7 @@ export function loadUnitTimingManifest() { } export function loadUnitMemoryHotspotManifest() { - const raw = readJson(unitMemoryHotspotManifestPath, defaultMemoryHotspotManifest); + const raw = tryReadJsonFile(unitMemoryHotspotManifestPath, defaultMemoryHotspotManifest); const defaultMinDeltaKb = Number.isFinite(raw.defaultMinDeltaKb) && raw.defaultMinDeltaKb > 0 ? raw.defaultMinDeltaKb diff --git a/scripts/test-update-memory-hotspots.mjs b/scripts/test-update-memory-hotspots.mjs index af4cb7c624c..e873f637d80 100644 --- a/scripts/test-update-memory-hotspots.mjs +++ b/scripts/test-update-memory-hotspots.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { parseMemoryTraceSummaryLines } from "./test-parallel-memory.mjs"; +import { normalizeTrackedRepoPath, tryReadJsonFile, writeJsonFile } from "./test-report-utils.mjs"; import { unitMemoryHotspotManifestPath } from "./test-runner-manifest.mjs"; function parseArgs(argv) { @@ -57,19 +58,6 @@ function parseArgs(argv) { return args; } -const normalizeRepoPath = (value) => value.split(path.sep).join("/"); -const repoRoot = path.resolve(process.cwd()); -const normalizeTrackedRepoPath = (value) => { - const normalizedValue = typeof value === "string" ? value : String(value ?? ""); - const repoRelative = path.isAbsolute(normalizedValue) - ? path.relative(repoRoot, path.resolve(normalizedValue)) - : normalizedValue; - if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { - return normalizeRepoPath(normalizedValue); - } - return normalizeRepoPath(repoRelative); -}; - function mergeHotspotEntry(aggregated, file, value) { if (!(Number.isFinite(value?.deltaKb) && value.deltaKb > 0)) { return; @@ -113,13 +101,11 @@ if (opts.logs.length === 0) { } const aggregated = new Map(); -try { - const existing = JSON.parse(fs.readFileSync(opts.out, "utf8")); +const existing = tryReadJsonFile(opts.out, null); +if (existing) { for (const [file, value] of Object.entries(existing.files ?? {})) { mergeHotspotEntry(aggregated, file, value); } -} catch { - // Start from scratch when the output file does not exist yet. } for (const logPath of opts.logs) { const text = fs.readFileSync(logPath, "utf8"); @@ -160,7 +146,7 @@ const output = { files, }; -fs.writeFileSync(opts.out, `${JSON.stringify(output, null, 2)}\n`); +writeJsonFile(opts.out, output); console.log( `[test-update-memory-hotspots] wrote ${String(Object.keys(files).length)} hotspots to ${opts.out}`, ); diff --git a/scripts/test-update-timings.mjs b/scripts/test-update-timings.mjs index e450ff9cd31..b3fa815c6d9 100644 --- a/scripts/test-update-timings.mjs +++ b/scripts/test-update-timings.mjs @@ -1,7 +1,10 @@ -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { + collectVitestFileDurations, + normalizeTrackedRepoPath, + readJsonFile, + runVitestJsonReport, + writeJsonFile, +} from "./test-report-utils.mjs"; import { unitTimingManifestPath } from "./test-runner-manifest.mjs"; function parseArgs(argv) { @@ -49,53 +52,15 @@ function parseArgs(argv) { return args; } -const normalizeRepoPath = (value) => value.split(path.sep).join("/"); -const repoRoot = path.resolve(process.cwd()); -const normalizeTrackedRepoPath = (value) => { - const normalizedValue = typeof value === "string" ? value : String(value ?? ""); - const repoRelative = path.isAbsolute(normalizedValue) - ? path.relative(repoRoot, path.resolve(normalizedValue)) - : normalizedValue; - if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") { - return normalizeRepoPath(normalizedValue); - } - return normalizeRepoPath(repoRelative); -}; - const opts = parseArgs(process.argv.slice(2)); -const reportPath = - opts.reportPath || path.join(os.tmpdir(), `openclaw-vitest-timings-${Date.now()}.json`); - -if (!(opts.reportPath && fs.existsSync(reportPath))) { - const run = spawnSync( - "pnpm", - ["vitest", "run", "--config", opts.config, "--reporter=json", "--outputFile", reportPath], - { - stdio: "inherit", - env: process.env, - }, - ); - - if (run.status !== 0) { - process.exit(run.status ?? 1); - } -} - -const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); +const reportPath = runVitestJsonReport({ + config: opts.config, + reportPath: opts.reportPath, + prefix: "openclaw-vitest-timings", +}); +const report = readJsonFile(reportPath); const files = Object.fromEntries( - (report.testResults ?? []) - .map((result) => { - const file = typeof result.name === "string" ? normalizeTrackedRepoPath(result.name) : ""; - const start = typeof result.startTime === "number" ? result.startTime : 0; - const end = typeof result.endTime === "number" ? result.endTime : 0; - const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0; - return { - file, - durationMs: Math.max(0, end - start), - testCount, - }; - }) - .filter((entry) => entry.file.length > 0 && entry.durationMs > 0) + collectVitestFileDurations(report, normalizeTrackedRepoPath) .toSorted((a, b) => b.durationMs - a.durationMs) .slice(0, opts.limit) .map((entry) => [ @@ -114,7 +79,7 @@ const output = { files, }; -fs.writeFileSync(opts.out, `${JSON.stringify(output, null, 2)}\n`); +writeJsonFile(opts.out, output); console.log( `[test-update-timings] wrote ${String(Object.keys(files).length)} timings to ${opts.out}`, ); diff --git a/test/helpers/extensions/runtime-env.ts b/test/helpers/extensions/runtime-env.ts index b197619e43e..45035976760 100644 --- a/test/helpers/extensions/runtime-env.ts +++ b/test/helpers/extensions/runtime-env.ts @@ -1,12 +1,15 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; -export function createRuntimeEnv(): RuntimeEnv { +export function createRuntimeEnv(options?: { throwOnExit?: boolean }): RuntimeEnv { + const throwOnExit = options?.throwOnExit ?? true; return { log: vi.fn(), error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), + exit: throwOnExit + ? vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }) + : vi.fn(), }; } diff --git a/test/helpers/extensions/zalo-lifecycle.ts b/test/helpers/extensions/zalo-lifecycle.ts new file mode 100644 index 00000000000..ac61fee103f --- /dev/null +++ b/test/helpers/extensions/zalo-lifecycle.ts @@ -0,0 +1,198 @@ +import { vi } from "vitest"; +import type { ResolvedZaloAccount } from "../../../extensions/zalo/src/accounts.js"; +import { + clearZaloWebhookSecurityStateForTest, + monitorZaloProvider, +} from "../../../extensions/zalo/src/monitor.js"; +import type { OpenClawConfig } from "../../../extensions/zalo/src/runtime-api.js"; +import { normalizeSecretInputString } from "../../../extensions/zalo/src/secret-input.js"; +import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; +import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; +import { withServer } from "../http-test-server.js"; +import { createRuntimeEnv } from "./runtime-env.js"; + +export { withServer }; + +const lifecycleMocks = vi.hoisted(() => ({ + setWebhookMock: vi.fn(async () => ({ ok: true, result: { url: "" } })), + deleteWebhookMock: vi.fn(async () => ({ ok: true, result: { url: "" } })), + getWebhookInfoMock: vi.fn(async () => ({ ok: true, result: { url: "" } })), + getUpdatesMock: vi.fn(() => new Promise(() => {})), + sendChatActionMock: vi.fn(async () => ({ ok: true })), + sendMessageMock: vi.fn(async () => ({ + ok: true, + result: { message_id: "zalo-test-reply-1" }, + })), + sendPhotoMock: vi.fn(async () => ({ ok: true })), + getZaloRuntimeMock: vi.fn(), +})); + +export const setWebhookMock = lifecycleMocks.setWebhookMock; +export const deleteWebhookMock = lifecycleMocks.deleteWebhookMock; +export const getWebhookInfoMock = lifecycleMocks.getWebhookInfoMock; +export const getUpdatesMock = lifecycleMocks.getUpdatesMock; +export const sendChatActionMock = lifecycleMocks.sendChatActionMock; +export const sendMessageMock = lifecycleMocks.sendMessageMock; +export const sendPhotoMock = lifecycleMocks.sendPhotoMock; +export const getZaloRuntimeMock = lifecycleMocks.getZaloRuntimeMock; + +vi.mock("../../../extensions/zalo/src/api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deleteWebhook: lifecycleMocks.deleteWebhookMock, + getUpdates: lifecycleMocks.getUpdatesMock, + getWebhookInfo: lifecycleMocks.getWebhookInfoMock, + sendChatAction: lifecycleMocks.sendChatActionMock, + sendMessage: lifecycleMocks.sendMessageMock, + sendPhoto: lifecycleMocks.sendPhotoMock, + setWebhook: lifecycleMocks.setWebhookMock, + }; +}); + +vi.mock("../../../extensions/zalo/src/runtime.js", () => ({ + getZaloRuntime: lifecycleMocks.getZaloRuntimeMock, +})); + +export function resetLifecycleTestState() { + vi.clearAllMocks(); + clearZaloWebhookSecurityStateForTest(); + setActivePluginRegistry(createEmptyPluginRegistry()); +} + +export function createLifecycleConfig(params: { + accountId: string; + dmPolicy: "open" | "pairing"; + allowFrom?: string[]; + webhookUrl?: string; + webhookSecret?: string; +}): OpenClawConfig { + const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo"; + const webhookSecret = params.webhookSecret ?? "supersecret"; + return { + channels: { + zalo: { + enabled: true, + accounts: { + [params.accountId]: { + enabled: true, + webhookUrl, + webhookSecret, // pragma: allowlist secret + dmPolicy: params.dmPolicy, + ...(params.allowFrom ? { allowFrom: params.allowFrom } : {}), + }, + }, + }, + }, + } as OpenClawConfig; +} + +export function createLifecycleAccount(params: { + accountId: string; + dmPolicy: "open" | "pairing"; + allowFrom?: string[]; + webhookUrl?: string; + webhookSecret?: string; +}): ResolvedZaloAccount { + const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo"; + const webhookSecret = params.webhookSecret ?? "supersecret"; + return { + accountId: params.accountId, + enabled: true, + token: "zalo-token", + tokenSource: "config", + config: { + webhookUrl, + webhookSecret, // pragma: allowlist secret + dmPolicy: params.dmPolicy, + ...(params.allowFrom ? { allowFrom: params.allowFrom } : {}), + }, + } as ResolvedZaloAccount; +} + +export function createTextUpdate(params: { + messageId: string; + userId: string; + userName: string; + chatId: string; + text?: string; +}) { + return { + event_name: "message.text.received", + message: { + from: { id: params.userId, name: params.userName }, + chat: { id: params.chatId, chat_type: "PRIVATE" as const }, + message_id: params.messageId, + date: Math.floor(Date.now() / 1000), + text: params.text ?? "hello from zalo", + }, + }; +} + +export async function settleAsyncWork(): Promise { + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +export async function postWebhookUpdate(params: { + baseUrl: string; + path: string; + secret: string; + payload: Record; +}) { + return await fetch(`${params.baseUrl}${params.path}`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-bot-api-secret-token": params.secret, + }, + body: JSON.stringify(params.payload), + }); +} + +export async function startWebhookLifecycleMonitor(params: { + account: ResolvedZaloAccount; + config: OpenClawConfig; + token?: string; + webhookUrl?: string; + webhookSecret?: string; +}) { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry); + const abort = new AbortController(); + const runtime = createRuntimeEnv(); + const webhookUrl = params.webhookUrl ?? params.account.config?.webhookUrl; + const webhookSecret = + params.webhookSecret ?? normalizeSecretInputString(params.account.config?.webhookSecret); + const run = monitorZaloProvider({ + token: params.token ?? "zalo-token", + account: params.account, + config: params.config, + runtime, + abortSignal: abort.signal, + useWebhook: true, + webhookUrl, + webhookSecret, + }); + + await vi.waitFor(() => { + if (setWebhookMock.mock.calls.length !== 1 || registry.httpRoutes.length !== 1) { + throw new Error("waiting for webhook registration"); + } + }); + + const route = registry.httpRoutes[0]; + if (!route) { + throw new Error("missing plugin HTTP route"); + } + + return { + abort, + registry, + route, + run, + runtime, + }; +} diff --git a/test/helpers/http-test-server.ts b/test/helpers/http-test-server.ts new file mode 100644 index 00000000000..18493e885de --- /dev/null +++ b/test/helpers/http-test-server.ts @@ -0,0 +1,18 @@ +import { createServer, type RequestListener } from "node:http"; +import type { AddressInfo } from "node:net"; + +export async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise) { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error("missing server address"); + } + try { + await fn(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} diff --git a/test/scripts/check-file-utils.test.ts b/test/scripts/check-file-utils.test.ts new file mode 100644 index 00000000000..5fcbf812a53 --- /dev/null +++ b/test/scripts/check-file-utils.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { collectFilesSync, isCodeFile, relativeToCwd } from "../../scripts/check-file-utils.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-check-file-utils-")); + tempDirs.push(dir); + return dir; +} + +describe("scripts/check-file-utils isCodeFile", () => { + it("accepts source files and skips declarations", () => { + expect(isCodeFile("example.ts")).toBe(true); + expect(isCodeFile("example.mjs")).toBe(true); + expect(isCodeFile("example.d.ts")).toBe(false); + }); +}); + +describe("scripts/check-file-utils collectFilesSync", () => { + it("collects matching files while skipping common generated dirs", () => { + const rootDir = makeTempDir(); + fs.mkdirSync(path.join(rootDir, "src", "nested"), { recursive: true }); + fs.mkdirSync(path.join(rootDir, "dist"), { recursive: true }); + fs.writeFileSync(path.join(rootDir, "src", "keep.ts"), ""); + fs.writeFileSync(path.join(rootDir, "src", "nested", "keep.test.ts"), ""); + fs.writeFileSync(path.join(rootDir, "dist", "skip.ts"), ""); + + const files = collectFilesSync(rootDir, { + includeFile: (filePath) => filePath.endsWith(".ts"), + }).map((filePath) => path.relative(rootDir, filePath)); + + expect(files.toSorted()).toEqual(["src/keep.ts", "src/nested/keep.test.ts"]); + }); + + it("supports custom skipped directories", () => { + const rootDir = makeTempDir(); + fs.mkdirSync(path.join(rootDir, "fixtures"), { recursive: true }); + fs.mkdirSync(path.join(rootDir, "src"), { recursive: true }); + fs.writeFileSync(path.join(rootDir, "fixtures", "skip.ts"), ""); + fs.writeFileSync(path.join(rootDir, "src", "keep.ts"), ""); + + const files = collectFilesSync(rootDir, { + includeFile: (filePath) => filePath.endsWith(".ts"), + skipDirNames: new Set(["fixtures"]), + }).map((filePath) => path.relative(rootDir, filePath)); + + expect(files).toEqual(["src/keep.ts"]); + }); +}); + +describe("scripts/check-file-utils relativeToCwd", () => { + it("renders repo-relative paths when possible", () => { + expect(relativeToCwd(path.join(process.cwd(), "scripts", "check-file-utils.ts"))).toBe( + "scripts/check-file-utils.ts", + ); + }); +}); diff --git a/test/scripts/test-report-utils.test.ts b/test/scripts/test-report-utils.test.ts new file mode 100644 index 00000000000..55537a6920b --- /dev/null +++ b/test/scripts/test-report-utils.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + collectVitestFileDurations, + normalizeTrackedRepoPath, + tryReadJsonFile, +} from "../../scripts/test-report-utils.mjs"; + +describe("scripts/test-report-utils normalizeTrackedRepoPath", () => { + it("normalizes repo-local absolute paths to repo-relative slash paths", () => { + const absoluteFile = path.join(process.cwd(), "src", "tools", "example.test.ts"); + + expect(normalizeTrackedRepoPath(absoluteFile)).toBe("src/tools/example.test.ts"); + }); + + it("preserves external absolute paths as normalized absolute paths", () => { + const externalFile = path.join(path.parse(process.cwd()).root, "tmp", "outside.test.ts"); + + expect(normalizeTrackedRepoPath(externalFile)).toBe(externalFile.split(path.sep).join("/")); + }); +}); + +describe("scripts/test-report-utils collectVitestFileDurations", () => { + it("extracts per-file durations and applies file normalization", () => { + const report = { + testResults: [ + { + name: path.join(process.cwd(), "src", "alpha.test.ts"), + startTime: 100, + endTime: 460, + assertionResults: [{}, {}], + }, + { + name: "src/zero.test.ts", + startTime: 300, + endTime: 300, + assertionResults: [{}], + }, + ], + }; + + expect(collectVitestFileDurations(report, normalizeTrackedRepoPath)).toEqual([ + { + file: "src/alpha.test.ts", + durationMs: 360, + testCount: 2, + }, + ]); + }); +}); + +describe("scripts/test-report-utils tryReadJsonFile", () => { + it("returns the fallback when the file is missing", () => { + const missingPath = path.join(os.tmpdir(), `openclaw-missing-${Date.now()}.json`); + + expect(tryReadJsonFile(missingPath, { ok: true })).toEqual({ ok: true }); + }); + + it("reads valid JSON files", () => { + const tempPath = path.join(os.tmpdir(), `openclaw-json-${Date.now()}.json`); + fs.writeFileSync(tempPath, JSON.stringify({ ok: true })); + + try { + expect(tryReadJsonFile(tempPath, null)).toEqual({ ok: true }); + } finally { + fs.unlinkSync(tempPath); + } + }); +});