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.
@@ -9,7 +9,9 @@ import { findBrowserSource } from './browser-sources.js';
9
9
  import { launchSafari } from './safari-launch.js';
10
10
  import { isSafariChannel } from './safari-support.js';
11
11
 
12
- import { startProcess } from '../utilities/subprocess.js';
12
+ import { interruptionMode, startProcess } from '../utilities/subprocess.js';
13
+ import { installHandoff } from './browser-handoff.js';
14
+ import { closeRemoteBrowser } from './cdp-browser-endpoint.js';
13
15
  import { withDeadline } from '../traces/deadline.js';
14
16
  import {
15
17
  applyAutomationParityArgs,
@@ -256,8 +258,16 @@ export async function waitForCdpEndpoint({
256
258
  }
257
259
  }
258
260
 
259
- function spawnWithStartProcess(executablePath, args, { env, verbose }) {
260
- return startProcess(executablePath, args, { env, forwardOutput: verbose });
261
+ function spawnWithStartProcess(
262
+ executablePath,
263
+ args,
264
+ { env, verbose, interruption }
265
+ ) {
266
+ return startProcess(executablePath, args, {
267
+ env,
268
+ forwardOutput: verbose,
269
+ interruption,
270
+ });
261
271
  }
262
272
 
263
273
  async function waitForExit(browserProcess, timeout) {
@@ -310,12 +320,20 @@ function createCloser({
310
320
  closeTimeout,
311
321
  closeConnection,
312
322
  verbose,
323
+ handoffState = {},
313
324
  }) {
314
325
  const originalClose = closeConnection ?? browser?.close;
315
326
  let closing;
316
327
  const close = () => {
317
328
  closing ??= (async () => {
318
- if (browserProcess.exitCode === null && originalClose) {
329
+ if (browserProcess.exitCode === null && handoffState.detached) {
330
+ // The engine connection was handed off; ask the browser directly.
331
+ await withDeadline(
332
+ closeRemoteBrowser(handoffState.webSocketDebuggerUrl),
333
+ closeTimeout,
334
+ 'browser close'
335
+ ).catch(() => {});
336
+ } else if (browserProcess.exitCode === null && originalClose) {
319
337
  // A signal can sever the engine transport while its close RPC is pending.
320
338
  // Keep the existing close budget active until owned-process cleanup runs.
321
339
  await withDeadline(
@@ -348,6 +366,14 @@ function createCloser({
348
366
  return close;
349
367
  }
350
368
 
369
+ /**
370
+ * Selenium attaches through a chromedriver the launch starts; the driver
371
+ * follows the browser's interruption ownership (issue #174).
372
+ */
373
+ function driverInterruption(engine, interruption) {
374
+ return engine === 'selenium' ? { interruption } : {};
375
+ }
376
+
351
377
  /**
352
378
  * Spawn a genuine installed Chrome-family browser with a dedicated profile,
353
379
  * wait for its loopback CDP endpoint, and attach through {@link connectBrowser}.
@@ -400,6 +426,7 @@ async function spawnOnFreePort({
400
426
  argOptions,
401
427
  env,
402
428
  verbose,
429
+ interruption,
403
430
  userDataDir,
404
431
  startupTimeout,
405
432
  dependencies,
@@ -421,6 +448,7 @@ async function spawnOnFreePort({
421
448
  browserProcess = await spawnBrowser(executablePath, browserArgs, {
422
449
  env,
423
450
  verbose: false,
451
+ interruption,
424
452
  });
425
453
  } catch (error) {
426
454
  throw launchFailure(error, {
@@ -650,8 +678,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
650
678
  preferences,
651
679
  localState,
652
680
  firstRun,
681
+ interruption: requestedInterruption,
653
682
  ...connectionOptions
654
683
  } = options;
684
+ const interruption = interruptionMode(requestedInterruption);
655
685
  const argOptions = {
656
686
  headless,
657
687
  restrictions,
@@ -731,6 +761,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
731
761
  argOptions,
732
762
  env: childEnv,
733
763
  verbose,
764
+ interruption,
734
765
  userDataDir,
735
766
  startupTimeout,
736
767
  dependencies,
@@ -756,9 +787,11 @@ export async function launchAndConnectRealBrowserWithDependencies(
756
787
  engine,
757
788
  cdpEndpoint: resolvedCdpEndpoint,
758
789
  ...connectionOptions,
790
+ ...driverInterruption(engine, interruption),
759
791
  seedCookies,
760
792
  verbose,
761
793
  });
794
+ const handoffState = { detached: false };
762
795
  const close = createCloser({
763
796
  engine,
764
797
  browser: connection.browser,
@@ -768,8 +801,9 @@ export async function launchAndConnectRealBrowserWithDependencies(
768
801
  closeTimeout,
769
802
  closeConnection: engine === 'selenium' ? connection.close : undefined,
770
803
  verbose,
804
+ handoffState,
771
805
  });
772
- return await installSessionPersistence(
806
+ const session = await installSessionPersistence(
773
807
  {
774
808
  ...connection,
775
809
  close,
@@ -785,6 +819,23 @@ export async function launchAndConnectRealBrowserWithDependencies(
785
819
  },
786
820
  options
787
821
  );
822
+ return installHandoff(
823
+ session,
824
+ {
825
+ engine,
826
+ cdpEndpoint: resolvedCdpEndpoint,
827
+ userDataDir,
828
+ temporaryProfile,
829
+ remoteDebuggingPort: launched.port,
830
+ executablePath,
831
+ pid: () => browserProcess.pid,
832
+ connectedBrowser: () => connection.browser,
833
+ // The browser outlives this controller: no SIGINT forwarding, and
834
+ // its handle no longer keeps this program running.
835
+ release: () => browserProcess.detach?.(),
836
+ },
837
+ handoffState
838
+ );
788
839
  } catch (error) {
789
840
  if (browserProcess.exitCode === null) {
790
841
  browserProcess.kill();
@@ -54,11 +54,15 @@ export async function installSessionPersistence(result, options) {
54
54
  const persistAndClose = () =>
55
55
  (closing ??= (async () => {
56
56
  try {
57
- const state = await saveStorageState(result.page);
58
- await writeRestrictedState(file, {
59
- cookies: state.cookies.filter((cookie) => !(cookie.expires > 0)),
60
- origins: [],
61
- });
57
+ // A handed-off session no longer has an engine connection to save
58
+ // from; the controller that reconnects owns persistence.
59
+ if (!result.detached) {
60
+ const state = await saveStorageState(result.page);
61
+ await writeRestrictedState(file, {
62
+ cookies: state.cookies.filter((cookie) => !(cookie.expires > 0)),
63
+ origins: [],
64
+ });
65
+ }
62
66
  } finally {
63
67
  await close();
64
68
  }
@@ -30,6 +30,7 @@ import path from 'node:path';
30
30
  import { createWebDriverPage } from '../core/webdriver-page.js';
31
31
  import { isVerboseEnabled } from '../core/logger.js';
32
32
  import {
33
+ interruptionMode,
33
34
  runCommand as defaultRunCommand,
34
35
  startProcess as defaultStartProcess,
35
36
  } from '../utilities/subprocess.js';
@@ -483,6 +484,7 @@ async function teardown({
483
484
  * @param {Object<string,string>} [options.env] - Extra environment for the driver and browser
484
485
  * @param {number} [options.startupTimeout=30000]
485
486
  * @param {boolean} [options.verbose=false] - Mirror the driver server's output
487
+ * @param {'managed'|'caller'} [options.interruption='managed'] - Who handles Ctrl+C for the driver server and the browser under it; see `startProcess`
486
488
  * @param {Object} [dependencies] - Injected selenium modules, runCommand, startProcess, fetch, reservePort, environment, platform (tests)
487
489
  * @returns {Promise<{driver: Object, page: Object, close: function(): Promise<void>, serverUrl: string, driverProcess: Object, driverPath: string, driverSource: string, browser: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, bidi: boolean}>}
488
490
  */
@@ -564,6 +566,7 @@ async function launchWebDriverOnce(options, dependencies) {
564
566
  startupTimeout = 30_000,
565
567
  verbose = false,
566
568
  debuggerAddress,
569
+ interruption,
567
570
  } = options;
568
571
  assertBrowser(browser);
569
572
  const safari = isSafariChannel(browser);
@@ -607,7 +610,11 @@ async function launchWebDriverOnce(options, dependencies) {
607
610
  driverProcess = startProcess(
608
611
  resolved.driverPath,
609
612
  buildDriverServerArgs(browser, port),
610
- { env: childEnv, forwardOutput: verbose }
613
+ {
614
+ env: childEnv,
615
+ forwardOutput: verbose,
616
+ interruption: interruptionMode(interruption),
617
+ }
611
618
  );
612
619
  for (const stream of [driverProcess.stdout, driverProcess.stderr]) {
613
620
  stream?.on('data', (chunk) => {
package/src/exports.js CHANGED
@@ -70,6 +70,12 @@ export {
70
70
  launchAndConnectRealBrowser,
71
71
  launchRealBrowser,
72
72
  } from './browser/real-browser.js';
73
+ export {
74
+ BROWSER_HANDOFF_VERSION,
75
+ BrowserHandoffError,
76
+ reconnectBrowser,
77
+ } from './browser/browser-handoff.js';
78
+ export { INTERRUPTION_MODES } from './utilities/subprocess.js';
73
79
  export {
74
80
  classifyDifferences,
75
81
  compareCommandLines,
@@ -1,3 +1,4 @@
1
+ import { constants as osConstants } from 'node:os';
1
2
  import { ProcessRunner } from 'command-stream/process-runner';
2
3
 
3
4
  /** Start an explicitly persistent process, with no parent-owned pipes or signal forwarding. */
@@ -8,15 +9,7 @@ export async function startDetachedProcess(file, args = [], options = {}) {
8
9
  runner.child.once('spawn', resolve);
9
10
  runner.child.once('error', reject);
10
11
  });
11
- const child = runner.child;
12
- // command-stream starts noninteractive children in their own process
13
- // group. Release its controller-owned cancellation registry while retaining
14
- // the child's exit listeners. Keep this adapter isolated until it exposes a
15
- // public detach operation; normal subprocesses retain managed cancellation.
16
- runner._child = null;
17
- runner._abortController = null;
18
- runner.finished = true;
19
- runner._cleanup();
12
+ const child = releaseRunner(runner);
20
13
  Promise.resolve(runner).catch(() => {});
21
14
  child.stderrTail = '';
22
15
  child.stderr?.on('data', (chunk) => {
@@ -25,11 +18,65 @@ export async function startDetachedProcess(file, args = [], options = {}) {
25
18
  child.on('error', (error) => {
26
19
  child.spawnError = error;
27
20
  });
28
- child.unref();
21
+ unrefChild(child);
22
+ return child;
23
+ }
24
+
25
+ /**
26
+ * Release a started runner from command-stream's process-wide cancellation
27
+ * registry and return its raw child. command-stream 2.x has no public per-run
28
+ * opt-out: every runner joins a global set whose SIGINT handler forwards the
29
+ * signal to the child's process group. command-stream starts noninteractive
30
+ * children in their own process group, so once released nothing but the caller
31
+ * signals the child. Its exit listeners are retained.
32
+ */
33
+ function releaseRunner(runner) {
34
+ const child = runner.child;
35
+ runner._child = null;
36
+ runner._abortController = null;
37
+ runner.finished = true;
38
+ runner._cleanup();
39
+ return child;
40
+ }
41
+
42
+ function unrefChild(child) {
43
+ child.unref?.();
29
44
  child.stdout?.unref?.();
30
45
  child.stderr?.unref?.();
31
46
  child.stdin?.unref?.();
32
- return child;
47
+ }
48
+
49
+ /** Who answers a terminal interrupt (SIGINT) for a started process. */
50
+ export const INTERRUPTION_MODES = Object.freeze(['managed', 'caller']);
51
+
52
+ /** Validate an `interruption` option, returning the effective mode. */
53
+ export function interruptionMode(value = 'managed') {
54
+ if (!INTERRUPTION_MODES.includes(value)) {
55
+ throw new TypeError(
56
+ `interruption must be one of ${INTERRUPTION_MODES.join(', ')}, got ${String(value)}`
57
+ );
58
+ }
59
+ return value;
60
+ }
61
+
62
+ function signalExitCode(signal) {
63
+ return 128 + (osConstants.signals[signal] ?? 1);
64
+ }
65
+
66
+ /** Signal a process and its process group, ignoring an already-gone one. */
67
+ export function signalProcessTree(pid, signal) {
68
+ let delivered = false;
69
+ for (const target of process.platform === 'win32' ? [pid] : [-pid, pid]) {
70
+ try {
71
+ process.kill(target, signal);
72
+ delivered = true;
73
+ } catch (error) {
74
+ if (error.code !== 'ESRCH') {
75
+ throw error;
76
+ }
77
+ }
78
+ }
79
+ return delivered;
33
80
  }
34
81
 
35
82
  /**
@@ -128,18 +175,27 @@ export async function runCommand(file, args = [], options = {}) {
128
175
  * rely on (`pid`, `exitCode`, `kill()`, `stderr`/`stdout` `data` events and an
129
176
  * `exit` event), plus an `exited` promise.
130
177
  *
178
+ * By default (`interruption: 'managed'`) command-stream owns interruption: its
179
+ * process-wide SIGINT handler forwards Ctrl+C to the child's process group.
180
+ * With `interruption: 'caller'` the child is released from that registry right
181
+ * after it starts, so the embedding program's own SIGINT handler decides
182
+ * whether to stop, detach or keep the child (issue #174). Output streaming,
183
+ * `exited`, and `kill()` (which still awaits the actual exit) keep working.
184
+ *
131
185
  * @param {string} file - Executable path
132
186
  * @param {string[]} args - Exact arguments
133
187
  * @param {Object} [options]
134
188
  * @param {Object<string,string>} [options.env] - Environment for the child only
135
189
  * @param {boolean} [options.forwardOutput=false] - Mirror the child's output to this process
136
190
  * @param {number} [options.killGrace=2000] - Milliseconds between SIGTERM and SIGKILL
191
+ * @param {'managed'|'caller'} [options.interruption='managed'] - Who answers SIGINT
137
192
  * @returns {ManagedProcess}
138
193
  */
139
194
  export function startProcess(file, args = [], options = {}) {
140
195
  const { env, cwd, forwardOutput = false, killGrace = 2000 } = options;
196
+ const interruption = interruptionMode(options.interruption);
141
197
  const runner = runnerFor(file, args, { env, cwd, killGrace, capture: false });
142
- return new ManagedProcess(runner, { forwardOutput });
198
+ return new ManagedProcess(runner, { forwardOutput, killGrace, interruption });
143
199
  }
144
200
 
145
201
  class OutputChannel {
@@ -168,7 +224,10 @@ class OutputChannel {
168
224
 
169
225
  /** Handle for a process started by {@link startProcess}. */
170
226
  export class ManagedProcess {
171
- constructor(runner, { forwardOutput }) {
227
+ constructor(
228
+ runner,
229
+ { forwardOutput, killGrace = 2000, interruption = 'managed' }
230
+ ) {
172
231
  this.runner = runner;
173
232
  this.exitCode = null;
174
233
  this.signalCode = null;
@@ -178,46 +237,120 @@ export class ManagedProcess {
178
237
  this.stderr = new OutputChannel();
179
238
  this.exitListeners = new Set();
180
239
  this.termination = null;
181
- runner.on('stdout', (chunk) => {
182
- if (forwardOutput) {
183
- process.stdout.write(chunk);
184
- }
185
- this.stdout.emit(chunk);
186
- });
187
- runner.on('stderr', (chunk) => {
188
- this.stderrTail = (this.stderrTail + String(chunk)).slice(-8192);
189
- if (forwardOutput) {
190
- process.stderr.write(chunk);
191
- }
192
- this.stderr.emit(chunk);
240
+ this.killGrace = killGrace;
241
+ this.interruption = interruption;
242
+ this.detached = false;
243
+ this.child = null;
244
+ this.forwardOutput = forwardOutput;
245
+ runner.on('stdout', (chunk) => this.receiveStdout(chunk));
246
+ runner.on('stderr', (chunk) => this.receiveStderr(chunk));
247
+ let settle;
248
+ const settled = new Promise((resolve) => {
249
+ settle = resolve;
193
250
  });
251
+ this.settle = settle;
194
252
  runner.start();
195
- this.exited = Promise.resolve(runner)
196
- .catch((error) => {
197
- this.spawnError = error;
198
- return { code: error?.code ?? 1 };
199
- })
200
- .then(async (result) => {
201
- const actualExit = await this.termination;
202
- if (actualExit) {
203
- result = {
204
- ...result,
205
- code: actualExit.code ?? result.code,
206
- signal: actualExit.signal,
207
- };
208
- }
209
- this.exitCode = typeof result.code === 'number' ? result.code : 1;
210
- this.signalCode = result.signal ?? null;
211
- for (const listener of this.exitListeners) {
212
- listener(this.exitCode);
253
+ Promise.resolve(runner).then(
254
+ (result) => {
255
+ // A released runner resolves without a result; its raw child reports.
256
+ if (!this.child) {
257
+ settle(result);
213
258
  }
214
- return this.exitCode;
259
+ },
260
+ (error) => {
261
+ this.spawnError = error;
262
+ settle({ code: error?.code ?? 1 });
263
+ }
264
+ );
265
+ this.exited = settled.then(async (result) => {
266
+ const actualExit = await this.termination;
267
+ if (actualExit) {
268
+ result = {
269
+ ...result,
270
+ code: actualExit.code ?? result.code,
271
+ signal: actualExit.signal,
272
+ };
273
+ }
274
+ this.exitCode = typeof result.code === 'number' ? result.code : 1;
275
+ this.signalCode = result.signal ?? null;
276
+ for (const listener of this.exitListeners) {
277
+ listener(this.exitCode);
278
+ }
279
+ return this.exitCode;
280
+ });
281
+ if (interruption === 'caller') {
282
+ this.release();
283
+ }
284
+ }
285
+
286
+ receiveStdout(chunk) {
287
+ if (this.forwardOutput) {
288
+ process.stdout.write(chunk);
289
+ }
290
+ this.stdout.emit(chunk);
291
+ }
292
+
293
+ receiveStderr(chunk) {
294
+ this.stderrTail = (this.stderrTail + String(chunk)).slice(-8192);
295
+ if (this.forwardOutput) {
296
+ process.stderr.write(chunk);
297
+ }
298
+ this.stderr.emit(chunk);
299
+ }
300
+
301
+ /**
302
+ * Take the child out of command-stream's SIGINT forwarding while this handle
303
+ * keeps observing its output and exit.
304
+ */
305
+ release() {
306
+ if (this.exitCode !== null || this.termination) {
307
+ return false;
308
+ }
309
+ if (this.child) {
310
+ return true;
311
+ }
312
+ const child = this.runner.child;
313
+ if (!child?.pid) {
314
+ return false;
315
+ }
316
+ releaseRunner(this.runner);
317
+ this.child = child;
318
+ this.interruption = 'caller';
319
+ child.stdout?.on('data', (chunk) => this.receiveStdout(chunk));
320
+ child.stderr?.on('data', (chunk) => this.receiveStderr(chunk));
321
+ const report = (code, signal) =>
322
+ this.settle({
323
+ code: Number.isInteger(code) ? code : signalExitCode(signal),
324
+ signal: signal ?? null,
215
325
  });
326
+ if (Number.isInteger(child.exitCode) || child.signalCode) {
327
+ report(child.exitCode, child.signalCode);
328
+ } else {
329
+ child.once('exit', report);
330
+ }
331
+ return true;
332
+ }
333
+
334
+ /**
335
+ * Leave the process running independently of this program: release it from
336
+ * command-stream's SIGINT forwarding and stop its handle and pipes from
337
+ * keeping the event loop alive. Exit observation keeps working while this
338
+ * program runs, and {@link kill} still stops it. Idempotent.
339
+ *
340
+ * @returns {boolean} Whether a running process is now detached
341
+ */
342
+ detach() {
343
+ if (!this.release()) {
344
+ return false;
345
+ }
346
+ this.detached = true;
347
+ unrefChild(this.child);
348
+ return true;
216
349
  }
217
350
 
218
351
  /** Operating system process id, once spawned. */
219
352
  get pid() {
220
- return this.runner.pid;
353
+ return this.child?.pid ?? this.runner.pid;
221
354
  }
222
355
 
223
356
  on(event, listener) {
@@ -245,6 +378,9 @@ export class ManagedProcess {
245
378
  if (this.exitCode !== null || this.termination) {
246
379
  return false;
247
380
  }
381
+ if (this.child) {
382
+ return this.killReleased(signal ?? 'SIGTERM');
383
+ }
248
384
  // command-stream resolves cancellation immediately and removes the child's
249
385
  // listeners. Retain its handle and observe the real exit after signalling,
250
386
  // so launchers cannot delete profiles while Chrome still writes to them.
@@ -274,4 +410,22 @@ export class ManagedProcess {
274
410
  }
275
411
  return true;
276
412
  }
413
+
414
+ /** Stop a released child directly; command-stream no longer tracks it. */
415
+ killReleased(signal) {
416
+ const { child } = this;
417
+ if (!signalProcessTree(child.pid, signal)) {
418
+ return false;
419
+ }
420
+ if (signal !== 'SIGKILL') {
421
+ const escalation = setTimeout(() => {
422
+ if (this.exitCode === null) {
423
+ signalProcessTree(child.pid, 'SIGKILL');
424
+ }
425
+ }, this.killGrace);
426
+ escalation.unref?.();
427
+ this.exited.then(() => clearTimeout(escalation));
428
+ }
429
+ return true;
430
+ }
277
431
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Issue #174: an embedding supervisor owns Ctrl+C for the browser it launched,
3
+ * hands the exact browser and tab to its next run, and that run closes it.
4
+ */
5
+ // feature-parity: process.interruption@native-typed sessions.handoff@native-typed
6
+ import assert from 'node:assert/strict';
7
+ import { readFile, mkdtemp, rm } from 'node:fs/promises';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { describe, it } from 'node:test';
11
+ import {
12
+ reconnectBrowser,
13
+ waitForPidExit,
14
+ } from '../../src/browser/browser-handoff.js';
15
+ import { startProcess } from '../../src/utilities/subprocess.js';
16
+ import { repoPath } from '../helpers/repo.js';
17
+
18
+ const SUPERVISOR = repoPath(
19
+ 'experiments',
20
+ 'issue-174',
21
+ 'browser-supervisor.mjs'
22
+ );
23
+
24
+ /** Run the supervisor, which interrupts itself after opening a page. */
25
+ async function interruptSupervisor(mode, engine) {
26
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-e2e-174-'));
27
+ const handoffFile = path.join(directory, 'handoff.json');
28
+ try {
29
+ const supervisor = startProcess(process.execPath, [
30
+ SUPERVISOR,
31
+ mode,
32
+ engine,
33
+ handoffFile,
34
+ ]);
35
+ const exitCode = await supervisor.exited;
36
+ const pid = Number(await readFile(`${handoffFile}.pid`, 'utf8'));
37
+ const handoff = await readFile(handoffFile, 'utf8').catch(() => null);
38
+ return { exitCode, pid, handoff, stderr: supervisor.stderrTail };
39
+ } finally {
40
+ await rm(directory, { recursive: true, force: true });
41
+ }
42
+ }
43
+
44
+ describe(
45
+ 'browser hand-off across an embedding supervisor (issue #174)',
46
+ {
47
+ skip:
48
+ (!process.env.RUN_E2E && 'set RUN_E2E=true') ||
49
+ (process.platform === 'win32' && 'SIGINT forwarding is Unix-only'),
50
+ timeout: 120000,
51
+ },
52
+ () => {
53
+ it('by default Ctrl+C reaches Chrome before the embedder can keep it', async () => {
54
+ const { exitCode, pid, handoff } = await interruptSupervisor(
55
+ 'managed',
56
+ 'playwright'
57
+ );
58
+ assert.equal(exitCode, 130);
59
+ assert.equal(handoff, null);
60
+ assert.equal(await waitForPidExit(pid, 10000), true);
61
+ });
62
+
63
+ for (const engine of ['playwright', 'puppeteer', 'selenium']) {
64
+ it(`${engine}: keeps the exact tab across Ctrl+C and closes it later`, async () => {
65
+ const { exitCode, pid, handoff, stderr } = await interruptSupervisor(
66
+ 'caller',
67
+ engine
68
+ );
69
+ assert.equal(exitCode, 130, stderr);
70
+ assert.ok(handoff, stderr);
71
+ const descriptor = JSON.parse(handoff);
72
+ assert.equal(descriptor.pid, pid);
73
+ try {
74
+ assert.equal(await waitForPidExit(pid, 500), false);
75
+ const session = await reconnectBrowser(handoff);
76
+ const title =
77
+ engine === 'selenium'
78
+ ? await session.browser.getTitle()
79
+ : await session.page.title();
80
+ assert.equal(title, 'waiting for a human');
81
+ await session.close();
82
+ assert.equal(await waitForPidExit(pid, 1000), true);
83
+ await assert.rejects(readFile(descriptor.userDataDir), {
84
+ code: 'ENOENT',
85
+ });
86
+ } finally {
87
+ if (!(await waitForPidExit(pid, 0))) {
88
+ process.kill(-pid, 'SIGKILL');
89
+ }
90
+ }
91
+ });
92
+ }
93
+ }
94
+ );
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A fake DevTools HTTP endpoint for hand-off tests (issue #174): it answers
3
+ * `/json/version` with a browser WebSocket URL and accepts `Browser.close`
4
+ * on that WebSocket, so detach/reconnect/close run without a browser.
5
+ */
6
+ import http from 'node:http';
7
+ import { acceptUpgrade } from '../../src/browser/attach/websocket.js';
8
+
9
+ /**
10
+ * @param {Object} [options]
11
+ * @param {string} [options.browserId='browser-1'] - Identity in webSocketDebuggerUrl
12
+ * @param {Function} [options.onBrowserClose] - Called when Browser.close arrives
13
+ * @returns {Promise<Object>} `{ endpoint, webSocketDebuggerUrl, closes, setBrowserId, close }`
14
+ */
15
+ export async function startFakeCdpEndpoint({
16
+ browserId = 'browser-1',
17
+ onBrowserClose = () => {},
18
+ } = {}) {
19
+ const closes = [];
20
+ let currentId = browserId;
21
+ const server = http.createServer((request, response) => {
22
+ if (request.url !== '/json/version') {
23
+ response.writeHead(404).end();
24
+ return;
25
+ }
26
+ response.setHeader('content-type', 'application/json');
27
+ response.end(
28
+ JSON.stringify({
29
+ Browser: 'FakeChrome/1.0',
30
+ webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/${currentId}`,
31
+ })
32
+ );
33
+ });
34
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
35
+ const { port } = server.address();
36
+ server.on('upgrade', (request, socket, head) => {
37
+ const connection = acceptUpgrade(request, socket, head);
38
+ connection?.on('message', (text) => {
39
+ const message = JSON.parse(text);
40
+ if (message.method === 'Browser.close') {
41
+ closes.push(request.url);
42
+ connection.send(JSON.stringify({ id: message.id, result: {} }));
43
+ onBrowserClose();
44
+ socket.end();
45
+ }
46
+ });
47
+ });
48
+ return {
49
+ endpoint: `http://127.0.0.1:${port}`,
50
+ get webSocketDebuggerUrl() {
51
+ return `ws://127.0.0.1:${port}/devtools/browser/${currentId}`;
52
+ },
53
+ closes,
54
+ /** Pretend another browser now listens on the same port. */
55
+ setBrowserId: (id) => {
56
+ currentId = id;
57
+ },
58
+ close: () =>
59
+ new Promise((resolve) => {
60
+ server.closeAllConnections?.();
61
+ server.close(() => resolve());
62
+ }),
63
+ };
64
+ }