mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 18:33:37 +00:00
test: share cli command and discord test helpers
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
65
src/cli/browser-cli-manage.test-helpers.ts
Normal file
65
src/cli/browser-cli-manage.test-helpers.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
15
src/commands/doctor-service-audit.test-helpers.ts
Normal file
15
src/commands/doctor-service-audit.test-helpers.ts
Normal 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;
|
||||
}
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user