mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 18:24:47 +00:00
354 lines
11 KiB
TypeScript
354 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createChannelApprovalHandlerFromCapability,
|
|
createLazyChannelApprovalNativeRuntimeAdapter,
|
|
} from "./approval-handler-runtime.js";
|
|
import {
|
|
createApprovalNativeRuntimeAdapterStubs,
|
|
type ApprovalNativeRuntimeAdapterStubParams,
|
|
} from "./approval-handler.test-helpers.js";
|
|
import type { ExecApprovalRequest } from "./exec-approvals.js";
|
|
|
|
type ApprovalCapability = NonNullable<
|
|
Parameters<typeof createChannelApprovalHandlerFromCapability>[0]["capability"]
|
|
>;
|
|
type ApprovalNativeAdapter = NonNullable<ApprovalCapability["native"]>;
|
|
|
|
const TEST_HANDLER_PARAMS = {
|
|
label: "test/approval-handler",
|
|
clientDisplayName: "Test Approval Handler",
|
|
channel: "test",
|
|
channelLabel: "Test",
|
|
cfg: { channels: {} } as never,
|
|
} as const;
|
|
|
|
function makeSequentialPendingDeliveryMock() {
|
|
return vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ messageId: "1" })
|
|
.mockResolvedValueOnce({ messageId: "2" });
|
|
}
|
|
|
|
function makeSequentialPendingBindingMock() {
|
|
return vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ bindingId: "bound-1" })
|
|
.mockResolvedValueOnce({ bindingId: "bound-2" });
|
|
}
|
|
|
|
function makeExecApprovalRequest(id: string): ExecApprovalRequest {
|
|
return {
|
|
id,
|
|
expiresAtMs: Date.now() + 60_000,
|
|
request: {
|
|
command: "echo hi",
|
|
turnSourceChannel: "test",
|
|
turnSourceTo: "origin-chat",
|
|
},
|
|
createdAtMs: Date.now(),
|
|
};
|
|
}
|
|
|
|
function makeNativeApprovalCapability(
|
|
params: {
|
|
preferredSurface?: ReturnType<
|
|
ApprovalNativeAdapter["describeDeliveryCapabilities"]
|
|
>["preferredSurface"];
|
|
supportsApproverDmSurface?: boolean;
|
|
resolveApproverDmTargets?: ApprovalNativeAdapter["resolveApproverDmTargets"];
|
|
} & ApprovalNativeRuntimeAdapterStubParams = {},
|
|
): ApprovalCapability {
|
|
const preferredSurface = params.preferredSurface ?? "origin";
|
|
return {
|
|
native: {
|
|
describeDeliveryCapabilities: vi.fn().mockReturnValue({
|
|
enabled: true,
|
|
preferredSurface,
|
|
supportsOriginSurface: true,
|
|
supportsApproverDmSurface: params.supportsApproverDmSurface ?? false,
|
|
notifyOriginWhenDmOnly: false,
|
|
}),
|
|
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
|
|
...(params.resolveApproverDmTargets
|
|
? { resolveApproverDmTargets: params.resolveApproverDmTargets }
|
|
: {}),
|
|
},
|
|
nativeRuntime: createApprovalNativeRuntimeAdapterStubs(params),
|
|
};
|
|
}
|
|
|
|
function createTestApprovalHandler(capability: ApprovalCapability) {
|
|
return createChannelApprovalHandlerFromCapability({
|
|
capability,
|
|
...TEST_HANDLER_PARAMS,
|
|
});
|
|
}
|
|
|
|
describe("createChannelApprovalHandlerFromCapability", () => {
|
|
it("returns null when the capability does not expose a native runtime", async () => {
|
|
await expect(
|
|
createChannelApprovalHandlerFromCapability({
|
|
capability: {},
|
|
...TEST_HANDLER_PARAMS,
|
|
}),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("returns a runtime when the capability exposes a native runtime", async () => {
|
|
const runtime = await createChannelApprovalHandlerFromCapability({
|
|
capability: {
|
|
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(),
|
|
},
|
|
},
|
|
},
|
|
...TEST_HANDLER_PARAMS,
|
|
});
|
|
|
|
expect(runtime).not.toBeNull();
|
|
});
|
|
|
|
it("preserves the original request and resolved approval kind when stop-time cleanup unbinds", async () => {
|
|
const unbindPending = vi.fn();
|
|
const runtime = await createTestApprovalHandler(
|
|
makeNativeApprovalCapability({
|
|
resolveApprovalKind: vi.fn().mockReturnValue("plugin"),
|
|
unbindPending,
|
|
}),
|
|
);
|
|
|
|
expect(runtime).not.toBeNull();
|
|
const request = {
|
|
id: "custom:1",
|
|
expiresAtMs: Date.now() + 60_000,
|
|
request: {
|
|
turnSourceChannel: "test",
|
|
turnSourceTo: "origin-chat",
|
|
},
|
|
} as never;
|
|
|
|
await runtime?.handleRequested(request);
|
|
await runtime?.stop();
|
|
|
|
expect(unbindPending).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
request,
|
|
approvalKind: "plugin",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("ignores duplicate pending request ids before finalization", async () => {
|
|
const unbindPending = vi.fn();
|
|
const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
|
|
const runtime = await createTestApprovalHandler(
|
|
makeNativeApprovalCapability({
|
|
buildResolvedResult,
|
|
deliverPending: makeSequentialPendingDeliveryMock(),
|
|
bindPending: makeSequentialPendingBindingMock(),
|
|
unbindPending,
|
|
}),
|
|
);
|
|
|
|
expect(runtime).not.toBeNull();
|
|
const request = makeExecApprovalRequest("exec:1");
|
|
|
|
await runtime?.handleRequested(request);
|
|
await runtime?.handleRequested(request);
|
|
await runtime?.handleResolved({
|
|
id: "exec:1",
|
|
decision: "approved",
|
|
resolvedBy: "operator",
|
|
} as never);
|
|
|
|
expect(unbindPending).toHaveBeenCalledTimes(1);
|
|
expect(unbindPending).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
entry: { messageId: "1" },
|
|
binding: { bindingId: "bound-1" },
|
|
request,
|
|
}),
|
|
);
|
|
expect(buildResolvedResult).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("continues finalization cleanup after one resolved entry unbind failure", async () => {
|
|
const unbindPending = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("unbind failed"))
|
|
.mockResolvedValueOnce(undefined);
|
|
const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
|
|
const runtime = await createTestApprovalHandler(
|
|
makeNativeApprovalCapability({
|
|
preferredSurface: "both",
|
|
supportsApproverDmSurface: true,
|
|
resolveApproverDmTargets: vi.fn().mockResolvedValue([{ to: "approver-dm" }]),
|
|
buildResolvedResult,
|
|
prepareTarget: vi.fn().mockImplementation(async ({ plannedTarget }) => ({
|
|
dedupeKey: String(plannedTarget.target.to),
|
|
target: { to: plannedTarget.target.to },
|
|
})),
|
|
deliverPending: makeSequentialPendingDeliveryMock(),
|
|
bindPending: makeSequentialPendingBindingMock(),
|
|
unbindPending,
|
|
}),
|
|
);
|
|
|
|
const request = makeExecApprovalRequest("exec:2");
|
|
|
|
await runtime?.handleRequested(request);
|
|
await expect(
|
|
runtime?.handleResolved({
|
|
id: "exec:2",
|
|
decision: "approved",
|
|
resolvedBy: "operator",
|
|
} as never),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(unbindPending).toHaveBeenCalledTimes(2);
|
|
expect(buildResolvedResult).toHaveBeenCalledTimes(1);
|
|
expect(buildResolvedResult).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
entry: { messageId: "2" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("continues stop-time unbind cleanup when one binding throws", async () => {
|
|
const unbindPending = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("unbind failed"))
|
|
.mockResolvedValueOnce(undefined);
|
|
const runtime = await createTestApprovalHandler(
|
|
makeNativeApprovalCapability({
|
|
deliverPending: makeSequentialPendingDeliveryMock(),
|
|
bindPending: makeSequentialPendingBindingMock(),
|
|
unbindPending,
|
|
}),
|
|
);
|
|
|
|
const request = makeExecApprovalRequest("exec:stop-1");
|
|
|
|
await runtime?.handleRequested(request);
|
|
await runtime?.handleRequested({
|
|
...request,
|
|
id: "exec:stop-2",
|
|
});
|
|
|
|
await expect(runtime?.stop()).resolves.toBeUndefined();
|
|
expect(unbindPending).toHaveBeenCalledTimes(2);
|
|
await expect(runtime?.stop()).resolves.toBeUndefined();
|
|
expect(unbindPending).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("createLazyChannelApprovalNativeRuntimeAdapter", () => {
|
|
it("loads the runtime lazily and reuses the loaded adapter", async () => {
|
|
const explicitIsConfigured = vi.fn().mockReturnValue(true);
|
|
const explicitShouldHandle = vi.fn().mockReturnValue(false);
|
|
const buildPendingPayload = vi.fn().mockResolvedValue({ text: "pending" });
|
|
const load = vi.fn().mockResolvedValue({
|
|
availability: {
|
|
isConfigured: vi.fn(),
|
|
shouldHandle: vi.fn(),
|
|
},
|
|
presentation: {
|
|
buildPendingPayload,
|
|
buildResolvedResult: vi.fn(),
|
|
buildExpiredResult: vi.fn(),
|
|
},
|
|
transport: {
|
|
prepareTarget: vi.fn(),
|
|
deliverPending: vi.fn(),
|
|
},
|
|
});
|
|
const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
|
|
eventKinds: ["exec"],
|
|
isConfigured: explicitIsConfigured,
|
|
shouldHandle: explicitShouldHandle,
|
|
load,
|
|
});
|
|
const cfg = { channels: {} } as never;
|
|
const request = { id: "exec:1" } as never;
|
|
const view = {} as never;
|
|
|
|
expect(adapter.eventKinds).toEqual(["exec"]);
|
|
expect(adapter.availability.isConfigured({ cfg })).toBe(true);
|
|
expect(adapter.availability.shouldHandle({ cfg, request })).toBe(false);
|
|
await expect(
|
|
adapter.presentation.buildPendingPayload({
|
|
cfg,
|
|
request,
|
|
approvalKind: "exec",
|
|
nowMs: 1,
|
|
view,
|
|
}),
|
|
).resolves.toEqual({ text: "pending" });
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
expect(explicitIsConfigured).toHaveBeenCalledWith({ cfg });
|
|
expect(explicitShouldHandle).toHaveBeenCalledWith({ cfg, request });
|
|
expect(buildPendingPayload).toHaveBeenCalledWith({
|
|
cfg,
|
|
request,
|
|
approvalKind: "exec",
|
|
nowMs: 1,
|
|
view,
|
|
});
|
|
});
|
|
|
|
it("keeps observe hooks synchronous and only uses the already-loaded runtime", async () => {
|
|
const onDelivered = vi.fn();
|
|
const load = vi.fn().mockResolvedValue({
|
|
availability: {
|
|
isConfigured: vi.fn(),
|
|
shouldHandle: vi.fn(),
|
|
},
|
|
presentation: {
|
|
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
|
|
buildResolvedResult: vi.fn(),
|
|
buildExpiredResult: vi.fn(),
|
|
},
|
|
transport: {
|
|
prepareTarget: vi.fn(),
|
|
deliverPending: vi.fn(),
|
|
},
|
|
observe: {
|
|
onDelivered,
|
|
},
|
|
});
|
|
const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
|
|
isConfigured: vi.fn().mockReturnValue(true),
|
|
shouldHandle: vi.fn().mockReturnValue(true),
|
|
load,
|
|
});
|
|
|
|
adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
|
|
expect(load).not.toHaveBeenCalled();
|
|
expect(onDelivered).not.toHaveBeenCalled();
|
|
|
|
await adapter.presentation.buildPendingPayload({
|
|
cfg: {} as never,
|
|
request: { id: "exec:1" } as never,
|
|
approvalKind: "exec",
|
|
nowMs: 1,
|
|
view: {} as never,
|
|
});
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
|
|
adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
|
|
expect(onDelivered).toHaveBeenCalledWith({ request: { id: "exec:1" } });
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|