
























@@ -0,0 +1,236 @@
1+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2+import { OpenClawChannelBridge } from "./channel-bridge.js";
3+4+const ONE_MINUTE_MS = 60 * 1_000;
5+const ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
6+const SWEEP_INTERVAL_MS = 5 * ONE_MINUTE_MS;
7+const APPROVAL_DEFAULT_TTL_MS = 30 * ONE_MINUTE_MS;
8+9+// Test view that exposes the private map/timer fields and the methods we
10+// exercise. Defined as a standalone shape (not an intersection with the class)
11+// because mixing public/private constituents collapses to `never` under tsgo.
12+type BridgeInternals = {
13+pendingClaudePermissions: Map<string, unknown>;
14+pendingApprovals: Map<string, unknown>;
15+pendingSweepInterval: NodeJS.Timeout | null;
16+handleClaudePermissionRequest: (params: {
17+requestId: string;
18+toolName: string;
19+description: string;
20+inputPreview: string;
21+}) => Promise<void>;
22+handleGatewayEvent: (event: {
23+event: string;
24+payload?: Record<string, unknown>;
25+}) => Promise<void>;
26+listPendingApprovals: () => unknown[];
27+close: () => Promise<void>;
28+};
29+30+function makeBridge(): BridgeInternals {
31+return new OpenClawChannelBridge({} as never, {
32+claudeChannelMode: "off",
33+verbose: false,
34+}) as unknown as BridgeInternals;
35+}
36+37+describe("OpenClawChannelBridge — pendingClaudePermissions / pendingApprovals memory bounds", () => {
38+beforeEach(() => {
39+vi.useFakeTimers();
40+vi.setSystemTime(0);
41+});
42+43+afterEach(async () => {
44+vi.useRealTimers();
45+});
46+47+test("handleClaudePermissionRequest entries are evicted after TTL by the sweeper", async () => {
48+const bridge = makeBridge();
49+try {
50+await bridge.handleClaudePermissionRequest({
51+requestId: "abcde",
52+toolName: "Bash",
53+description: "run npm test",
54+inputPreview: "{}",
55+});
56+expect(bridge.pendingClaudePermissions.size).toBe(1);
57+expect(bridge.pendingSweepInterval).not.toBeNull();
58+59+vi.advanceTimersByTime(SWEEP_INTERVAL_MS);
60+expect(bridge.pendingClaudePermissions.size).toBe(1);
61+62+vi.advanceTimersByTime(ONE_HOUR_MS);
63+expect(bridge.pendingClaudePermissions.size).toBe(0);
64+} finally {
65+await bridge.close();
66+}
67+});
68+69+test("trackApproval entries are evicted at expiresAtMs by the sweeper", async () => {
70+const bridge = makeBridge();
71+try {
72+await bridge.handleGatewayEvent({
73+event: "exec.approval.requested",
74+payload: {
75+id: "approval-1",
76+createdAtMs: 0,
77+expiresAtMs: 10 * ONE_MINUTE_MS,
78+},
79+});
80+expect(bridge.pendingApprovals.size).toBe(1);
81+82+vi.advanceTimersByTime(SWEEP_INTERVAL_MS);
83+expect(bridge.pendingApprovals.size).toBe(1);
84+85+vi.advanceTimersByTime(SWEEP_INTERVAL_MS + ONE_MINUTE_MS);
86+expect(bridge.pendingApprovals.size).toBe(0);
87+} finally {
88+await bridge.close();
89+}
90+});
91+92+test("trackApproval falls back to a default TTL when expiresAtMs is absent", async () => {
93+const bridge = makeBridge();
94+try {
95+await bridge.handleGatewayEvent({
96+event: "plugin.approval.requested",
97+payload: { id: "approval-2", createdAtMs: 0 },
98+});
99+expect(bridge.pendingApprovals.size).toBe(1);
100+101+vi.advanceTimersByTime(APPROVAL_DEFAULT_TTL_MS - ONE_MINUTE_MS);
102+expect(bridge.pendingApprovals.size).toBe(1);
103+104+vi.advanceTimersByTime(SWEEP_INTERVAL_MS + ONE_MINUTE_MS);
105+expect(bridge.pendingApprovals.size).toBe(0);
106+} finally {
107+await bridge.close();
108+}
109+});
110+111+test("trackApproval evicts entries even when both createdAtMs and expiresAtMs are absent", async () => {
112+const bridge = makeBridge();
113+try {
114+await bridge.handleGatewayEvent({
115+event: "exec.approval.requested",
116+payload: { id: "approval-3" },
117+});
118+expect(bridge.pendingApprovals.size).toBe(1);
119+120+vi.advanceTimersByTime(APPROVAL_DEFAULT_TTL_MS - ONE_MINUTE_MS);
121+expect(bridge.pendingApprovals.size).toBe(1);
122+123+vi.advanceTimersByTime(SWEEP_INTERVAL_MS + ONE_MINUTE_MS);
124+expect(bridge.pendingApprovals.size).toBe(0);
125+} finally {
126+await bridge.close();
127+}
128+});
129+130+test("listPendingApprovals filters expired entries before the next sweep tick", async () => {
131+const bridge = makeBridge();
132+try {
133+await bridge.handleGatewayEvent({
134+event: "exec.approval.requested",
135+payload: {
136+id: "approval-early-expiry",
137+createdAtMs: 0,
138+expiresAtMs: ONE_MINUTE_MS,
139+},
140+});
141+expect(bridge.pendingApprovals.size).toBe(1);
142+143+vi.advanceTimersByTime(2 * ONE_MINUTE_MS);
144+145+expect(bridge.listPendingApprovals()).toHaveLength(0);
146+expect(bridge.pendingApprovals.size).toBe(0);
147+expect(bridge.pendingSweepInterval).toBeNull();
148+} finally {
149+await bridge.close();
150+}
151+});
152+153+test("close() clears both pending maps, stops the sweeper interval, and leaves no scheduled timers", async () => {
154+const bridge = makeBridge();
155+await bridge.handleClaudePermissionRequest({
156+requestId: "abcde",
157+toolName: "Bash",
158+description: "run npm test",
159+inputPreview: "{}",
160+});
161+await bridge.handleGatewayEvent({
162+event: "exec.approval.requested",
163+payload: { id: "approval-1", createdAtMs: 0, expiresAtMs: ONE_HOUR_MS },
164+});
165+expect(bridge.pendingClaudePermissions.size).toBe(1);
166+expect(bridge.pendingApprovals.size).toBe(1);
167+expect(bridge.pendingSweepInterval).not.toBeNull();
168+169+await bridge.close();
170+171+expect(bridge.pendingClaudePermissions.size).toBe(0);
172+expect(bridge.pendingApprovals.size).toBe(0);
173+expect(bridge.pendingSweepInterval).toBeNull();
174+expect(vi.getTimerCount()).toBe(0);
175+});
176+177+test("handleClaudePermissionRequest is a no-op after close(), preventing post-close accumulation", async () => {
178+const bridge = makeBridge();
179+await bridge.close();
180+181+await bridge.handleClaudePermissionRequest({
182+requestId: "fghij",
183+toolName: "Bash",
184+description: "after close",
185+inputPreview: "{}",
186+});
187+await bridge.handleGatewayEvent({
188+event: "exec.approval.requested",
189+payload: { id: "approval-after-close" },
190+});
191+192+expect(bridge.pendingClaudePermissions.size).toBe(0);
193+expect(bridge.pendingApprovals.size).toBe(0);
194+expect(bridge.pendingSweepInterval).toBeNull();
195+});
196+197+test("sweeper interval is not started before any pending entry is added", async () => {
198+const bridge = makeBridge();
199+try {
200+expect(bridge.pendingSweepInterval).toBeNull();
201+vi.advanceTimersByTime(SWEEP_INTERVAL_MS * 4);
202+expect(bridge.pendingSweepInterval).toBeNull();
203+} finally {
204+await bridge.close();
205+}
206+});
207+208+test("sweeper self-terminates once both maps drain, restoring lazy-init", async () => {
209+const bridge = makeBridge();
210+try {
211+await bridge.handleClaudePermissionRequest({
212+requestId: "abcde",
213+toolName: "Bash",
214+description: "run npm test",
215+inputPreview: "{}",
216+});
217+expect(bridge.pendingSweepInterval).not.toBeNull();
218+219+vi.advanceTimersByTime(ONE_HOUR_MS + SWEEP_INTERVAL_MS);
220+expect(bridge.pendingClaudePermissions.size).toBe(0);
221+expect(bridge.pendingApprovals.size).toBe(0);
222+expect(bridge.pendingSweepInterval).toBeNull();
223+expect(vi.getTimerCount()).toBe(0);
224+225+await bridge.handleClaudePermissionRequest({
226+requestId: "fghij",
227+toolName: "Bash",
228+description: "second request after drain",
229+inputPreview: "{}",
230+});
231+expect(bridge.pendingSweepInterval).not.toBeNull();
232+} finally {
233+await bridge.close();
234+}
235+});
236+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。