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,

View File

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

View File

@@ -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/<subpath> surface or use the extension's own public barrel instead.",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`,
);

View File

@@ -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}`,
);

View File

@@ -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(),
};
}

View File

@@ -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<typeof import("../../../extensions/zalo/src/api.js")>();
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<void> {
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<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),
});
}
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,
};
}

View File

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

View File

@@ -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",
);
});
});

View File

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