mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-15 11:11:09 +00:00
262 lines
8.4 KiB
TypeScript
262 lines
8.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createRuntimeChannel } from "../plugins/runtime/runtime-channel.js";
|
|
import { startChannelApprovalHandlerBootstrap } from "./approval-handler-bootstrap.js";
|
|
|
|
const { createChannelApprovalHandlerFromCapability } = vi.hoisted(() => ({
|
|
createChannelApprovalHandlerFromCapability: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./approval-handler-runtime.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./approval-handler-runtime.js")>(
|
|
"./approval-handler-runtime.js",
|
|
);
|
|
return {
|
|
...actual,
|
|
createChannelApprovalHandlerFromCapability,
|
|
};
|
|
});
|
|
|
|
describe("startChannelApprovalHandlerBootstrap", () => {
|
|
beforeEach(() => {
|
|
createChannelApprovalHandlerFromCapability.mockReset();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
const flushTransitions = async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
};
|
|
|
|
const createApprovalPlugin = () =>
|
|
({
|
|
id: "slack",
|
|
meta: { label: "Slack" },
|
|
approvalCapability: {
|
|
nativeRuntime: {
|
|
availability: {
|
|
isConfigured: vi.fn().mockReturnValue(true),
|
|
shouldHandle: vi.fn().mockReturnValue(true),
|
|
},
|
|
presentation: {
|
|
buildPendingPayload: vi.fn(),
|
|
buildResolvedResult: vi.fn(),
|
|
buildExpiredResult: vi.fn(),
|
|
},
|
|
transport: {
|
|
prepareTarget: vi.fn(),
|
|
deliverPending: vi.fn(),
|
|
},
|
|
},
|
|
},
|
|
}) as never;
|
|
|
|
const startTestBootstrap = (params: {
|
|
channelRuntime: ReturnType<typeof createRuntimeChannel>;
|
|
logger?: unknown;
|
|
}) =>
|
|
startChannelApprovalHandlerBootstrap({
|
|
plugin: createApprovalPlugin(),
|
|
cfg: {} as never,
|
|
accountId: "default",
|
|
channelRuntime: params.channelRuntime,
|
|
logger: params.logger as never,
|
|
});
|
|
|
|
const registerApprovalContext = (
|
|
channelRuntime: ReturnType<typeof createRuntimeChannel>,
|
|
app: unknown = { ok: true },
|
|
) =>
|
|
channelRuntime.runtimeContexts.register({
|
|
channelId: "slack",
|
|
accountId: "default",
|
|
capability: "approval.native",
|
|
context: { app },
|
|
});
|
|
|
|
it("starts and stops the shared approval handler from runtime context registration", async () => {
|
|
const channelRuntime = createRuntimeChannel();
|
|
const start = vi.fn().mockResolvedValue(undefined);
|
|
const stop = vi.fn().mockResolvedValue(undefined);
|
|
createChannelApprovalHandlerFromCapability.mockResolvedValue({
|
|
start,
|
|
stop,
|
|
});
|
|
|
|
const cleanup = await startTestBootstrap({ channelRuntime });
|
|
|
|
const lease = registerApprovalContext(channelRuntime);
|
|
await flushTransitions();
|
|
|
|
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalled();
|
|
expect(start).toHaveBeenCalledTimes(1);
|
|
|
|
lease.dispose();
|
|
await flushTransitions();
|
|
|
|
expect(stop).toHaveBeenCalledTimes(1);
|
|
|
|
await cleanup();
|
|
});
|
|
|
|
it("starts immediately when the runtime context was already registered", async () => {
|
|
const channelRuntime = createRuntimeChannel();
|
|
const start = vi.fn().mockResolvedValue(undefined);
|
|
const stop = vi.fn().mockResolvedValue(undefined);
|
|
createChannelApprovalHandlerFromCapability.mockResolvedValue({
|
|
start,
|
|
stop,
|
|
});
|
|
|
|
const lease = registerApprovalContext(channelRuntime);
|
|
|
|
const cleanup = await startTestBootstrap({ channelRuntime });
|
|
|
|
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(1);
|
|
expect(start).toHaveBeenCalledTimes(1);
|
|
|
|
await cleanup();
|
|
expect(stop).toHaveBeenCalledTimes(1);
|
|
lease.dispose();
|
|
});
|
|
|
|
it("does not start a handler after the runtime context is unregistered mid-boot", async () => {
|
|
const channelRuntime = createRuntimeChannel();
|
|
let resolveRuntime:
|
|
| ((value: { start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn> }) => void)
|
|
| undefined;
|
|
const runtimePromise = new Promise<{
|
|
start: ReturnType<typeof vi.fn>;
|
|
stop: ReturnType<typeof vi.fn>;
|
|
}>((resolve) => {
|
|
resolveRuntime = resolve;
|
|
});
|
|
createChannelApprovalHandlerFromCapability.mockReturnValue(runtimePromise);
|
|
|
|
const cleanup = await startTestBootstrap({ channelRuntime });
|
|
|
|
const lease = registerApprovalContext(channelRuntime);
|
|
await flushTransitions();
|
|
|
|
const start = vi.fn().mockResolvedValue(undefined);
|
|
const stop = vi.fn().mockResolvedValue(undefined);
|
|
|
|
lease.dispose();
|
|
resolveRuntime?.({ start, stop });
|
|
await flushTransitions();
|
|
|
|
expect(start).not.toHaveBeenCalled();
|
|
expect(stop).toHaveBeenCalledTimes(1);
|
|
|
|
await cleanup();
|
|
});
|
|
|
|
it("restarts the shared approval handler when the runtime context is replaced", async () => {
|
|
const channelRuntime = createRuntimeChannel();
|
|
const startFirst = vi.fn().mockResolvedValue(undefined);
|
|
const stopFirst = vi.fn().mockResolvedValue(undefined);
|
|
const startSecond = vi.fn().mockResolvedValue(undefined);
|
|
const stopSecond = vi.fn().mockResolvedValue(undefined);
|
|
createChannelApprovalHandlerFromCapability
|
|
.mockResolvedValueOnce({
|
|
start: startFirst,
|
|
stop: stopFirst,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
start: startSecond,
|
|
stop: stopSecond,
|
|
});
|
|
|
|
const cleanup = await startTestBootstrap({ channelRuntime });
|
|
|
|
const firstLease = registerApprovalContext(channelRuntime, { ok: "first" });
|
|
await flushTransitions();
|
|
|
|
const secondLease = registerApprovalContext(channelRuntime, { ok: "second" });
|
|
await flushTransitions();
|
|
|
|
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(2);
|
|
expect(startFirst).toHaveBeenCalledTimes(1);
|
|
expect(stopFirst).toHaveBeenCalledTimes(1);
|
|
expect(startSecond).toHaveBeenCalledTimes(1);
|
|
|
|
secondLease.dispose();
|
|
await flushTransitions();
|
|
|
|
expect(stopSecond).toHaveBeenCalledTimes(1);
|
|
|
|
firstLease.dispose();
|
|
await cleanup();
|
|
});
|
|
|
|
it("retries registered-context startup failures until the handler starts", async () => {
|
|
vi.useFakeTimers();
|
|
const channelRuntime = createRuntimeChannel();
|
|
const start = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(undefined);
|
|
const stop = vi.fn().mockResolvedValue(undefined);
|
|
const logger = {
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
child: vi.fn(),
|
|
isEnabled: vi.fn().mockReturnValue(true),
|
|
isVerboseEnabled: vi.fn().mockReturnValue(false),
|
|
verbose: vi.fn(),
|
|
};
|
|
createChannelApprovalHandlerFromCapability
|
|
.mockResolvedValueOnce({ start, stop })
|
|
.mockResolvedValueOnce({ start, stop });
|
|
|
|
const cleanup = await startTestBootstrap({ channelRuntime, logger });
|
|
|
|
registerApprovalContext(channelRuntime);
|
|
await flushTransitions();
|
|
|
|
expect(start).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await flushTransitions();
|
|
|
|
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(2);
|
|
expect(start).toHaveBeenCalledTimes(2);
|
|
expect(stop).toHaveBeenCalledTimes(1);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
"failed to start native approval handler: Error: boom",
|
|
);
|
|
|
|
await cleanup();
|
|
});
|
|
|
|
it("does not let a stale retry stop a newer active handler", async () => {
|
|
vi.useFakeTimers();
|
|
const channelRuntime = createRuntimeChannel();
|
|
const firstStart = vi.fn().mockRejectedValueOnce(new Error("boom"));
|
|
const firstStop = vi.fn().mockResolvedValue(undefined);
|
|
const secondStart = vi.fn().mockResolvedValue(undefined);
|
|
const secondStop = vi.fn().mockResolvedValue(undefined);
|
|
createChannelApprovalHandlerFromCapability
|
|
.mockResolvedValueOnce({ start: firstStart, stop: firstStop })
|
|
.mockResolvedValueOnce({ start: secondStart, stop: secondStop })
|
|
.mockResolvedValueOnce({ start: secondStart, stop: secondStop });
|
|
|
|
const cleanup = await startTestBootstrap({ channelRuntime });
|
|
|
|
registerApprovalContext(channelRuntime, { ok: "first" });
|
|
await flushTransitions();
|
|
expect(firstStart).toHaveBeenCalledTimes(1);
|
|
|
|
registerApprovalContext(channelRuntime, { ok: "second" });
|
|
await flushTransitions();
|
|
expect(secondStart).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await flushTransitions();
|
|
|
|
expect(firstStop).toHaveBeenCalledTimes(1);
|
|
expect(secondStart).toHaveBeenCalledTimes(1);
|
|
expect(secondStop).not.toHaveBeenCalled();
|
|
|
|
await cleanup();
|
|
});
|
|
});
|