refactor(test): dedupe shared test helpers

This commit is contained in:
Peter Steinberger
2026-03-21 23:07:16 +00:00
parent 29b165e456
commit a622eecd3b
45 changed files with 898 additions and 1107 deletions

View File

@@ -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",

View File

@@ -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({

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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({

View File

@@ -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;

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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({

View File

@@ -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 () => {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 () => {

View File

@@ -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<string, unknown>): 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 () => {

View File

@@ -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?: {

View File

@@ -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

View File

@@ -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: {},

View File

@@ -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: {

View File

@@ -0,0 +1,66 @@
import { vi } from "vitest";
import type { PluginRuntime } from "./runtime-api.js";
import { setMatrixRuntime } from "./runtime.js";
type MatrixTestRuntimeOptions = {
cfg?: Record<string, unknown>;
logging?: Partial<PluginRuntime["logging"]>;
channel?: Partial<PluginRuntime["channel"]>;
stateDir?: string;
};
export function installMatrixTestRuntime(options: MatrixTestRuntimeOptions = {}): void {
const defaultStateDirResolver: NonNullable<PluginRuntime["state"]>["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<MatrixTestRuntimeOptions, "cfg" | "stateDir"> & {
matchesMentionPatterns?: (text: string, patterns: RegExp[]) => boolean;
saveMediaBuffer?: NonNullable<NonNullable<PluginRuntime["channel"]>["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(),
},
},
});
}

View File

@@ -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<typeof vi.fn>, 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");

View File

@@ -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<Parameters<typeof monitorWebInbox>[0]["onMessage"]>;
async function settleInboundWork() {
await new Promise((resolve) => setTimeout(resolve, 25));
}
async function waitForMessageCalls(onMessage: ReturnType<typeof vi.fn>, 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 () => {});

View File

@@ -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<Parameters<typeof monitorWebInbox>[0]["onMessage"]>;
async function waitForMessageCalls(onMessage: ReturnType<typeof vi.fn>, 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: [

View File

@@ -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<typeof import("openclaw/plugin-sdk/media-runtime")>();
@@ -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<Parameters<typeof monitorWebInbox>[0]["onMessage"]>;
export async function settleInboundWork() {
await new Promise((resolve) => setTimeout(resolve, 25));
}
export async function waitForMessageCalls(onMessage: ReturnType<typeof vi.fn>, 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, {

View File

@@ -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<string, unknown>) => ctx);
const recordInboundSessionMock = vi.fn(async () => undefined);

View File

@@ -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,

View File

@@ -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<typeof import("./api.js")>();
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<void>) {
const server = createServer(handler);
await new Promise<void>((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<void>((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<void> {
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<string, unknown>;
}) {
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",

View File

@@ -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<typeof import("./api.js")>();
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<void>) {
const server = createServer(handler);
await new Promise<void>((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<void>((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<void> {
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<string, unknown>;
}) {
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<string, unknown>) => 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",

View File

@@ -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<void>) {
const server = createServer(handler);
await new Promise<void>((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<void>((resolve) => server.close(() => resolve()));
}
}
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
accountId: "default",
enabled: true,