-
Notifications
You must be signed in to change notification settings - Fork 146
Expand file tree
/
Copy pathExtensionChannel.test.ts
More file actions
263 lines (220 loc) · 8.53 KB
/
ExtensionChannel.test.ts
File metadata and controls
263 lines (220 loc) · 8.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Socket } from "socket.io-client"
import {
type TaskProviderLike,
type TaskProviderEvents,
RooCodeEventName,
ExtensionBridgeEventName,
ExtensionSocketEvents,
} from "@roo-code/types"
import { ExtensionChannel } from "../ExtensionChannel.js"
describe("ExtensionChannel", () => {
let mockSocket: Socket
let mockProvider: TaskProviderLike
let extensionChannel: ExtensionChannel
const instanceId = "test-instance-123"
const userId = "test-user-456"
// Track registered event listeners
const eventListeners = new Map<keyof TaskProviderEvents, Set<(...args: unknown[]) => unknown>>()
beforeEach(() => {
// Reset the event listeners tracker
eventListeners.clear()
// Create mock socket
mockSocket = {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
disconnect: vi.fn(),
} as unknown as Socket
// Create mock provider with event listener tracking
mockProvider = {
cwd: "/test/workspace",
appProperties: {
version: "1.0.0",
extensionVersion: "1.0.0",
},
gitProperties: undefined,
getCurrentTask: vi.fn().mockReturnValue(undefined),
getCurrentTaskStack: vi.fn().mockReturnValue([]),
getRecentTasks: vi.fn().mockReturnValue([]),
createTask: vi.fn(),
cancelTask: vi.fn(),
clearTask: vi.fn(),
resumeTask: vi.fn(),
getState: vi.fn(),
postStateToWebview: vi.fn(),
postMessageToWebview: vi.fn(),
getTelemetryProperties: vi.fn(),
getMode: vi.fn().mockResolvedValue("code"),
getModes: vi.fn().mockResolvedValue([
{ slug: "code", name: "Code", description: "Code mode" },
{ slug: "architect", name: "Architect", description: "Architect mode" },
]),
getProviderProfile: vi.fn().mockResolvedValue("default"),
getProviderProfiles: vi.fn().mockResolvedValue([{ name: "default", description: "Default profile" }]),
on: vi.fn((event: keyof TaskProviderEvents, listener: (...args: unknown[]) => unknown) => {
if (!eventListeners.has(event)) {
eventListeners.set(event, new Set())
}
eventListeners.get(event)!.add(listener)
return mockProvider
}),
off: vi.fn((event: keyof TaskProviderEvents, listener: (...args: unknown[]) => unknown) => {
const listeners = eventListeners.get(event)
if (listeners) {
listeners.delete(listener)
if (listeners.size === 0) {
eventListeners.delete(event)
}
}
return mockProvider
}),
} as unknown as TaskProviderLike
// Create extension channel instance
extensionChannel = new ExtensionChannel(instanceId, userId, mockProvider)
})
afterEach(() => {
vi.clearAllMocks()
})
describe("Event Listener Management", () => {
it("should register event listeners on initialization", () => {
// Verify that listeners were registered for all expected events
const expectedEvents: RooCodeEventName[] = [
RooCodeEventName.TaskCreated,
RooCodeEventName.TaskStarted,
RooCodeEventName.TaskCompleted,
RooCodeEventName.TaskAborted,
RooCodeEventName.TaskFocused,
RooCodeEventName.TaskUnfocused,
RooCodeEventName.TaskActive,
RooCodeEventName.TaskInteractive,
RooCodeEventName.TaskResumable,
RooCodeEventName.TaskIdle,
RooCodeEventName.TaskUserMessage,
]
// Check that on() was called for each event
expect(mockProvider.on).toHaveBeenCalledTimes(expectedEvents.length)
// Verify each event was registered
expectedEvents.forEach((eventName) => {
expect(mockProvider.on).toHaveBeenCalledWith(eventName, expect.any(Function))
})
// Verify listeners are tracked in our Map
expect(eventListeners.size).toBe(expectedEvents.length)
})
it("should remove all event listeners during cleanup", async () => {
// Verify initial state - listeners are registered
const initialListenerCount = eventListeners.size
expect(initialListenerCount).toBeGreaterThan(0)
// Get the count of listeners for each event before cleanup
const listenerCountsBefore = new Map<keyof TaskProviderEvents, number>()
eventListeners.forEach((listeners, event) => {
listenerCountsBefore.set(event, listeners.size)
})
// Perform cleanup
await extensionChannel.cleanup(mockSocket)
// Verify that off() was called for each registered event
expect(mockProvider.off).toHaveBeenCalledTimes(initialListenerCount)
// Verify all listeners were removed from our tracking Map
expect(eventListeners.size).toBe(0)
// Verify that the same listener functions that were added were removed
const onCalls = (mockProvider.on as any).mock.calls
const offCalls = (mockProvider.off as any).mock.calls
// Each on() call should have a corresponding off() call with the same listener
onCalls.forEach(([eventName, listener]: [keyof TaskProviderEvents, any]) => {
const hasMatchingOff = offCalls.some(
([offEvent, offListener]: [keyof TaskProviderEvents, any]) =>
offEvent === eventName && offListener === listener,
)
expect(hasMatchingOff).toBe(true)
})
})
it("should not have duplicate listeners after multiple channel creations", () => {
// Create a second channel with the same provider
const secondChannel = new ExtensionChannel("instance-2", userId, mockProvider)
// Each event should have exactly 2 listeners (one from each channel)
eventListeners.forEach((listeners) => {
expect(listeners.size).toBe(2)
})
// Clean up the first channel
extensionChannel.cleanup(mockSocket)
// Each event should now have exactly 1 listener (from the second channel)
eventListeners.forEach((listeners) => {
expect(listeners.size).toBe(1)
})
// Clean up the second channel
secondChannel.cleanup(mockSocket)
// All listeners should be removed
expect(eventListeners.size).toBe(0)
})
it("should handle cleanup even if called multiple times", async () => {
// First cleanup
await extensionChannel.cleanup(mockSocket)
const firstOffCallCount = (mockProvider.off as any).mock.calls.length
// Second cleanup (should be safe to call again)
await extensionChannel.cleanup(mockSocket)
const secondOffCallCount = (mockProvider.off as any).mock.calls.length
// The second cleanup shouldn't try to remove listeners again
// since the internal Map was cleared
expect(secondOffCallCount).toBe(firstOffCallCount)
})
it("should properly forward events to socket when listeners are triggered", async () => {
// Connect the socket to enable publishing
await extensionChannel.onConnect(mockSocket)
// Clear the mock calls from the connection (which emits a register event)
;(mockSocket.emit as any).mockClear()
// Get a listener that was registered for TaskStarted
const taskStartedListeners = eventListeners.get(RooCodeEventName.TaskStarted)
expect(taskStartedListeners).toBeDefined()
expect(taskStartedListeners!.size).toBe(1)
// Trigger the listener
const listener = Array.from(taskStartedListeners!)[0]
if (listener) {
await listener("test-task-id")
}
// Verify the event was published to the socket
expect(mockSocket.emit).toHaveBeenCalledWith(
ExtensionSocketEvents.EVENT,
expect.objectContaining({
type: ExtensionBridgeEventName.TaskStarted,
instance: expect.objectContaining({
instanceId,
userId,
}),
timestamp: expect.any(Number),
}),
undefined,
)
})
})
describe("Memory Leak Prevention", () => {
it("should not accumulate listeners over multiple connect/disconnect cycles", async () => {
// Simulate multiple connect/disconnect cycles
for (let i = 0; i < 5; i++) {
await extensionChannel.onConnect(mockSocket)
extensionChannel.onDisconnect()
}
// Listeners should still be the same count (not accumulated)
const expectedEventCount = 11 // Number of events we listen to (including TaskUserMessage)
expect(eventListeners.size).toBe(expectedEventCount)
// Each event should have exactly 1 listener
eventListeners.forEach((listeners) => {
expect(listeners.size).toBe(1)
})
})
it("should properly clean up heartbeat interval", async () => {
// Spy on setInterval and clearInterval
const setIntervalSpy = vi.spyOn(global, "setInterval")
const clearIntervalSpy = vi.spyOn(global, "clearInterval")
// Connect to start heartbeat
await extensionChannel.onConnect(mockSocket)
expect(setIntervalSpy).toHaveBeenCalled()
// Get the interval ID
const intervalId = setIntervalSpy.mock.results[0]?.value
// Cleanup should stop the heartbeat
await extensionChannel.cleanup(mockSocket)
expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId)
setIntervalSpy.mockRestore()
clearIntervalSpy.mockRestore()
})
})
})