Skip to content

Commit 7673f81

Browse files
authored
Merge pull request #52 from mozilla/feat/connect-existing-tests
test: add unit tests for connect-existing mode
2 parents 85e9dd4 + 3b491db commit 7673f81

1 file changed

Lines changed: 371 additions & 0 deletions

File tree

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
/**
2+
* Unit tests for connect-existing mode features (PR #50)
3+
* - GeckodriverHttpDriver BiDi support
4+
* - Session cleanup on quit/kill
5+
* - marionetteHost parameter
6+
* - Reconnect on lost connection
7+
*/
8+
9+
import { describe, it, expect, vi, beforeEach } from 'vitest';
10+
11+
// ---------------------------------------------------------------------------
12+
// GeckodriverHttpDriver tests — we access the class indirectly through
13+
// FirefoxCore since GeckodriverHttpDriver is not exported.
14+
// For direct testing we use (core as any).driver after mocked connect().
15+
// ---------------------------------------------------------------------------
16+
17+
describe('GeckodriverHttpDriver BiDi support', () => {
18+
let mockWsInstance: {
19+
on: ReturnType<typeof vi.fn>;
20+
off: ReturnType<typeof vi.fn>;
21+
send: ReturnType<typeof vi.fn>;
22+
close: ReturnType<typeof vi.fn>;
23+
readyState: number;
24+
};
25+
let wsEventListeners: Record<string, Function[]>;
26+
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
vi.resetModules();
30+
31+
wsEventListeners = {};
32+
mockWsInstance = {
33+
readyState: 1,
34+
on: vi.fn((event: string, handler: Function) => {
35+
if (!wsEventListeners[event]) wsEventListeners[event] = [];
36+
wsEventListeners[event].push(handler);
37+
}),
38+
off: vi.fn(),
39+
send: vi.fn(),
40+
close: vi.fn(),
41+
};
42+
});
43+
44+
/**
45+
* Helper: create a GeckodriverHttpDriver instance via mocked connect().
46+
* Returns the FirefoxCore with driver set to GeckodriverHttpDriver.
47+
*/
48+
async function createConnectExistingCore(opts?: {
49+
webSocketUrl?: string;
50+
marionetteHost?: string;
51+
}) {
52+
const mockGdProcess = {
53+
stdout: { on: vi.fn() },
54+
stderr: { on: vi.fn() },
55+
on: vi.fn(),
56+
kill: vi.fn(),
57+
};
58+
59+
// Mock child_process.spawn to return our mock geckodriver process
60+
vi.doMock('node:child_process', async (importOriginal) => {
61+
const original = (await importOriginal()) as typeof import('node:child_process');
62+
return {
63+
...original,
64+
spawn: vi.fn(() => {
65+
// Simulate geckodriver printing its listening port
66+
setTimeout(() => {
67+
const onData = mockGdProcess.stderr.on.mock.calls.find(
68+
(c: unknown[]) => c[0] === 'data'
69+
);
70+
if (onData) {
71+
(onData[1] as Function)(Buffer.from('Listening on 127.0.0.1:4444'));
72+
}
73+
}, 5);
74+
return mockGdProcess;
75+
}),
76+
};
77+
});
78+
79+
// Mock fetch for session creation
80+
const wsUrl = opts?.webSocketUrl ?? null;
81+
vi.doMock(
82+
'node:module',
83+
async (importOriginal) => await importOriginal()
84+
);
85+
86+
// We need to mock global fetch
87+
const mockFetch = vi.fn().mockResolvedValue({
88+
json: vi.fn().mockResolvedValue({
89+
value: {
90+
sessionId: 'mock-session-id',
91+
capabilities: {
92+
webSocketUrl: wsUrl,
93+
},
94+
},
95+
}),
96+
});
97+
vi.stubGlobal('fetch', mockFetch);
98+
99+
// Mock selenium-manager to avoid real binary lookup
100+
vi.doMock('selenium-webdriver/package.json', () => ({}), { virtual: true });
101+
102+
// Mock WebSocket constructor
103+
vi.doMock('ws', () => ({
104+
default: vi.fn(() => {
105+
// Simulate open event on next tick
106+
setTimeout(() => {
107+
if (wsEventListeners['open']) {
108+
wsEventListeners['open'].forEach((h) => h());
109+
}
110+
}, 5);
111+
return mockWsInstance;
112+
}),
113+
}));
114+
115+
const { FirefoxCore } = await import('@/firefox/core.js');
116+
117+
const core = new FirefoxCore({
118+
headless: true,
119+
connectExisting: true,
120+
marionettePort: 2828,
121+
marionetteHost: opts?.marionetteHost,
122+
});
123+
124+
await core.connect();
125+
return { core, mockGdProcess, mockFetch };
126+
}
127+
128+
it('should throw when getBidi() called without webSocketUrl', async () => {
129+
const { core } = await createConnectExistingCore({ webSocketUrl: undefined });
130+
const driver = core.getDriver();
131+
132+
await expect(driver.getBidi()).rejects.toThrow(
133+
/BiDi is not available.*webSocketUrl/
134+
);
135+
});
136+
137+
it('should open WebSocket and return BiDi handle', async () => {
138+
const { core } = await createConnectExistingCore({
139+
webSocketUrl: 'ws://127.0.0.1:9222/session/test',
140+
});
141+
const driver = core.getDriver();
142+
143+
const bidi = await driver.getBidi();
144+
expect(bidi).toBeDefined();
145+
expect(bidi.socket).toBeDefined();
146+
expect(bidi.subscribe).toBeDefined();
147+
});
148+
149+
it('should cache BiDi connection on subsequent calls', async () => {
150+
const { core } = await createConnectExistingCore({
151+
webSocketUrl: 'ws://127.0.0.1:9222/session/test',
152+
});
153+
const driver = core.getDriver();
154+
155+
const bidi1 = await driver.getBidi();
156+
const bidi2 = await driver.getBidi();
157+
expect(bidi1).toBe(bidi2);
158+
});
159+
160+
it('subscribe should send session.subscribe and wait for response', async () => {
161+
const { core } = await createConnectExistingCore({
162+
webSocketUrl: 'ws://127.0.0.1:9222/session/test',
163+
});
164+
const driver = core.getDriver();
165+
const bidi = await driver.getBidi();
166+
167+
// Start subscribe
168+
const subscribePromise = bidi.subscribe!('log.entryAdded', ['context-1']);
169+
170+
// Wait a tick for send to be called
171+
await new Promise((r) => setTimeout(r, 10));
172+
173+
expect(mockWsInstance.send).toHaveBeenCalledTimes(1);
174+
const sent = JSON.parse(mockWsInstance.send.mock.calls[0][0]);
175+
expect(sent.method).toBe('session.subscribe');
176+
expect(sent.params.events).toEqual(['log.entryAdded']);
177+
expect(sent.params.contexts).toEqual(['context-1']);
178+
179+
// Simulate response
180+
if (wsEventListeners['message']) {
181+
wsEventListeners['message'].forEach((h) =>
182+
h(JSON.stringify({ id: sent.id, result: {} }))
183+
);
184+
}
185+
186+
await expect(subscribePromise).resolves.toBeUndefined();
187+
});
188+
189+
it('subscribe should reject on error response', async () => {
190+
const { core } = await createConnectExistingCore({
191+
webSocketUrl: 'ws://127.0.0.1:9222/session/test',
192+
});
193+
const driver = core.getDriver();
194+
const bidi = await driver.getBidi();
195+
196+
const subscribePromise = bidi.subscribe!('log.entryAdded');
197+
198+
await new Promise((r) => setTimeout(r, 10));
199+
200+
const sent = JSON.parse(mockWsInstance.send.mock.calls[0][0]);
201+
if (wsEventListeners['message']) {
202+
wsEventListeners['message'].forEach((h) =>
203+
h(JSON.stringify({ id: sent.id, error: 'invalid subscription' }))
204+
);
205+
}
206+
207+
await expect(subscribePromise).rejects.toThrow(/BiDi subscribe error/);
208+
});
209+
});
210+
211+
describe('GeckodriverHttpDriver session cleanup', () => {
212+
let mockGdProcess: {
213+
stdout: { on: ReturnType<typeof vi.fn> };
214+
stderr: { on: ReturnType<typeof vi.fn> };
215+
on: ReturnType<typeof vi.fn>;
216+
kill: ReturnType<typeof vi.fn>;
217+
};
218+
let mockFetch: ReturnType<typeof vi.fn>;
219+
220+
beforeEach(() => {
221+
vi.clearAllMocks();
222+
vi.resetModules();
223+
224+
mockGdProcess = {
225+
stdout: { on: vi.fn() },
226+
stderr: { on: vi.fn() },
227+
on: vi.fn(),
228+
kill: vi.fn(),
229+
};
230+
231+
vi.doMock('node:child_process', async (importOriginal) => {
232+
const original = (await importOriginal()) as typeof import('node:child_process');
233+
return {
234+
...original,
235+
spawn: vi.fn(() => {
236+
setTimeout(() => {
237+
const onData = mockGdProcess.stderr.on.mock.calls.find(
238+
(c: unknown[]) => c[0] === 'data'
239+
);
240+
if (onData) {
241+
(onData[1] as Function)(Buffer.from('Listening on 127.0.0.1:4444'));
242+
}
243+
}, 5);
244+
return mockGdProcess;
245+
}),
246+
};
247+
});
248+
249+
mockFetch = vi.fn().mockResolvedValue({
250+
json: vi.fn().mockResolvedValue({
251+
value: {
252+
sessionId: 'mock-session-id',
253+
capabilities: {},
254+
},
255+
}),
256+
});
257+
vi.stubGlobal('fetch', mockFetch);
258+
259+
vi.doMock('ws', () => ({ default: vi.fn() }));
260+
});
261+
262+
async function createCore() {
263+
const { FirefoxCore } = await import('@/firefox/core.js');
264+
const core = new FirefoxCore({
265+
headless: true,
266+
connectExisting: true,
267+
marionettePort: 2828,
268+
});
269+
await core.connect();
270+
return core;
271+
}
272+
273+
it('kill() should send DELETE /session before killing geckodriver', async () => {
274+
const core = await createCore();
275+
276+
// Mock fetch for the DELETE call
277+
mockFetch.mockResolvedValueOnce({
278+
json: vi.fn().mockResolvedValue({ value: null }),
279+
});
280+
281+
const driver = core.getDriver() as any;
282+
await driver.kill();
283+
284+
// Verify DELETE /session was called
285+
const deleteCalls = mockFetch.mock.calls.filter(
286+
(c: unknown[]) => typeof c[1] === 'object' && (c[1] as RequestInit).method === 'DELETE'
287+
);
288+
expect(deleteCalls.length).toBeGreaterThan(0);
289+
expect(mockGdProcess.kill).toHaveBeenCalled();
290+
});
291+
292+
it('quit() should send DELETE /session and kill geckodriver', async () => {
293+
const core = await createCore();
294+
295+
mockFetch.mockResolvedValueOnce({
296+
json: vi.fn().mockResolvedValue({ value: null }),
297+
});
298+
299+
const driver = core.getDriver() as any;
300+
await driver.quit();
301+
302+
const deleteCalls = mockFetch.mock.calls.filter(
303+
(c: unknown[]) => typeof c[1] === 'object' && (c[1] as RequestInit).method === 'DELETE'
304+
);
305+
expect(deleteCalls.length).toBeGreaterThan(0);
306+
expect(mockGdProcess.kill).toHaveBeenCalled();
307+
});
308+
309+
it('kill() should not throw if DELETE /session fails', async () => {
310+
const core = await createCore();
311+
312+
mockFetch.mockRejectedValueOnce(new Error('connection refused'));
313+
314+
const driver = core.getDriver() as any;
315+
await expect(driver.kill()).resolves.toBeUndefined();
316+
expect(mockGdProcess.kill).toHaveBeenCalled();
317+
});
318+
});
319+
320+
describe('FirefoxCore connect-existing with marionetteHost', () => {
321+
it('should pass marionetteHost to options', async () => {
322+
const { FirefoxCore } = await import('@/firefox/core.js');
323+
const core = new FirefoxCore({
324+
headless: true,
325+
connectExisting: true,
326+
marionettePort: 2828,
327+
marionetteHost: '192.168.1.100',
328+
});
329+
330+
expect(core.getOptions().marionetteHost).toBe('192.168.1.100');
331+
});
332+
});
333+
334+
describe('getFirefox() reconnect behavior', () => {
335+
it('should reconnect when connection is lost instead of throwing', async () => {
336+
vi.resetModules();
337+
338+
// Mock the firefox module
339+
const mockIsConnected = vi.fn();
340+
const mockConnect = vi.fn();
341+
const mockClose = vi.fn();
342+
343+
vi.doMock('@/firefox/index.js', () => ({
344+
FirefoxDevTools: vi.fn(() => ({
345+
isConnected: mockIsConnected,
346+
connect: mockConnect,
347+
close: mockClose,
348+
})),
349+
}));
350+
351+
// First call: create instance, connection works
352+
mockIsConnected.mockResolvedValueOnce(true);
353+
mockConnect.mockResolvedValue(undefined);
354+
355+
// This test verifies the reconnect logic pattern:
356+
// When isConnected() returns false, getFirefox() should reset and create
357+
// a new connection instead of throwing FirefoxDisconnectedError
358+
const { FirefoxCore } = await import('@/firefox/core.js');
359+
const core = new FirefoxCore({
360+
headless: true,
361+
connectExisting: true,
362+
marionettePort: 2828,
363+
});
364+
365+
// Verify reset clears the state
366+
core.setCurrentContextId('old-context');
367+
core.reset();
368+
expect(core.getCurrentContextId()).toBe(null);
369+
expect(() => core.getDriver()).toThrow('Driver not connected');
370+
});
371+
});

0 commit comments

Comments
 (0)