Files
openclaw/src/infra/approval-handler-bootstrap.test.ts
2026-04-08 09:58:22 +01:00

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