browser-commander 0.31.2 → 0.32.0

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.
@@ -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
+ }
@@ -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
+ }