Skip to content

Commit a571f70

Browse files
authored
RSDK-12206 Clear dial timeout to terminate signal exchange on error/success (#661)
1 parent b636d72 commit a571f70

File tree

5 files changed

+921
-44
lines changed

5 files changed

+921
-44
lines changed

src/robot/client.spec.ts

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
// @vitest-environment happy-dom
2+
3+
import {
4+
beforeEach,
5+
afterEach,
6+
describe,
7+
expect,
8+
it,
9+
vi,
10+
type MockInstance,
11+
} from 'vitest';
12+
import type { Transport } from '@connectrpc/connect';
13+
import { createRouterTransport } from '@connectrpc/connect';
14+
import { RobotService } from '../gen/robot/v1/robot_connect';
15+
import { RobotClient } from './client';
16+
import * as rpcModule from '../rpc';
17+
18+
vi.mock('../rpc', async () => {
19+
const actual = await vi.importActual('../rpc');
20+
return {
21+
...actual,
22+
dialWebRTC: vi.fn(),
23+
dialDirect: vi.fn(),
24+
};
25+
});
26+
27+
describe('RobotClient', () => {
28+
let mockTransport: Transport;
29+
let mockPeerConnection: RTCPeerConnection;
30+
let mockDataChannel: RTCDataChannel;
31+
let client: RobotClient;
32+
33+
beforeEach(() => {
34+
mockTransport = createRouterTransport(({ service }) => {
35+
service(RobotService, {
36+
resourceNames: () => ({ resources: [] }),
37+
getOperations: () => ({ operations: [] }),
38+
});
39+
});
40+
41+
mockPeerConnection = {
42+
close: vi.fn(),
43+
addEventListener: vi.fn(),
44+
removeEventListener: vi.fn(),
45+
iceConnectionState: 'connected',
46+
} as unknown as RTCPeerConnection;
47+
48+
mockDataChannel = {
49+
close: vi.fn(),
50+
addEventListener: vi.fn(),
51+
removeEventListener: vi.fn(),
52+
readyState: 'open',
53+
} as unknown as RTCDataChannel;
54+
55+
vi.mocked(rpcModule.dialWebRTC).mockResolvedValue({
56+
transport: mockTransport,
57+
peerConnection: mockPeerConnection,
58+
dataChannel: mockDataChannel,
59+
});
60+
61+
client = new RobotClient();
62+
});
63+
64+
afterEach(() => {
65+
vi.clearAllMocks();
66+
});
67+
68+
describe('event listeners', () => {
69+
let pcAddEventListenerSpy: ReturnType<typeof vi.fn>;
70+
let pcRemoveEventListenerSpy: ReturnType<typeof vi.fn>;
71+
72+
let dcAddEventListenerSpy: ReturnType<typeof vi.fn>;
73+
let dcRemoveEventListenerSpy: ReturnType<typeof vi.fn>;
74+
75+
beforeEach(() => {
76+
pcAddEventListenerSpy = vi.fn();
77+
pcRemoveEventListenerSpy = vi.fn();
78+
dcAddEventListenerSpy = vi.fn();
79+
dcRemoveEventListenerSpy = vi.fn();
80+
81+
mockPeerConnection = {
82+
close: vi.fn(),
83+
addEventListener: pcAddEventListenerSpy,
84+
removeEventListener: pcRemoveEventListenerSpy,
85+
iceConnectionState: 'connected',
86+
} as unknown as RTCPeerConnection;
87+
88+
mockDataChannel = {
89+
close: vi.fn(),
90+
addEventListener: dcAddEventListenerSpy,
91+
removeEventListener: dcRemoveEventListenerSpy,
92+
readyState: 'open',
93+
} as unknown as RTCDataChannel;
94+
95+
vi.mocked(rpcModule.dialWebRTC).mockResolvedValue({
96+
transport: mockTransport,
97+
peerConnection: mockPeerConnection,
98+
dataChannel: mockDataChannel,
99+
});
100+
});
101+
102+
it.each([
103+
{
104+
eventType: 'iceconnectionstatechange',
105+
addSpy: () => pcAddEventListenerSpy,
106+
removeSpy: () => pcRemoveEventListenerSpy,
107+
description: 'peer connection iceconnectionstatechange',
108+
},
109+
{
110+
eventType: 'close',
111+
addSpy: () => dcAddEventListenerSpy,
112+
removeSpy: () => dcRemoveEventListenerSpy,
113+
description: 'data channel close',
114+
},
115+
{
116+
eventType: 'track',
117+
addSpy: () => pcAddEventListenerSpy,
118+
removeSpy: () => pcRemoveEventListenerSpy,
119+
description: 'peer connection track',
120+
},
121+
])(
122+
'should remove old $description handler before adding new one',
123+
async ({ eventType, addSpy, removeSpy }) => {
124+
await client.dial({
125+
host: 'test-host',
126+
signalingAddress: 'https://test.local',
127+
disableSessions: true,
128+
noReconnect: true,
129+
});
130+
131+
const firstCallArgs = addSpy().mock.calls.find(
132+
(call) => call[0] === eventType
133+
);
134+
135+
expect(firstCallArgs).toBeDefined();
136+
137+
const firstHandler = firstCallArgs?.[1];
138+
139+
addSpy().mockClear();
140+
removeSpy().mockClear();
141+
142+
// simulate reconnection
143+
await client.connect();
144+
145+
const removeCallArgs = removeSpy().mock.calls.find(
146+
(call) => call[0] === eventType
147+
);
148+
149+
const secondCallArgs = addSpy().mock.calls.find(
150+
(call) => call[0] === eventType
151+
);
152+
153+
expect(removeCallArgs).toBeDefined();
154+
expect(removeCallArgs?.[1]).toBe(firstHandler);
155+
expect(secondCallArgs).toBeDefined();
156+
}
157+
);
158+
159+
it.each([
160+
{
161+
eventType: 'iceconnectionstatechange',
162+
addSpy: () => pcAddEventListenerSpy,
163+
removeSpy: () => pcRemoveEventListenerSpy,
164+
description: 'iceconnectionstatechange',
165+
},
166+
{
167+
eventType: 'close',
168+
addSpy: () => dcAddEventListenerSpy,
169+
removeSpy: () => dcRemoveEventListenerSpy,
170+
description: 'data channel close',
171+
},
172+
{
173+
eventType: 'track',
174+
addSpy: () => pcAddEventListenerSpy,
175+
removeSpy: () => pcRemoveEventListenerSpy,
176+
description: 'track',
177+
},
178+
])(
179+
'should only have one $description handler at a time',
180+
async ({ eventType, addSpy, removeSpy }) => {
181+
await client.dial({
182+
host: 'test-host',
183+
signalingAddress: 'https://test.local',
184+
disableSessions: true,
185+
noReconnect: true,
186+
});
187+
188+
const firstConnectionCalls = addSpy().mock.calls.filter(
189+
(call) => call[0] === eventType
190+
);
191+
192+
expect(firstConnectionCalls).toHaveLength(1);
193+
194+
// simulate reconnection
195+
await client.connect();
196+
197+
const totalCalls = addSpy().mock.calls.filter(
198+
(call) => call[0] === eventType
199+
);
200+
const removeCalls = removeSpy().mock.calls.filter(
201+
(call) => call[0] === eventType
202+
);
203+
204+
expect(totalCalls).toHaveLength(2);
205+
expect(removeCalls).toHaveLength(1);
206+
}
207+
);
208+
209+
it('should not accumulate handlers over multiple reconnections', async () => {
210+
await client.dial({
211+
host: 'test-host',
212+
signalingAddress: 'https://test.local',
213+
disableSessions: true,
214+
noReconnect: true,
215+
});
216+
217+
for (let i = 0; i < 5; i += 1) {
218+
// eslint-disable-next-line no-await-in-loop
219+
await client.connect();
220+
}
221+
222+
const iceAddCalls = pcAddEventListenerSpy.mock.calls.filter(
223+
(call) => call[0] === 'iceconnectionstatechange'
224+
);
225+
const iceRemoveCalls = pcRemoveEventListenerSpy.mock.calls.filter(
226+
(call) => call[0] === 'iceconnectionstatechange'
227+
);
228+
229+
expect(iceAddCalls).toHaveLength(6);
230+
expect(iceRemoveCalls).toHaveLength(5);
231+
expect(iceAddCalls.length - iceRemoveCalls.length).toBe(1);
232+
});
233+
234+
it('should clean up all event handlers when disconnecting', async () => {
235+
await client.dial({
236+
host: 'test-host',
237+
signalingAddress: 'https://test.local',
238+
disableSessions: true,
239+
noReconnect: true,
240+
});
241+
242+
pcRemoveEventListenerSpy.mockClear();
243+
dcRemoveEventListenerSpy.mockClear();
244+
245+
await client.disconnect();
246+
247+
const iceRemoveCalls = pcRemoveEventListenerSpy.mock.calls.filter(
248+
(call) => call[0] === 'iceconnectionstatechange'
249+
);
250+
const trackRemoveCalls = pcRemoveEventListenerSpy.mock.calls.filter(
251+
(call) => call[0] === 'track'
252+
);
253+
254+
const dcRemoveCalls = dcRemoveEventListenerSpy.mock.calls.filter(
255+
(call) => call[0] === 'close'
256+
);
257+
258+
expect(iceRemoveCalls.length).toBeGreaterThanOrEqual(1);
259+
expect(trackRemoveCalls.length).toBeGreaterThanOrEqual(1);
260+
expect(dcRemoveCalls.length).toBeGreaterThanOrEqual(1);
261+
});
262+
});
263+
264+
describe('session management on reconnection', () => {
265+
let mockResetFn: MockInstance<[], void>;
266+
267+
const testCredential = {
268+
authEntity: 'test-entity',
269+
type: 'api-key' as const,
270+
payload: 'test-payload',
271+
};
272+
273+
const differentCredential = {
274+
authEntity: 'different-entity',
275+
type: 'api-key' as const,
276+
payload: 'different-payload',
277+
};
278+
279+
const accessToken = {
280+
type: 'access-token' as const,
281+
payload: 'test-access-token',
282+
};
283+
284+
const differentAccessToken = {
285+
type: 'access-token' as const,
286+
payload: 'different-access-token',
287+
};
288+
289+
beforeEach(() => {
290+
// Spy on the SessionManager's reset method to verify conditional reset behavior
291+
// eslint-disable-next-line vitest/no-restricted-vi-methods, @typescript-eslint/dot-notation
292+
mockResetFn = vi.spyOn(client['sessionManager'], 'reset');
293+
});
294+
295+
afterEach(() => {
296+
mockResetFn.mockRestore();
297+
});
298+
299+
it('should reset session when connecting for the first time', async () => {
300+
await client.dial({
301+
host: 'test-host',
302+
signalingAddress: 'https://test.local',
303+
credentials: testCredential,
304+
disableSessions: false,
305+
noReconnect: true,
306+
});
307+
308+
expect(mockResetFn).toHaveBeenCalledTimes(1);
309+
});
310+
311+
it.each([
312+
{
313+
description:
314+
'should reset session when credentials change during reconnection',
315+
initialCreds: testCredential,
316+
disableSessions: false,
317+
reconnectCreds: differentCredential,
318+
},
319+
{
320+
description: 'should reset session when sessions are disabled',
321+
initialCreds: testCredential,
322+
disableSessions: true,
323+
reconnectCreds: testCredential,
324+
},
325+
{
326+
description:
327+
'should reset session when reconnecting with no saved credentials',
328+
initialCreds: undefined,
329+
disableSessions: false,
330+
reconnectCreds: undefined,
331+
},
332+
{
333+
description:
334+
'should reset session when access token changes during reconnection',
335+
initialCreds: accessToken,
336+
disableSessions: false,
337+
reconnectCreds: differentAccessToken,
338+
},
339+
])(
340+
'$description',
341+
async ({ initialCreds, disableSessions, reconnectCreds }) => {
342+
await client.dial({
343+
host: 'test-host',
344+
signalingAddress: 'https://test.local',
345+
credentials: initialCreds,
346+
disableSessions,
347+
noReconnect: true,
348+
});
349+
350+
mockResetFn.mockClear();
351+
352+
await client.connect({ creds: reconnectCreds });
353+
354+
expect(mockResetFn).toHaveBeenCalledTimes(1);
355+
}
356+
);
357+
358+
it.each([
359+
{
360+
description:
361+
'should NOT reset session when reconnecting with same credentials',
362+
initialCreds: testCredential,
363+
reconnectCreds: testCredential,
364+
},
365+
{
366+
description:
367+
'should NOT reset session when reconnecting without explicitly passing creds (uses savedCreds)',
368+
initialCreds: testCredential,
369+
reconnectCreds: undefined,
370+
},
371+
{
372+
description:
373+
'should NOT reset session when using access token and reconnecting with same token',
374+
initialCreds: accessToken,
375+
reconnectCreds: accessToken,
376+
},
377+
])('$description', async ({ initialCreds, reconnectCreds }) => {
378+
await client.dial({
379+
host: 'test-host',
380+
signalingAddress: 'https://test.local',
381+
credentials: initialCreds,
382+
disableSessions: false,
383+
noReconnect: true,
384+
});
385+
386+
mockResetFn.mockClear();
387+
388+
await client.connect({ creds: reconnectCreds });
389+
390+
expect(mockResetFn).not.toHaveBeenCalled();
391+
});
392+
});
393+
});

0 commit comments

Comments
 (0)