test: share cli command and discord test helpers

This commit is contained in:
Peter Steinberger
2026-03-26 19:35:51 +00:00
parent b48df79c0a
commit e8f9d68bec
11 changed files with 350 additions and 391 deletions

View File

@@ -63,6 +63,43 @@ function installDefaultDiscordPreflight() {
);
}
function createAbortOnTimeoutProcessImplementation() {
return async (ctx: { abortSignal?: AbortSignal }) => {
await new Promise<void>((resolve) => {
if (ctx.abortSignal?.aborted) {
resolve();
return;
}
ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
};
}
async function queueTimedMessages(params?: {
workerRunTimeoutMs?: number;
beforeCreateHandler?: () => void;
}) {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockClear();
processDiscordMessageMock
.mockImplementationOnce(createAbortOnTimeoutProcessImplementation())
.mockImplementationOnce(async () => undefined);
installDefaultDiscordPreflight();
params?.beforeCreateHandler?.();
const handlerParams = createDiscordHandlerParams({
workerRunTimeoutMs: params?.workerRunTimeoutMs ?? 50,
});
const handler = createDiscordMessageHandler(handlerParams);
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined();
return { handlerParams };
}
async function runSingleMessageTimeout(params: {
processImpl: Parameters<typeof processDiscordMessageMock.mockImplementationOnce>[0];
workerRunTimeoutMs?: number;
@@ -225,34 +262,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
it("applies explicit inbound worker timeout to queued runs so stalled runs do not block the queue", async () => {
vi.useFakeTimers();
try {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockClear();
processDiscordMessageMock
.mockImplementationOnce(async (ctx: { abortSignal?: AbortSignal }) => {
await new Promise<void>((resolve) => {
if (ctx.abortSignal?.aborted) {
resolve();
return;
}
ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
})
.mockImplementationOnce(async () => undefined);
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 });
preflightDiscordMessageMock.mockImplementation(
async (preflightParams: { data: { channel_id: string } }) =>
createPreflightContext(preflightParams.data.channel_id),
);
const handler = createDiscordMessageHandler(params);
await expect(
handler(createMessageData("m-1") as never, {} as never),
).resolves.toBeUndefined();
await expect(
handler(createMessageData("m-2") as never, {} as never),
).resolves.toBeUndefined();
const { handlerParams } = await queueTimedMessages();
await vi.advanceTimersByTimeAsync(60);
await vi.waitFor(() => {
@@ -263,7 +273,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
| { abortSignal?: AbortSignal }
| undefined;
expect(firstCtx?.abortSignal?.aborted).toBe(true);
expect(params.runtime.error).toHaveBeenCalledWith(
expect(handlerParams.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("discord inbound worker timed out after"),
);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
@@ -287,40 +297,15 @@ describe("createDiscordMessageHandler queue behavior", () => {
it("waits for the timeout fallback reply before starting the next queued run", async () => {
vi.useFakeTimers();
try {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockReset();
const deliverTimeoutReply = createDeferred();
deliverDiscordReplyMock.mockImplementationOnce(async () => {
await deliverTimeoutReply.promise;
});
processDiscordMessageMock
.mockImplementationOnce(async (ctx: { abortSignal?: AbortSignal }) => {
await new Promise<void>((resolve) => {
if (ctx.abortSignal?.aborted) {
resolve();
return;
}
ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
const { handlerParams } = await queueTimedMessages({
beforeCreateHandler: () => {
deliverDiscordReplyMock.mockReset();
deliverDiscordReplyMock.mockImplementationOnce(async () => {
await deliverTimeoutReply.promise;
});
})
.mockImplementationOnce(async () => undefined);
preflightDiscordMessageMock.mockImplementation(
async (preflightParams: { data: { channel_id: string } }) =>
createPreflightContext(preflightParams.data.channel_id),
);
const handler = createDiscordMessageHandler(
createDiscordHandlerParams({ workerRunTimeoutMs: 50 }),
);
await expect(
handler(createMessageData("m-1") as never, {} as never),
).resolves.toBeUndefined();
await expect(
handler(createMessageData("m-2") as never, {} as never),
).resolves.toBeUndefined();
},
});
await vi.advanceTimersByTimeAsync(60);
await vi.waitFor(() => {
@@ -328,6 +313,9 @@ describe("createDiscordMessageHandler queue behavior", () => {
});
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
expect(handlerParams.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("discord inbound worker timed out after"),
);
deliverTimeoutReply.resolve();
await deliverTimeoutReply.promise;

View File

@@ -187,6 +187,23 @@ describe("runDiscordGatewayLifecycle", () => {
};
}
function expectGatewaySessionStateCleared(gateway: {
state?: {
sessionId?: string | null;
resumeGatewayUrl?: string | null;
sequence?: number | null;
};
sequence?: number | null;
}) {
if (!gateway.state) {
throw new Error("gateway state was not initialized");
}
expect(gateway.state.sessionId).toBeNull();
expect(gateway.state.resumeGatewayUrl).toBeNull();
expect(gateway.state.sequence).toBeNull();
expect(gateway.sequence).toBeNull();
}
it("cleans up thread bindings when exec approvals startup fails", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({
@@ -350,13 +367,7 @@ describe("runDiscordGatewayLifecycle", () => {
expect(runtimeError).not.toHaveBeenCalledWith(
expect.stringContaining("WebSocket was closed before the connection was established"),
);
if (!gateway.state) {
throw new Error("gateway state was not initialized");
}
expect(gateway.state.sessionId).toBeNull();
expect(gateway.state.resumeGatewayUrl).toBeNull();
expect(gateway.state.sequence).toBeNull();
expect(gateway.sequence).toBeNull();
expectGatewaySessionStateCleared(gateway);
} finally {
vi.useRealTimers();
}
@@ -677,13 +688,7 @@ describe("runDiscordGatewayLifecycle", () => {
expect(gateway.connect).toHaveBeenNthCalledWith(1, true);
expect(gateway.connect).toHaveBeenNthCalledWith(2, true);
expect(gateway.connect).toHaveBeenNthCalledWith(3, false);
if (!gateway.state) {
throw new Error("gateway state was not initialized");
}
expect(gateway.state.sessionId).toBeNull();
expect(gateway.state.resumeGatewayUrl).toBeNull();
expect(gateway.state.sequence).toBeNull();
expect(gateway.sequence).toBeNull();
expectGatewaySessionStateCleared(gateway);
} finally {
vi.useRealTimers();
}

View File

@@ -0,0 +1,65 @@
import { vi } from "vitest";
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
import { createBrowserProgram } from "./browser-cli-test-helpers.js";
type BrowserRequest = { path?: string };
type BrowserRuntimeOptions = { timeoutMs?: number };
export type BrowserManageCall = [unknown, BrowserRequest, BrowserRuntimeOptions | undefined];
const browserManageMocks = vi.hoisted(() => ({
callBrowserRequest: vi.fn<
(
opts: unknown,
req: BrowserRequest,
runtimeOpts?: BrowserRuntimeOptions,
) => Promise<Record<string, unknown>>
>(async (_opts: unknown, req: BrowserRequest) =>
req.path === "/"
? {
enabled: true,
running: true,
pid: 1,
cdpPort: 18800,
chosenBrowser: "chrome",
userDataDir: "/tmp/openclaw",
color: "blue",
headless: true,
attachOnly: false,
}
: {},
),
}));
vi.mock("./browser-cli-shared.js", () => ({
callBrowserRequest: browserManageMocks.callBrowserRequest,
}));
vi.mock("./cli-utils.js", async () => ({
...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()),
}));
vi.mock(
"../runtime.js",
async () =>
await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(),
);
export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }) {
const { program, browser, parentOpts } = createBrowserProgram();
if (params?.withParentTimeout) {
browser.option("--timeout <ms>", "Timeout in ms", "30000");
}
registerBrowserManageCommands(browser, parentOpts);
return program;
}
export function getBrowserManageCallBrowserRequestMock() {
return browserManageMocks.callBrowserRequest;
}
export function findBrowserManageCall(path: string): BrowserManageCall | undefined {
return browserManageMocks.callBrowserRequest.mock.calls.find(
(call) => (call[1] ?? {}).path === path,
) as BrowserManageCall | undefined;
}

View File

@@ -1,51 +1,18 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
import { beforeEach, describe, expect, it } from "vitest";
import {
createBrowserProgram,
getBrowserCliRuntime,
getBrowserCliRuntimeCapture,
} from "./browser-cli-test-helpers.js";
const mocks = vi.hoisted(() => {
return {
callBrowserRequest: vi.fn<
(
opts: unknown,
req: { path?: string },
runtimeOpts?: { timeoutMs?: number },
) => Promise<Record<string, unknown>>
>(async () => ({})),
};
});
vi.mock("./browser-cli-shared.js", () => ({
callBrowserRequest: mocks.callBrowserRequest,
}));
vi.mock("./cli-utils.js", async () => ({
...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()),
}));
vi.mock(
"../runtime.js",
async () =>
await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(),
);
function createProgram() {
const { program, browser, parentOpts } = createBrowserProgram();
registerBrowserManageCommands(browser, parentOpts);
return program;
}
createBrowserManageProgram,
getBrowserManageCallBrowserRequestMock,
} from "./browser-cli-manage.test-helpers.js";
import { getBrowserCliRuntime, getBrowserCliRuntimeCapture } from "./browser-cli-test-helpers.js";
describe("browser manage output", () => {
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
getBrowserManageCallBrowserRequestMock().mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it("shows chrome-mcp transport for existing-session status without fake CDP fields", async () => {
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
req.path === "/"
? {
enabled: true,
@@ -69,7 +36,7 @@ describe("browser manage output", () => {
: {},
);
const program = createProgram();
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "--browser-profile", "chrome-live", "status"], {
from: "user",
});
@@ -81,7 +48,7 @@ describe("browser manage output", () => {
});
it("shows configured userDataDir for existing-session status", async () => {
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
req.path === "/"
? {
enabled: true,
@@ -105,7 +72,7 @@ describe("browser manage output", () => {
: {},
);
const program = createProgram();
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "--browser-profile", "brave-live", "status"], {
from: "user",
});
@@ -117,7 +84,7 @@ describe("browser manage output", () => {
});
it("shows chrome-mcp transport in browser profiles output", async () => {
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
req.path === "/profiles"
? {
profiles: [
@@ -138,7 +105,7 @@ describe("browser manage output", () => {
: {},
);
const program = createProgram();
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "profiles"], { from: "user" });
const output = getBrowserCliRuntime().log.mock.calls.at(-1)?.[0] as string;
@@ -148,7 +115,7 @@ describe("browser manage output", () => {
});
it("shows chrome-mcp transport after creating an existing-session profile", async () => {
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
req.path === "/profiles/create"
? {
ok: true,
@@ -163,7 +130,7 @@ describe("browser manage output", () => {
: {},
);
const program = createProgram();
const program = createBrowserManageProgram();
await program.parseAsync(
["browser", "create-profile", "--name", "chrome-live", "--driver", "existing-session"],
{ from: "user" },
@@ -176,7 +143,7 @@ describe("browser manage output", () => {
});
it("redacts sensitive remote cdpUrl details in status output", async () => {
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) =>
req.path === "/"
? {
enabled: true,
@@ -201,7 +168,7 @@ describe("browser manage output", () => {
: {},
);
const program = createProgram();
const program = createBrowserManageProgram();
await program.parseAsync(["browser", "--browser-profile", "remote", "status"], {
from: "user",
});

View File

@@ -1,108 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
import { createBrowserProgram, getBrowserCliRuntimeCapture } from "./browser-cli-test-helpers.js";
const mocks = vi.hoisted(() => {
return {
callBrowserRequest: vi.fn(async (_opts: unknown, req: { path?: string }) =>
req.path === "/"
? {
enabled: true,
running: true,
pid: 1,
cdpPort: 18800,
chosenBrowser: "chrome",
userDataDir: "/tmp/openclaw",
color: "blue",
headless: true,
attachOnly: false,
}
: {},
),
};
});
vi.mock("./browser-cli-shared.js", () => ({
callBrowserRequest: mocks.callBrowserRequest,
}));
vi.mock("./cli-utils.js", async () => ({
...(await (await import("./browser-cli-test-helpers.js")).createBrowserCliUtilsMockModule()),
}));
vi.mock(
"../runtime.js",
async () =>
await (await import("./browser-cli-test-helpers.js")).createBrowserCliRuntimeMockModule(),
);
import { beforeEach, describe, expect, it } from "vitest";
import {
createBrowserManageProgram,
findBrowserManageCall,
getBrowserManageCallBrowserRequestMock,
} from "./browser-cli-manage.test-helpers.js";
import { getBrowserCliRuntimeCapture } from "./browser-cli-test-helpers.js";
describe("browser manage start timeout option", () => {
function createProgram() {
const { program, browser, parentOpts } = createBrowserProgram();
browser.option("--timeout <ms>", "Timeout in ms", "30000");
registerBrowserManageCommands(browser, parentOpts);
return program;
}
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
getBrowserManageCallBrowserRequestMock().mockClear();
getBrowserCliRuntimeCapture().resetRuntimeCapture();
});
it("uses parent --timeout for browser start instead of hardcoded 15s", async () => {
const program = createProgram();
const program = createBrowserManageProgram({ withParentTimeout: true });
await program.parseAsync(["browser", "--timeout", "60000", "start"], { from: "user" });
const startCall = mocks.callBrowserRequest.mock.calls.find(
(call) => ((call[1] ?? {}) as { path?: string }).path === "/start",
) as [Record<string, unknown>, { path?: string }, unknown] | undefined;
const startCall = findBrowserManageCall("/start");
expect(startCall).toBeDefined();
expect(startCall?.[0]).toMatchObject({ timeout: "60000" });
expect(startCall?.[2]).toBeUndefined();
});
it("uses a longer built-in timeout for browser status", async () => {
const program = createProgram();
const program = createBrowserManageProgram({ withParentTimeout: true });
await program.parseAsync(["browser", "status"], { from: "user" });
const statusCall = mocks.callBrowserRequest.mock.calls.find(
(call) => ((call[1] ?? {}) as { path?: string }).path === "/",
) as [Record<string, unknown>, { path?: string }, { timeoutMs?: number }] | undefined;
const statusCall = findBrowserManageCall("/");
expect(statusCall?.[2]).toEqual({ timeoutMs: 45_000 });
});
it("uses a longer built-in timeout for browser tabs", async () => {
const program = createProgram();
const program = createBrowserManageProgram({ withParentTimeout: true });
await program.parseAsync(["browser", "tabs"], { from: "user" });
const tabsCall = mocks.callBrowserRequest.mock.calls.find(
(call) => ((call[1] ?? {}) as { path?: string }).path === "/tabs",
) as [Record<string, unknown>, { path?: string }, { timeoutMs?: number }] | undefined;
const tabsCall = findBrowserManageCall("/tabs");
expect(tabsCall?.[2]).toEqual({ timeoutMs: 45_000 });
});
it("uses a longer built-in timeout for browser profiles", async () => {
const program = createProgram();
const program = createBrowserManageProgram({ withParentTimeout: true });
await program.parseAsync(["browser", "profiles"], { from: "user" });
const profilesCall = mocks.callBrowserRequest.mock.calls.find(
(call) => ((call[1] ?? {}) as { path?: string }).path === "/profiles",
) as [Record<string, unknown>, { path?: string }, { timeoutMs?: number }] | undefined;
const profilesCall = findBrowserManageCall("/profiles");
expect(profilesCall?.[2]).toEqual({ timeoutMs: 45_000 });
});
it("uses a longer built-in timeout for browser open", async () => {
const program = createProgram();
const program = createBrowserManageProgram({ withParentTimeout: true });
await program.parseAsync(["browser", "open", "https://example.com"], { from: "user" });
const openCall = mocks.callBrowserRequest.mock.calls.find(
(call) => ((call[1] ?? {}) as { path?: string }).path === "/tabs/open",
) as [Record<string, unknown>, { path?: string }, { timeoutMs?: number }] | undefined;
const openCall = findBrowserManageCall("/tabs/open");
expect(openCall?.[2]).toEqual({ timeoutMs: 45_000 });
});
});

View File

@@ -21,6 +21,59 @@ import {
writeConfigFile,
} from "./plugins-cli-test-helpers.js";
function createEnabledPluginConfig(pluginId: string): OpenClawConfig {
return {
plugins: {
entries: {
[pluginId]: {
enabled: true,
},
},
},
} as OpenClawConfig;
}
function createClawHubInstalledConfig(params: {
pluginId: string;
install: Record<string, unknown>;
}): OpenClawConfig {
const enabledCfg = createEnabledPluginConfig(params.pluginId);
return {
...enabledCfg,
plugins: {
...enabledCfg.plugins,
installs: {
[params.pluginId]: params.install,
},
},
} as OpenClawConfig;
}
function createClawHubInstallResult(params: {
pluginId: string;
packageName: string;
version: string;
channel: string;
}): Awaited<ReturnType<typeof installPluginFromClawHub>> {
return {
ok: true,
pluginId: params.pluginId,
targetDir: `/tmp/openclaw-state/extensions/${params.pluginId}`,
version: params.version,
packageName: params.packageName,
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: params.packageName,
clawhubFamily: "code-plugin",
clawhubChannel: params.channel,
version: params.version,
integrity: "sha256-abc",
resolvedAt: "2026-03-22T00:00:00.000Z",
},
};
}
describe("plugins cli install", () => {
beforeEach(() => {
resetPluginsCliTestState();
@@ -142,51 +195,29 @@ describe("plugins cli install", () => {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = {
plugins: {
entries: {
demo: {
enabled: true,
},
},
},
} as OpenClawConfig;
const installedCfg = {
...enabledCfg,
plugins: {
...enabledCfg.plugins,
installs: {
demo: {
source: "clawhub",
spec: "clawhub:demo@1.2.3",
installPath: "/tmp/openclaw-state/extensions/demo",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue({
ok: true,
const enabledCfg = createEnabledPluginConfig("demo");
const installedCfg = createClawHubInstalledConfig({
pluginId: "demo",
targetDir: "/tmp/openclaw-state/extensions/demo",
version: "1.2.3",
packageName: "demo",
clawhub: {
install: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
spec: "clawhub:demo@1.2.3",
installPath: "/tmp/openclaw-state/extensions/demo",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
version: "1.2.3",
integrity: "sha256-abc",
resolvedAt: "2026-03-22T00:00:00.000Z",
},
});
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(installedCfg);
applyExclusiveSlotSelection.mockReturnValue({
@@ -223,48 +254,26 @@ describe("plugins cli install", () => {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = {
plugins: {
entries: {
demo: {
enabled: true,
},
},
},
} as OpenClawConfig;
const installedCfg = {
...enabledCfg,
plugins: {
...enabledCfg.plugins,
installs: {
demo: {
source: "clawhub",
spec: "clawhub:demo@1.2.3",
installPath: "/tmp/openclaw-state/extensions/demo",
clawhubPackage: "demo",
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: true,
const enabledCfg = createEnabledPluginConfig("demo");
const installedCfg = createClawHubInstalledConfig({
pluginId: "demo",
targetDir: "/tmp/openclaw-state/extensions/demo",
version: "1.2.3",
packageName: "demo",
clawhub: {
install: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
spec: "clawhub:demo@1.2.3",
installPath: "/tmp/openclaw-state/extensions/demo",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "community",
version: "1.2.3",
integrity: "sha256-abc",
resolvedAt: "2026-03-22T00:00:00.000Z",
},
});
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "community",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(installedCfg);
applyExclusiveSlotSelection.mockReturnValue({

View File

@@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withEnvAsync } from "../test-utils/env.js";
import { createDoctorPrompter } from "./doctor-prompter.js";
import {
readEmbeddedGatewayTokenForTest,
testServiceAuditCodes,
} from "./doctor-service-audit.test-helpers.js";
const fsMocks = vi.hoisted(() => ({
realpath: vi.fn(),
@@ -57,17 +61,9 @@ vi.mock("../daemon/runtime-paths.js", () => ({
vi.mock("../daemon/service-audit.js", () => ({
auditGatewayServiceConfig: mocks.auditGatewayServiceConfig,
needsNodeRuntimeMigration: vi.fn(() => false),
readEmbeddedGatewayToken: (
command: {
environment?: Record<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
} | null,
) =>
command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file"
? undefined
: command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined,
readEmbeddedGatewayToken: readEmbeddedGatewayTokenForTest,
SERVICE_AUDIT_CODES: {
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
gatewayEntrypointMismatch: testServiceAuditCodes.gatewayEntrypointMismatch,
},
}));

View File

@@ -0,0 +1,15 @@
export const testServiceAuditCodes = {
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
gatewayTokenMismatch: "gateway-token-mismatch",
} as const;
export function readEmbeddedGatewayTokenForTest(
command: {
environment?: Record<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
} | null,
) {
return command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file"
? undefined
: command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined;
}

View File

@@ -4,6 +4,10 @@ import path from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import {
readEmbeddedGatewayTokenForTest,
testServiceAuditCodes,
} from "./doctor-service-audit.test-helpers.js";
import type { LegacyStateDetection } from "./doctor-state-migrations.js";
let originalIsTTY: boolean | undefined;
@@ -224,19 +228,8 @@ vi.mock("../daemon/inspect.js", () => ({
vi.mock("../daemon/service-audit.js", () => ({
auditGatewayServiceConfig,
needsNodeRuntimeMigration: vi.fn(() => false),
readEmbeddedGatewayToken: (
command: {
environment?: Record<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
} | null,
) =>
command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file"
? undefined
: command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined,
SERVICE_AUDIT_CODES: {
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
gatewayTokenMismatch: "gateway-token-mismatch",
},
readEmbeddedGatewayToken: readEmbeddedGatewayTokenForTest,
SERVICE_AUDIT_CODES: testServiceAuditCodes,
}));
vi.mock("../daemon/program-args.js", () => ({

View File

@@ -8,6 +8,9 @@ vi.mock("../terminal/note.js", () => ({
export async function loadDoctorCommandForTest(params?: { unmockModules?: string[] }) {
vi.resetModules();
vi.doMock("../terminal/note.js", () => ({
note: (...args: unknown[]) => terminalNoteMock(...args),
}));
for (const modulePath of params?.unmockModules ?? []) {
vi.doUnmock(modulePath);
}

View File

@@ -96,6 +96,71 @@ function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig
} as OpenClawConfig;
}
function createMSTeamsCatalogEntry(): ChannelPluginCatalogEntry {
return {
id: "msteams",
pluginId: "@openclaw/msteams-plugin",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
install: {
npmSpec: "@openclaw/msteams",
},
};
}
function createMSTeamsPluginRegistryEntry(params?: { includeSetupWizard?: boolean }) {
return {
pluginId: "@openclaw/msteams-plugin",
source: "test",
plugin: {
id: "msteams",
meta: createMSTeamsCatalogEntry().meta,
capabilities: { chatTypes: ["direct"] as const },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
...(params?.includeSetupWizard
? {
setupWizard: {
channel: "msteams",
status: {
configuredLabel: "configured",
unconfiguredLabel: "installed",
resolveConfigured: () => false,
resolveStatusLines: async () => [],
resolveSelectionHint: async () => "installed",
},
credentials: [],
},
}
: {}),
outbound: { deliveryMode: "direct" as const },
},
};
}
function mockMSTeamsRegistrySnapshot(params?: { includeSetupWizard?: boolean }) {
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockImplementation(
({ channel }: { channel: string }) => {
const registry = createEmptyPluginRegistry();
if (channel === "msteams") {
if (params?.includeSetupWizard) {
registry.channelSetups.push(createMSTeamsPluginRegistryEntry(params) as never);
} else {
registry.channels.push(createMSTeamsPluginRegistryEntry(params) as never);
}
}
return registry;
},
);
}
function patchTelegramAdapter(overrides: Parameters<typeof patchChannelSetupWizardAdapter>[1]) {
return patchChannelSetupWizardAdapter("telegram", {
...overrides,
@@ -374,50 +439,8 @@ describe("setupChannels", () => {
it("keeps configured external plugin channels visible when the active registry starts empty", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "msteams",
pluginId: "@openclaw/msteams-plugin",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
install: {
npmSpec: "@openclaw/msteams",
},
} satisfies ChannelPluginCatalogEntry,
]);
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockImplementation(
({ channel }: { channel: string }) => {
const registry = createEmptyPluginRegistry();
if (channel === "msteams") {
registry.channels.push({
pluginId: "@openclaw/msteams-plugin",
source: "test",
plugin: {
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
} as never);
}
return registry;
},
);
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]);
mockMSTeamsRegistrySnapshot();
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
if (message === "Select a channel") {
const entries = options as Array<{ value: string; hint?: string }>;
@@ -463,22 +486,7 @@ describe("setupChannels", () => {
it("treats installed external plugin channels as installed without reinstall prompts", async () => {
setActivePluginRegistry(createEmptyPluginRegistry());
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "msteams",
pluginId: "@openclaw/msteams-plugin",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
install: {
npmSpec: "@openclaw/msteams",
},
} satisfies ChannelPluginCatalogEntry,
]);
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([createMSTeamsCatalogEntry()]);
manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
@@ -488,45 +496,7 @@ describe("setupChannels", () => {
],
diagnostics: [],
});
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockImplementation(
({ channel }: { channel: string }) => {
const registry = createEmptyPluginRegistry();
if (channel === "msteams") {
registry.channelSetups.push({
pluginId: "@openclaw/msteams-plugin",
source: "test",
plugin: {
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
setupWizard: {
channel: "msteams",
status: {
configuredLabel: "configured",
unconfiguredLabel: "installed",
resolveConfigured: () => false,
resolveStatusLines: async () => [],
resolveSelectionHint: async () => "installed",
},
credentials: [],
},
outbound: { deliveryMode: "direct" },
},
} as never);
}
return registry;
},
);
mockMSTeamsRegistrySnapshot({ includeSetupWizard: true });
let channelSelectionCount = 0;
const select = vi.fn(async ({ message }: { message: string }) => {