mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 06:02:13 +00:00
refactor(test): dedupe shared test helpers
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
66
extensions/matrix/src/test-runtime.ts
Normal file
66
extensions/matrix/src/test-runtime.ts
Normal 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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
55
scripts/check-file-utils.ts
Normal file
55
scripts/check-file-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
75
scripts/test-report-utils.mjs
Normal file
75
scripts/test-report-utils.mjs
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
198
test/helpers/extensions/zalo-lifecycle.ts
Normal file
198
test/helpers/extensions/zalo-lifecycle.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
18
test/helpers/http-test-server.ts
Normal file
18
test/helpers/http-test-server.ts
Normal 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()));
|
||||
}
|
||||
}
|
||||
67
test/scripts/check-file-utils.test.ts
Normal file
67
test/scripts/check-file-utils.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
71
test/scripts/test-report-utils.test.ts
Normal file
71
test/scripts/test-report-utils.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user