browser-commander 0.31.2 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66,3 +66,28 @@ export function useTempDirectories(prefix) {
66
66
  return directory;
67
67
  };
68
68
  }
69
+
70
+ /**
71
+ * Wait until a file a background process writes has the expected text.
72
+ *
73
+ * @param {string} file - File to poll
74
+ * @param {string} expected - Text to wait for
75
+ * @param {number} [timeout=5000] - How long to poll, in milliseconds
76
+ * @returns {Promise<boolean>} Whether the text appeared in time
77
+ */
78
+ export async function waitForFileText(file, expected, timeout = 5000) {
79
+ const deadline = Date.now() + timeout;
80
+ while (Date.now() < deadline) {
81
+ const text = await fs.readFile(file, 'utf8').catch((error) => {
82
+ if (error.code !== 'ENOENT') {
83
+ throw error;
84
+ }
85
+ return null;
86
+ });
87
+ if (text === expected) {
88
+ return true;
89
+ }
90
+ await new Promise((resolve) => setTimeout(resolve, 50));
91
+ }
92
+ return false;
93
+ }
@@ -0,0 +1,406 @@
1
+ // feature-parity: sessions.handoff@native-typed
2
+ import { afterEach, describe, it } from 'node:test';
3
+ import assert from 'node:assert';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { access, mkdtemp, rm, writeFile } from 'node:fs/promises';
7
+
8
+ import {
9
+ BROWSER_HANDOFF_VERSION,
10
+ BrowserHandoffError,
11
+ closeHandedOffBrowser,
12
+ installHandoff,
13
+ reconnectBrowserWithDependencies,
14
+ waitForPidExit,
15
+ } from '../../../src/browser/browser-handoff.js';
16
+ import { launchAndConnectRealBrowserWithDependencies } from '../../../src/browser/real-browser.js';
17
+ import { startProcess } from '../../../src/utilities/subprocess.js';
18
+ import * as api from '../../../src/index.js';
19
+ import { startFakeCdpEndpoint } from '../../helpers/fake-cdp-endpoint.js';
20
+
21
+ const cleanups = [];
22
+ afterEach(async () => {
23
+ for (const cleanup of cleanups.splice(0).reverse()) {
24
+ await cleanup();
25
+ }
26
+ });
27
+
28
+ async function fakeEndpoint(options) {
29
+ const endpoint = await startFakeCdpEndpoint(options);
30
+ cleanups.push(() => endpoint.close());
31
+ return endpoint;
32
+ }
33
+
34
+ function handoffFor(endpoint, overrides = {}) {
35
+ return {
36
+ version: BROWSER_HANDOFF_VERSION,
37
+ engine: 'selenium',
38
+ cdpEndpoint: endpoint.endpoint,
39
+ webSocketDebuggerUrl: endpoint.webSocketDebuggerUrl,
40
+ targetId: 'TARGET-1',
41
+ pid: null,
42
+ userDataDir: null,
43
+ temporaryProfile: false,
44
+ remoteDebuggingPort: null,
45
+ executablePath: null,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ /** A connectBrowser stand-in returning a Selenium-shaped connection. */
51
+ function fakeConnect(calls, { targetId = 'TARGET-1' } = {}) {
52
+ return async (options) => {
53
+ calls.push(['connect', options]);
54
+ return {
55
+ browser: {
56
+ getWindowHandle: async () => `CDwindow-${targetId}`,
57
+ },
58
+ page: { id: 'page' },
59
+ detach: async () => calls.push(['engine-detach']),
60
+ };
61
+ };
62
+ }
63
+
64
+ /** A long-running process in its own process group, like a browser. */
65
+ function sleeper(t, script = 'setInterval(()=>{},1000)') {
66
+ const child = startProcess(process.execPath, ['-e', script], {
67
+ interruption: 'caller',
68
+ });
69
+ cleanups.push(async () => {
70
+ child.kill('SIGKILL');
71
+ await child.exited;
72
+ });
73
+ return child;
74
+ }
75
+
76
+ // These tests stop real processes with POSIX signals and exit codes.
77
+ const posixSignals = {
78
+ skip: process.platform === 'win32' && 'POSIX signals and exit codes',
79
+ };
80
+
81
+ describe('browser hand-off (issue #174)', () => {
82
+ it('is exported from the package API', () => {
83
+ assert.equal(typeof api.reconnectBrowser, 'function');
84
+ assert.equal(api.BrowserHandoffError, BrowserHandoffError);
85
+ assert.equal(api.BROWSER_HANDOFF_VERSION, BROWSER_HANDOFF_VERSION);
86
+ });
87
+
88
+ it('reattaches to the exact tab and never falls back to another page', async () => {
89
+ const endpoint = await fakeEndpoint();
90
+ const calls = [];
91
+ const session = await reconnectBrowserWithDependencies(
92
+ JSON.stringify(handoffFor(endpoint)),
93
+ { verbose: false },
94
+ { connect: fakeConnect(calls) }
95
+ );
96
+ assert.deepEqual(calls, [
97
+ [
98
+ 'connect',
99
+ {
100
+ verbose: false,
101
+ engine: 'selenium',
102
+ cdpEndpoint: endpoint.endpoint,
103
+ targetId: 'TARGET-1',
104
+ fallback: false,
105
+ },
106
+ ],
107
+ ]);
108
+ assert.equal(session.cdpEndpoint, endpoint.endpoint);
109
+ assert.deepEqual(await session.handoff(), handoffFor(endpoint));
110
+ });
111
+
112
+ it('reads a hand-off from a JSON file', async () => {
113
+ const endpoint = await fakeEndpoint();
114
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-handoff-'));
115
+ cleanups.push(() => rm(directory, { recursive: true, force: true }));
116
+ const file = path.join(directory, 'handoff.json');
117
+ await writeFile(file, JSON.stringify(handoffFor(endpoint)));
118
+ const calls = [];
119
+ await reconnectBrowserWithDependencies(
120
+ file,
121
+ {},
122
+ {
123
+ connect: fakeConnect(calls),
124
+ }
125
+ );
126
+ assert.equal(calls[0][1].targetId, 'TARGET-1');
127
+ });
128
+
129
+ it('refuses a browser that is gone or replaced instead of launching one', async () => {
130
+ const endpoint = await fakeEndpoint();
131
+ const handoff = handoffFor(endpoint);
132
+ const connect = () => assert.fail('connect reached');
133
+ endpoint.setBrowserId('browser-2');
134
+ await assert.rejects(
135
+ reconnectBrowserWithDependencies(handoff, {}, { connect }),
136
+ (error) =>
137
+ error instanceof BrowserHandoffError &&
138
+ /another browser instance/.test(error.message)
139
+ );
140
+ await endpoint.close();
141
+ await assert.rejects(
142
+ reconnectBrowserWithDependencies(handoff, {}, { connect }),
143
+ /no longer answers/
144
+ );
145
+ });
146
+
147
+ it('reports a closed tab as a hand-off error', async () => {
148
+ const endpoint = await fakeEndpoint();
149
+ await assert.rejects(
150
+ reconnectBrowserWithDependencies(
151
+ handoffFor(endpoint),
152
+ {},
153
+ {
154
+ connect: async () => {
155
+ throw new Error('No page with target id TARGET-1');
156
+ },
157
+ }
158
+ ),
159
+ (error) =>
160
+ error instanceof BrowserHandoffError &&
161
+ /Could not reattach to tab TARGET-1/.test(error.message) &&
162
+ error.cause.message === 'No page with target id TARGET-1'
163
+ );
164
+ });
165
+
166
+ it('validates the hand-off before touching the network', async () => {
167
+ const connect = () => assert.fail('connect reached');
168
+ await assert.rejects(
169
+ reconnectBrowserWithDependencies(
170
+ { version: 1, cdpEndpoint: 'http://127.0.0.1:1' },
171
+ {},
172
+ { connect }
173
+ ),
174
+ /missing webSocketDebuggerUrl/
175
+ );
176
+ await assert.rejects(
177
+ reconnectBrowserWithDependencies(
178
+ {
179
+ version: 2,
180
+ cdpEndpoint: 'http://127.0.0.1:1',
181
+ webSocketDebuggerUrl: 'ws://127.0.0.1:1/devtools/browser/x',
182
+ targetId: 'x',
183
+ },
184
+ {},
185
+ { connect }
186
+ ),
187
+ /Unsupported browser hand-off version 2/
188
+ );
189
+ });
190
+
191
+ it('detach() hands off once, drops the engine connection, and releases the process', async () => {
192
+ const endpoint = await fakeEndpoint();
193
+ const calls = [];
194
+ const state = {};
195
+ const session = installHandoff(
196
+ {
197
+ browser: { getWindowHandle: async () => 'CDwindow-TAB' },
198
+ page: {},
199
+ detach: async () => calls.push('engine-detach'),
200
+ },
201
+ {
202
+ engine: 'selenium',
203
+ cdpEndpoint: endpoint.endpoint,
204
+ pid: () => 1234,
205
+ connectedBrowser: () => ({ getWindowHandle: async () => 'TAB' }),
206
+ temporaryProfile: true,
207
+ userDataDir: '/tmp/profile',
208
+ release: () => calls.push('release'),
209
+ },
210
+ state
211
+ );
212
+ assert.equal(session.detached, false);
213
+ assert.equal(Object.keys({ ...session }).includes('detached'), false);
214
+ const [first, second] = await Promise.all([
215
+ session.detach(),
216
+ session.detach(),
217
+ ]);
218
+ assert.equal(first, second);
219
+ assert.deepEqual(calls, ['engine-detach', 'release']);
220
+ assert.equal(session.detached, true);
221
+ assert.equal(state.webSocketDebuggerUrl, endpoint.webSocketDebuggerUrl);
222
+ assert.deepEqual(JSON.parse(JSON.stringify(first)), {
223
+ version: BROWSER_HANDOFF_VERSION,
224
+ engine: 'selenium',
225
+ cdpEndpoint: endpoint.endpoint,
226
+ webSocketDebuggerUrl: endpoint.webSocketDebuggerUrl,
227
+ targetId: 'TAB',
228
+ pid: 1234,
229
+ userDataDir: '/tmp/profile',
230
+ temporaryProfile: true,
231
+ remoteDebuggingPort: null,
232
+ executablePath: null,
233
+ });
234
+ assert.equal(await session.handoff(), first);
235
+ });
236
+
237
+ it(
238
+ 'close() of a reconnected session closes the browser and awaits its process',
239
+ posixSignals,
240
+ async (t) => {
241
+ const child = sleeper(t);
242
+ const endpoint = await fakeEndpoint({
243
+ onBrowserClose: () => child.kill('SIGTERM'),
244
+ });
245
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-handoff-'));
246
+ cleanups.push(() => rm(directory, { recursive: true, force: true }));
247
+ const session = await reconnectBrowserWithDependencies(
248
+ handoffFor(endpoint, {
249
+ pid: child.pid,
250
+ userDataDir: directory,
251
+ temporaryProfile: true,
252
+ }),
253
+ {},
254
+ { connect: fakeConnect([]) }
255
+ );
256
+ await session.close();
257
+ assert.equal(endpoint.closes.length, 1);
258
+ assert.equal(await child.exited, 128 + 15);
259
+ await assert.rejects(access(directory), { code: 'ENOENT' });
260
+ // Idempotent.
261
+ await session.close();
262
+ assert.equal(endpoint.closes.length, 1);
263
+ }
264
+ );
265
+
266
+ it(
267
+ 'kills the process tree when Browser.close does not stop it',
268
+ posixSignals,
269
+ async (t) => {
270
+ const child = sleeper(t);
271
+ const endpoint = await fakeEndpoint();
272
+ await closeHandedOffBrowser(handoffFor(endpoint, { pid: child.pid }), {
273
+ closeTimeout: 300,
274
+ });
275
+ assert.equal(endpoint.closes.length, 1);
276
+ assert.equal(await child.exited, 128 + 9);
277
+ }
278
+ );
279
+
280
+ it(
281
+ 'never closes a different browser that took over the endpoint',
282
+ posixSignals,
283
+ async (t) => {
284
+ const child = sleeper(t);
285
+ const endpoint = await fakeEndpoint();
286
+ const handoff = handoffFor(endpoint, { pid: child.pid });
287
+ endpoint.setBrowserId('someone-else');
288
+ await closeHandedOffBrowser(handoff, { closeTimeout: 200 });
289
+ assert.equal(endpoint.closes.length, 0);
290
+ assert.equal(await child.exited, 128 + 9);
291
+ }
292
+ );
293
+
294
+ it('waitForPidExit reports whether a process ended in time', async (t) => {
295
+ const child = sleeper(t);
296
+ assert.equal(await waitForPidExit(child.pid, 100), false);
297
+ child.kill('SIGKILL');
298
+ assert.equal(await waitForPidExit(child.pid, 2000), true);
299
+ });
300
+ });
301
+
302
+ describe('launch hand-off (issue #174)', () => {
303
+ function fakeBrowserProcess(calls) {
304
+ const listeners = [];
305
+ const browserProcess = {
306
+ pid: 4321,
307
+ exitCode: null,
308
+ kill: () => {
309
+ calls.push('kill');
310
+ browserProcess.exit(143);
311
+ },
312
+ detach: () => calls.push('process-detach'),
313
+ once: (_event, listener) => {
314
+ if (browserProcess.exitCode === null) {
315
+ listeners.push(listener);
316
+ } else {
317
+ listener(browserProcess.exitCode);
318
+ }
319
+ },
320
+ exit: (code) => {
321
+ browserProcess.exitCode = code;
322
+ for (const listener of listeners.splice(0)) {
323
+ listener(code);
324
+ }
325
+ },
326
+ };
327
+ return browserProcess;
328
+ }
329
+
330
+ async function launch({ interruption } = {}) {
331
+ const calls = [];
332
+ const browserProcess = fakeBrowserProcess(calls);
333
+ const endpoint = await fakeEndpoint({
334
+ onBrowserClose: () => browserProcess.exit(0),
335
+ });
336
+ const cdp = {
337
+ send: async () => ({ targetInfo: { targetId: 'LAUNCHED-TAB' } }),
338
+ detach: async () => {},
339
+ };
340
+ const session = await launchAndConnectRealBrowserWithDependencies(
341
+ {
342
+ engine: 'puppeteer',
343
+ channel: 'chrome',
344
+ ...(interruption ? { interruption } : {}),
345
+ },
346
+ {
347
+ resolveExecutable: async () => '/opt/google/chrome',
348
+ reservePort: async () => 9555,
349
+ spawnBrowser: (_path, _args, options) => {
350
+ calls.push(['spawn', options.interruption]);
351
+ return browserProcess;
352
+ },
353
+ waitForEndpoint: async () => endpoint.endpoint,
354
+ connect: async (options) => {
355
+ calls.push(['connect', 'interruption' in options]);
356
+ return {
357
+ browser: {
358
+ close: async () => {
359
+ calls.push('engine-close');
360
+ browserProcess.exit(0);
361
+ },
362
+ },
363
+ page: { target: () => ({ createCDPSession: async () => cdp }) },
364
+ detach: async () => calls.push('engine-detach'),
365
+ };
366
+ },
367
+ }
368
+ );
369
+ return { session, calls, endpoint, browserProcess };
370
+ }
371
+
372
+ it('passes interruption ownership to the spawn, not to the engine', async () => {
373
+ const managed = await launch();
374
+ assert.deepEqual(managed.calls.slice(0, 2), [
375
+ ['spawn', 'managed'],
376
+ ['connect', false],
377
+ ]);
378
+ await managed.session.close();
379
+ const caller = await launch({ interruption: 'caller' });
380
+ assert.deepEqual(caller.calls[0], ['spawn', 'caller']);
381
+ await caller.session.close();
382
+ await assert.rejects(launch({ interruption: 'detached' }), TypeError);
383
+ });
384
+
385
+ it('detach() leaves the browser running and returns its hand-off', async () => {
386
+ const { session, calls, endpoint, browserProcess } = await launch();
387
+ const handoff = await session.detach();
388
+ assert.equal(handoff.targetId, 'LAUNCHED-TAB');
389
+ assert.equal(handoff.pid, 4321);
390
+ assert.equal(handoff.remoteDebuggingPort, 9555);
391
+ assert.equal(handoff.temporaryProfile, true);
392
+ assert.equal(handoff.webSocketDebuggerUrl, endpoint.webSocketDebuggerUrl);
393
+ assert.deepEqual(calls.slice(2), ['engine-detach', 'process-detach']);
394
+ assert.equal(browserProcess.exitCode, null);
395
+ await access(handoff.userDataDir);
396
+
397
+ // The launch still owns the browser: close() closes it over its
398
+ // WebSocket (the engine connection is gone) and awaits the process.
399
+ await session.close();
400
+ assert.equal(endpoint.closes.length, 1);
401
+ assert.equal(browserProcess.exitCode, 0);
402
+ assert.equal(calls.includes('engine-close'), false);
403
+ assert.equal(calls.includes('kill'), false);
404
+ await assert.rejects(access(handoff.userDataDir), { code: 'ENOENT' });
405
+ });
406
+ });
@@ -135,7 +135,10 @@ describe('profile directory', () => {
135
135
  }
136
136
  );
137
137
  assert.deepEqual(await readPreferences(dir), {
138
- browser: { check_default_browser: false },
138
+ browser: {
139
+ check_default_browser: false,
140
+ session_restore_infobar_times_shown: 5,
141
+ },
139
142
  });
140
143
  } finally {
141
144
  await removeUserDataDir(dir);
@@ -181,7 +184,11 @@ describe('profile directory', () => {
181
184
  localState: { browser: { extra: 1 } },
182
185
  });
183
186
  assert.deepEqual(await readPreferences(dir), {
184
- browser: { check_default_browser: false, show_home_button: true },
187
+ browser: {
188
+ check_default_browser: false,
189
+ session_restore_infobar_times_shown: 5,
190
+ show_home_button: true,
191
+ },
185
192
  intl: { accept_languages: 'en' },
186
193
  });
187
194
  assert.deepEqual(
@@ -232,6 +232,29 @@ describe('launchBrowser', () => {
232
232
  await result.close();
233
233
  assert.deepEqual(calls.at(-1), ['browser.close']);
234
234
  });
235
+
236
+ it("leaves Ctrl+C to the caller with interruption: 'caller' (#174)", async () => {
237
+ for (const [interruption, expected] of [
238
+ [undefined, undefined],
239
+ ['managed', undefined],
240
+ ['caller', false],
241
+ ]) {
242
+ const calls = [];
243
+ const result = await launchBrowserWithDependencies(
244
+ { launch: 'engine', interruption },
245
+ { loadEngineModule: async () => fakePlaywright(calls), settleMs: 0 }
246
+ );
247
+ assert.equal(calls[0][2].handleSIGINT, expected);
248
+ await result.close();
249
+ }
250
+ await assert.rejects(
251
+ launchBrowserWithDependencies(
252
+ { launch: 'engine', interruption: 'nobody' },
253
+ { loadEngineModule: async () => fakePlaywright([]), settleMs: 0 }
254
+ ),
255
+ /interruption must be one of managed, caller/
256
+ );
257
+ });
235
258
  });
236
259
  });
237
260
 
@@ -266,7 +266,7 @@ describe('launchAndConnectRealBrowser', () => {
266
266
  '--remote-debugging-port=9444',
267
267
  'about:blank',
268
268
  ],
269
- { env: undefined, verbose: false },
269
+ { env: undefined, interruption: 'managed', verbose: false },
270
270
  ]);
271
271
  assert.deepEqual(calls[1], ['wait', 9444]);
272
272
  assert.deepEqual(calls[2], [