browser-commander 0.31.1 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +26 -0
  3. package/package.json +1 -1
  4. package/src/browser/browser-handoff.js +304 -0
  5. package/src/browser/cdp-browser-endpoint.js +55 -0
  6. package/src/browser/connection-stage.js +23 -0
  7. package/src/browser/connector.js +29 -15
  8. package/src/browser/launch-diagnostics.js +4 -1
  9. package/src/browser/launcher.js +8 -0
  10. package/src/browser/persistent-session.js +5 -56
  11. package/src/browser/real-browser.js +56 -5
  12. package/src/browser/session-persistence.js +9 -5
  13. package/src/browser/webdriver.js +8 -1
  14. package/src/core/engine-adapter.js +0 -7
  15. package/src/elements/locators.js +1 -10
  16. package/src/elements/selectors.js +10 -155
  17. package/src/elements/text-selector.js +23 -0
  18. package/src/exports.js +6 -0
  19. package/src/utilities/subprocess.js +227 -38
  20. package/tests/e2e/browser-handoff.e2e.test.js +94 -0
  21. package/tests/e2e/selector-identity.e2e.test.js +104 -0
  22. package/tests/e2e/startup-readiness.e2e.test.js +95 -0
  23. package/tests/helpers/fake-cdp-endpoint.js +64 -0
  24. package/tests/helpers/temp-directory.js +25 -0
  25. package/tests/unit/browser/browser-handoff.test.js +406 -0
  26. package/tests/unit/browser/connection-stage.test.js +74 -0
  27. package/tests/unit/browser/launch-diagnostics.test.js +23 -1
  28. package/tests/unit/browser/launcher.test.js +23 -0
  29. package/tests/unit/browser/real-browser.test.js +1 -1
  30. package/tests/unit/browser/startup-readiness.test.js +129 -0
  31. package/tests/unit/elements/selector-identity.test.js +50 -0
  32. package/tests/unit/elements/selectors.test.js +12 -26
  33. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  34. package/tests/unit/utilities/interruption.test.js +182 -0
  35. package/tests/unit/utilities/subprocess.test.js +39 -13
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.32.0
4
+
5
+ ### Minor Changes
6
+
7
+ - dd4b3d5: Let an embedding supervisor own Ctrl+C and keep a launched browser across its own restarts (#174). `interruption: 'caller'` on `launchBrowser`, `launchRealBrowser`, `launchWebDriver` and `startProcess` stops SIGINT from being forwarded to the browser and its ChromeDriver and turns off the engines' `handleSIGINT`. A real launch's `detach()` and `handoff()` return a JSON hand-off naming the exact browser instance and tab, and `reconnectBrowser(handoff)` reattaches to that tab without launching or navigating, refusing a replaced browser or a closed tab with `BrowserHandoffError`. `close()` on either session closes the browser, waits for its process to exit and removes a temporary profile.
8
+
9
+ ## 0.31.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 9f486af: Preserve native Playwright text selectors and CSS sibling positions. Resolve nonnative text matches to unique ancestor paths so interactions keep their intended target across Playwright, Puppeteer, and Selenium.
14
+
15
+ Retain sanitized engine failures alongside Chrome stderr in launch error messages. Identify attachment stages and optionally log their timing with verbose mode; wait for actual child exit before profile cleanup, and verify readiness, cleanup ownership, and fresh-profile startup on macOS in CI.
16
+
3
17
  ## 0.31.1
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -399,6 +399,7 @@ const { browser, page, close } = await launchBrowser({
399
399
  verbose: false, // Enable debug logging
400
400
  args: ['--no-sandbox'], // Custom Chrome args to append
401
401
  storageState: './session-state.json', // Saved cookies and localStorage, as a path or object
402
+ interruption: 'managed', // 'caller' leaves Ctrl+C (SIGINT) to your own handler
402
403
  });
403
404
  ```
404
405
 
@@ -472,6 +473,31 @@ const session = await launchRealBrowser({
472
473
  console.log(session.migration.migrated, session.migration.skipped);
473
474
  ```
474
475
 
476
+ ### detach() / reconnectBrowser(handoff)
477
+
478
+ For an application that supervises the browser itself, `interruption:
479
+ 'caller'` keeps Ctrl+C from reaching the launched browser (and its
480
+ ChromeDriver), so the application's own SIGINT handler decides what happens.
481
+ A real launch can then hand the exact browser and tab to the next run:
482
+
483
+ ```javascript
484
+ const session = await launchBrowser({ interruption: 'caller' });
485
+ process.on('SIGINT', async () => {
486
+ // The browser keeps running; the hand-off names its instance and tab.
487
+ writeFileSync('handoff.json', JSON.stringify(await session.detach()));
488
+ process.exit(130);
489
+ });
490
+
491
+ // Next run: attach to that exact tab - no launch, no new page, no navigation.
492
+ const again = await reconnectBrowser('handoff.json');
493
+ await again.close(); // closes the browser and waits for its process to exit
494
+ ```
495
+
496
+ `session.handoff()` returns the same JSON-serializable description without
497
+ detaching. `reconnectBrowser` throws a `BrowserHandoffError` when the endpoint
498
+ now belongs to another browser instance or the tab is gone. See
499
+ [embedding: Ctrl+C ownership and browser hand-off](../docs/embedding-and-handoff.md).
500
+
475
501
  ### launchWebDriver(options) / connectWebDriver(options)
476
502
 
477
503
  The same engine is available through `launchBrowser({engine: 'selenium'})` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Hand a launched browser from one controller to the next (issue #174).
3
+ *
4
+ * An embedding supervisor that keeps a human-facing browser open across its
5
+ * own restarts needs three things from a launch:
6
+ *
7
+ * - interruption ownership: `launchBrowser({ interruption: 'caller' })`
8
+ * leaves Ctrl+C to the supervisor's own SIGINT handler (see
9
+ * `startProcess` in `utilities/subprocess.js`);
10
+ * - a `detach()` that stops this controller without touching the browser and
11
+ * returns a JSON-serializable {@link BrowserHandoff} naming the exact
12
+ * browser instance and tab;
13
+ * - {@link reconnectBrowser}, which attaches to that exact tab again - it
14
+ * never launches a browser and never creates or navigates a page, so a page
15
+ * waiting for a human (a login, a captcha) is found exactly as it was left.
16
+ *
17
+ * Whoever holds the hand-off owns the browser: `close()` on the launch result
18
+ * or on a reconnected session closes the browser, waits for the actual
19
+ * process to exit, and removes a temporary profile.
20
+ */
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import { connectBrowser } from './connector.js';
24
+ import { removeUserDataDir } from './profile-directory.js';
25
+ import { closeRemoteBrowser, probeSession } from './cdp-browser-endpoint.js';
26
+ import { signalProcessTree } from '../utilities/subprocess.js';
27
+ import { withDeadline } from '../traces/deadline.js';
28
+
29
+ /** Version of the {@link BrowserHandoff} shape. */
30
+ export const BROWSER_HANDOFF_VERSION = 1;
31
+
32
+ /**
33
+ * @typedef {Object} BrowserHandoff
34
+ * @property {number} version - {@link BROWSER_HANDOFF_VERSION}
35
+ * @property {'playwright'|'puppeteer'|'selenium'} engine - Engine of the controller that handed off
36
+ * @property {string} cdpEndpoint - HTTP DevTools endpoint, such as http://127.0.0.1:9222
37
+ * @property {string} webSocketDebuggerUrl - Identity of the exact browser instance
38
+ * @property {string} targetId - DevTools target id of the exact tab
39
+ * @property {number|null} pid - Browser process id
40
+ * @property {string|null} userDataDir - Profile directory
41
+ * @property {boolean} temporaryProfile - Whether close() removes the profile
42
+ * @property {number|null} remoteDebuggingPort - Loopback CDP port
43
+ * @property {string|null} executablePath - Browser executable
44
+ */
45
+
46
+ /** Raised when a hand-off no longer names a running browser or tab. */
47
+ export class BrowserHandoffError extends Error {
48
+ constructor(message, { cause } = {}) {
49
+ super(message, cause ? { cause } : undefined);
50
+ this.name = 'BrowserHandoffError';
51
+ }
52
+ }
53
+
54
+ /** DevTools target id of an engine page: the exact tab, across controllers. */
55
+ export async function pageTargetId({ engine, page, browser }) {
56
+ if (engine === 'selenium') {
57
+ // ChromeDriver window handles are target ids (older builds prefix them).
58
+ const handle = await browser.getWindowHandle();
59
+ return String(handle).replace(/^CDwindow-/, '');
60
+ }
61
+ const cdp =
62
+ engine === 'playwright'
63
+ ? await page.context().newCDPSession(page)
64
+ : await page.target().createCDPSession();
65
+ try {
66
+ return (await cdp.send('Target.getTargetInfo')).targetInfo.targetId;
67
+ } finally {
68
+ await cdp.detach().catch(() => {});
69
+ }
70
+ }
71
+
72
+ /** Describe the browser and tab a controller holds, for another controller. */
73
+ async function describe(owner, page) {
74
+ const version = await probeSession(owner.cdpEndpoint);
75
+ if (!version?.webSocketDebuggerUrl) {
76
+ throw new BrowserHandoffError(
77
+ `No browser answers at ${owner.cdpEndpoint}; nothing to hand off`
78
+ );
79
+ }
80
+ return {
81
+ version: BROWSER_HANDOFF_VERSION,
82
+ engine: owner.engine,
83
+ cdpEndpoint: owner.cdpEndpoint,
84
+ webSocketDebuggerUrl: version.webSocketDebuggerUrl,
85
+ targetId: await pageTargetId({
86
+ engine: owner.engine,
87
+ page,
88
+ browser: owner.connectedBrowser(),
89
+ }),
90
+ pid: owner.pid() ?? null,
91
+ userDataDir: owner.userDataDir ?? null,
92
+ temporaryProfile: Boolean(owner.temporaryProfile),
93
+ remoteDebuggingPort: owner.remoteDebuggingPort ?? null,
94
+ executablePath: owner.executablePath ?? null,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Add `handoff()` and a hand-off returning `detach()` to a session.
100
+ *
101
+ * `state` is shared with the session's closer so that `close()` after
102
+ * `detach()` closes the browser over its WebSocket instead of the dropped
103
+ * engine connection.
104
+ */
105
+ export function installHandoff(session, owner, state) {
106
+ const engineDetach = session.detach;
107
+ let detaching;
108
+ // Read by session persistence; not enumerable, so a launch result that
109
+ // spreads the session does not copy a value that goes stale.
110
+ Object.defineProperty(session, 'detached', {
111
+ configurable: true,
112
+ get: () => Boolean(state.detached),
113
+ });
114
+ session.handoff = async ({ page } = {}) => {
115
+ if (state.detached) {
116
+ return state.handoff;
117
+ }
118
+ return await describe(owner, page ?? session.page);
119
+ };
120
+ session.detach = ({ page } = {}) => {
121
+ detaching ??= (async () => {
122
+ const handoff = await session.handoff({ page });
123
+ state.handoff = handoff;
124
+ state.webSocketDebuggerUrl = handoff.webSocketDebuggerUrl;
125
+ state.detached = true;
126
+ await engineDetach?.();
127
+ owner.release?.();
128
+ return handoff;
129
+ })();
130
+ return detaching;
131
+ };
132
+ return session;
133
+ }
134
+
135
+ function readHandoff(handoff) {
136
+ let value = handoff;
137
+ if (typeof value === 'string') {
138
+ value = JSON.parse(
139
+ value.trimStart().startsWith('{') ? value : readFileSync(value, 'utf8')
140
+ );
141
+ }
142
+ for (const field of ['cdpEndpoint', 'webSocketDebuggerUrl', 'targetId']) {
143
+ if (typeof value?.[field] !== 'string' || !value[field]) {
144
+ throw new TypeError(`Browser hand-off is missing ${field}`);
145
+ }
146
+ }
147
+ if (value.version !== BROWSER_HANDOFF_VERSION) {
148
+ throw new TypeError(
149
+ `Unsupported browser hand-off version ${String(value.version)}`
150
+ );
151
+ }
152
+ return value;
153
+ }
154
+
155
+ function processRunning(pid) {
156
+ try {
157
+ process.kill(pid, 0);
158
+ } catch (error) {
159
+ return error.code === 'EPERM';
160
+ }
161
+ if (process.platform === 'linux') {
162
+ // A zombie still answers signal 0; its parent has not reaped it yet.
163
+ try {
164
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
165
+ return stat.slice(stat.lastIndexOf(')') + 2)[0] !== 'Z';
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+ return true;
171
+ }
172
+
173
+ /** Wait until a process that is not necessarily our child has exited. */
174
+ export async function waitForPidExit(pid, timeout) {
175
+ const deadline = Date.now() + timeout;
176
+ while (processRunning(pid)) {
177
+ if (Date.now() >= deadline) {
178
+ return false;
179
+ }
180
+ await new Promise((resolve) => setTimeout(resolve, 50));
181
+ }
182
+ return true;
183
+ }
184
+
185
+ /**
186
+ * Close a handed-off browser and wait for its process to exit: `Browser.close`
187
+ * first, SIGKILL to its process group if it is still running after
188
+ * `closeTimeout`, then remove a temporary profile.
189
+ */
190
+ export async function closeHandedOffBrowser(handoff, { closeTimeout = 5000 }) {
191
+ const version = await probeSession(handoff.cdpEndpoint);
192
+ if (version?.webSocketDebuggerUrl === handoff.webSocketDebuggerUrl) {
193
+ await withDeadline(
194
+ closeRemoteBrowser(handoff.webSocketDebuggerUrl),
195
+ closeTimeout,
196
+ 'browser close'
197
+ ).catch(() => {});
198
+ }
199
+ if (handoff.pid && !(await waitForPidExit(handoff.pid, closeTimeout))) {
200
+ signalProcessTree(handoff.pid, 'SIGKILL');
201
+ await waitForPidExit(handoff.pid, closeTimeout);
202
+ }
203
+ if (handoff.temporaryProfile && handoff.userDataDir) {
204
+ await removeUserDataDir(handoff.userDataDir);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Attach a new controller to the exact browser and tab a previous controller
210
+ * handed off with `detach()`.
211
+ *
212
+ * The endpoint must still belong to the same browser instance (its
213
+ * `webSocketDebuggerUrl` is compared) and the tab must still exist; otherwise
214
+ * this throws instead of launching a browser or opening a page. The result
215
+ * owns the browser like the launch did: `close()` closes it and waits for its
216
+ * process to exit, `detach()` hands it off again.
217
+ *
218
+ * @param {BrowserHandoff|string} handoff - The hand-off object, its JSON, or a path to a JSON file
219
+ * @param {Object} [options] - {@link connectBrowser} options; `engine` defaults to the hand-off's
220
+ * @param {number} [options.closeTimeout=5000] - How long close() waits before killing the process
221
+ * @returns {Promise<Object>} {@link connectBrowser} handles plus `close`, `detach`, `handoff`, `pid`, `cdpEndpoint`, `userDataDir`, `temporaryProfile`
222
+ */
223
+ export async function reconnectBrowser(handoff, options = {}) {
224
+ return await reconnectBrowserWithDependencies(handoff, options);
225
+ }
226
+
227
+ export async function reconnectBrowserWithDependencies(
228
+ handoff,
229
+ options = {},
230
+ dependencies = {}
231
+ ) {
232
+ const descriptor = readHandoff(handoff);
233
+ const { closeTimeout = 5000, ...connectionOptions } = options;
234
+ const engine = options.engine ?? descriptor.engine ?? 'playwright';
235
+ const version = await probeSession(descriptor.cdpEndpoint);
236
+ if (version?.webSocketDebuggerUrl !== descriptor.webSocketDebuggerUrl) {
237
+ throw new BrowserHandoffError(
238
+ version
239
+ ? `${descriptor.cdpEndpoint} now belongs to another browser instance`
240
+ : `The handed-off browser no longer answers at ${descriptor.cdpEndpoint}`
241
+ );
242
+ }
243
+ const connect = dependencies.connect ?? connectBrowser;
244
+ let connection;
245
+ try {
246
+ connection = await connect({
247
+ ...connectionOptions,
248
+ engine,
249
+ cdpEndpoint: descriptor.cdpEndpoint,
250
+ targetId: descriptor.targetId,
251
+ fallback: false,
252
+ });
253
+ } catch (error) {
254
+ throw new BrowserHandoffError(
255
+ `Could not reattach to tab ${descriptor.targetId}: ${error.message}`,
256
+ { cause: error }
257
+ );
258
+ }
259
+ const state = { detached: false };
260
+ const owner = {
261
+ ...descriptor,
262
+ engine,
263
+ pid: () => descriptor.pid,
264
+ connectedBrowser: () => connection.browser,
265
+ };
266
+ const originalClose = connection.browser?.close;
267
+ let closing;
268
+ const close = () => {
269
+ closing ??= (async () => {
270
+ if (!state.detached) {
271
+ await connection.downloads?.dispose?.().catch(() => {});
272
+ if (engine === 'selenium') {
273
+ await connection.detach?.().catch(() => {});
274
+ }
275
+ }
276
+ await closeHandedOffBrowser(descriptor, { closeTimeout });
277
+ if (engine !== 'selenium') {
278
+ await Promise.resolve()
279
+ .then(() => originalClose?.call(connection.browser))
280
+ .catch(() => {});
281
+ }
282
+ })();
283
+ return closing;
284
+ };
285
+ if (connection.browser && engine !== 'selenium') {
286
+ // As for a launch, browser.close() closes the browser this session owns.
287
+ connection.browser.close = close;
288
+ }
289
+ const session = installHandoff(
290
+ {
291
+ ...connection,
292
+ close,
293
+ pid: descriptor.pid,
294
+ cdpEndpoint: descriptor.cdpEndpoint,
295
+ remoteDebuggingPort: descriptor.remoteDebuggingPort,
296
+ userDataDir: descriptor.userDataDir,
297
+ temporaryProfile: descriptor.temporaryProfile,
298
+ executablePath: descriptor.executablePath,
299
+ },
300
+ owner,
301
+ state
302
+ );
303
+ return session;
304
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A running Chromium-family browser addressed only by its DevTools endpoint:
3
+ * identify it, and ask it to shut down. Used by persistent sessions, their
4
+ * watchdog, and hand-off reconnection (issue #174), none of which hold an
5
+ * engine connection when they need to.
6
+ */
7
+
8
+ /** `/json/version` of a CDP HTTP endpoint, or null when nothing answers. */
9
+ export function probeSession(endpoint) {
10
+ return fetch(`${endpoint}/json/version`, {
11
+ signal: AbortSignal.timeout(1000),
12
+ })
13
+ .then((r) => (r.ok ? r.json() : null))
14
+ .catch(() => null);
15
+ }
16
+
17
+ /** Send `Browser.close` over a browser WebSocket and wait for the socket to close. */
18
+ export async function closeRemoteBrowser(endpoint) {
19
+ await new Promise((resolve, reject) => {
20
+ const socket = new globalThis.WebSocket(endpoint);
21
+ let acknowledged = false;
22
+ const timer = setTimeout(() => {
23
+ socket.close();
24
+ reject(new Error('Browser.close timed out'));
25
+ }, 3000);
26
+ socket.onopen = () =>
27
+ socket.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
28
+ socket.onclose = () => {
29
+ clearTimeout(timer);
30
+ resolve();
31
+ };
32
+ socket.onerror = () => {
33
+ // Chrome can close the TCP connection without a WebSocket close frame
34
+ // after acknowledging Browser.close. Its ensuing close event completes
35
+ // shutdown; a pre-acknowledgement connection error remains a failure.
36
+ if (!acknowledged) {
37
+ clearTimeout(timer);
38
+ reject(new Error('Browser.close connection failed'));
39
+ }
40
+ };
41
+ socket.onmessage = ({ data }) => {
42
+ const response = JSON.parse(data);
43
+ if (response.id !== 1) {
44
+ return;
45
+ }
46
+ if (response.error) {
47
+ clearTimeout(timer);
48
+ reject(new Error(response.error.message ?? 'Browser.close failed'));
49
+ socket.close();
50
+ } else {
51
+ acknowledged = true;
52
+ }
53
+ };
54
+ });
55
+ }
@@ -0,0 +1,23 @@
1
+ /** Identify attachment failures; optional logs contain stage names and timings. */
2
+ export async function connectionStage(options, stage, work) {
3
+ const start = performance.now();
4
+ const report = (state) => {
5
+ if (options.verbose) {
6
+ console.log(
7
+ `Browser attachment ${stage} ${state} after ${Math.round(performance.now() - start)}ms`
8
+ );
9
+ }
10
+ };
11
+ report('started');
12
+ try {
13
+ const result = await work();
14
+ report('completed');
15
+ return result;
16
+ } catch (error) {
17
+ if (error && typeof error === 'object' && Object.isExtensible(error)) {
18
+ error.attachmentStage ??= stage;
19
+ }
20
+ report('failed');
21
+ throw error;
22
+ }
23
+ }
@@ -1,4 +1,5 @@
1
1
  import { attachDownloads } from '../downloads/attach.js';
2
+ import { connectionStage } from './connection-stage.js';
2
3
  import { connectWebDriver, launchWebDriver } from './webdriver.js';
3
4
  import {
4
5
  loadStorageState,
@@ -282,7 +283,9 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
282
283
  const settings = buildPlaywrightConnectOptions(options);
283
284
  let browser;
284
285
  try {
285
- browser = await chromium.connectOverCDP(endpoint, settings);
286
+ browser = await connectionStage(options, 'engine-connect', () =>
287
+ chromium.connectOverCDP(endpoint, settings)
288
+ );
286
289
  } catch (error) {
287
290
  if (
288
291
  settings.noDefaults !== undefined ||
@@ -291,21 +294,26 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
291
294
  ) {
292
295
  throw error;
293
296
  }
294
- browser = await chromium.connectOverCDP(endpoint, {
295
- ...settings,
296
- noDefaults: true,
297
- });
297
+ browser = await connectionStage(options, 'engine-connect', () =>
298
+ chromium.connectOverCDP(endpoint, { ...settings, noDefaults: true })
299
+ );
298
300
  }
299
301
  try {
300
302
  const context = browser.contexts()[0];
301
303
  if (!context) {
302
304
  throw new Error('Connected Playwright browser has no default context');
303
305
  }
304
- const page =
305
- (await pickForegroundPage(context.pages(), options)) ??
306
- (await context.newPage());
306
+ const page = await connectionStage(
307
+ options,
308
+ 'page-selection',
309
+ async () =>
310
+ (await pickForegroundPage(context.pages(), options)) ??
311
+ (await context.newPage())
312
+ );
307
313
 
308
- await restorePlaywrightStorageState({ context, storageState });
314
+ await connectionStage(options, 'storage-restore', () =>
315
+ restorePlaywrightStorageState({ context, storageState })
316
+ );
309
317
  return { browser, page, detach: browser.close?.bind(browser) };
310
318
  } catch (error) {
311
319
  await ignoreCleanup(() => browser.close?.());
@@ -316,15 +324,21 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
316
324
  async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
317
325
  const puppeteerModule = await loadPuppeteer();
318
326
  const puppeteer = puppeteerModule.default ?? puppeteerModule;
319
- const browser = await puppeteer.connect(
320
- buildPuppeteerConnectOptions(options)
327
+ const browser = await connectionStage(options, 'engine-connect', () =>
328
+ puppeteer.connect(buildPuppeteerConnectOptions(options))
321
329
  );
322
330
  try {
323
- const page =
324
- (await pickForegroundPage(await browser.pages(), options)) ??
325
- (await browser.newPage());
331
+ const page = await connectionStage(
332
+ options,
333
+ 'page-selection',
334
+ async () =>
335
+ (await pickForegroundPage(await browser.pages(), options)) ??
336
+ (await browser.newPage())
337
+ );
326
338
 
327
- await restorePuppeteerStorageState({ page, storageState });
339
+ await connectionStage(options, 'storage-restore', () =>
340
+ restorePuppeteerStorageState({ page, storageState })
341
+ );
328
342
  return { browser, page, detach: browser.disconnect?.bind(browser) };
329
343
  } catch (error) {
330
344
  await ignoreCleanup(() => browser.disconnect?.());
@@ -7,10 +7,11 @@ export class BrowserLaunchError extends Error {
7
7
  exitCode = null,
8
8
  signal = null,
9
9
  stderrTail = '',
10
+ attachmentStage,
10
11
  cause,
11
12
  }) {
12
13
  super(
13
- `Browser launch failed (${phase}: ${category}): ${stderrTail || cause?.message || ''}`,
14
+ `Browser launch failed (${phase}: ${category}): ${cause?.message || ''}${stderrTail ? `\nBrowser stderr:\n${stderrTail}` : ''}`,
14
15
  { cause }
15
16
  );
16
17
  this.name = 'BrowserLaunchError';
@@ -21,6 +22,7 @@ export class BrowserLaunchError extends Error {
21
22
  exitCode,
22
23
  signal,
23
24
  stderrTail,
25
+ ...(attachmentStage ? { attachmentStage } : {}),
24
26
  });
25
27
  }
26
28
  }
@@ -98,5 +100,6 @@ export function launchFailure(
98
100
  signal: browserProcess?.signalCode ?? null,
99
101
  stderrTail: scrub(browserProcess?.stderrTail ?? cause?.stderrTail ?? ''),
100
102
  cause: safeCause,
103
+ attachmentStage: cause?.attachmentStage,
101
104
  });
102
105
  }
@@ -34,6 +34,7 @@ import {
34
34
  restorePuppeteerStorageState,
35
35
  restoreWebDriverStorageState,
36
36
  } from './storage-state.js';
37
+ import { interruptionMode } from '../utilities/subprocess.js';
37
38
 
38
39
  /** How `launchBrowser` starts the browser (issue #103). */
39
40
  export const LAUNCH_MODES = Object.freeze(['real', 'engine']);
@@ -166,7 +167,12 @@ async function launchWithEngine(options, dependencies) {
166
167
  executablePath,
167
168
  automationParity,
168
169
  storageState,
170
+ interruption,
169
171
  } = options;
172
+ // The engines install their own SIGINT handler that closes the browser;
173
+ // with caller-owned interruption the embedder's handler decides (#174).
174
+ const signals =
175
+ interruptionMode(interruption) === 'caller' ? { handleSIGINT: false } : {};
170
176
  const chrome = resolveChromeArgs({
171
177
  args,
172
178
  extraArgs,
@@ -208,6 +214,7 @@ async function launchWithEngine(options, dependencies) {
208
214
  ignoreDefaultArgs: chrome.ignoreDefaultArgs,
209
215
  automationParity,
210
216
  }),
217
+ ...signals,
211
218
  ...(childEnv ? { env: childEnv } : {}),
212
219
  });
213
220
  page = browser.pages()[0] ?? (await browser.newPage());
@@ -223,6 +230,7 @@ async function launchWithEngine(options, dependencies) {
223
230
  automationParity,
224
231
  }),
225
232
  ...(slowMo ? { slowMo } : {}),
233
+ ...signals,
226
234
  ...(childEnv ? { env: childEnv } : {}),
227
235
  });
228
236
  page = (await browser.pages())[0] ?? (await browser.newPage());
@@ -7,6 +7,10 @@ import { launchAndConnectRealBrowserWithDependencies } from './real-browser.js';
7
7
  import { assertDedicatedUserDataDir } from './system-browser.js';
8
8
  import { findSessionProcess } from './session-adoption.js';
9
9
  import { startDetachedProcess } from '../utilities/subprocess.js';
10
+ import { closeRemoteBrowser, probeSession } from './cdp-browser-endpoint.js';
11
+ import { pageTargetId } from './browser-handoff.js';
12
+
13
+ export { closeRemoteBrowser, probeSession };
10
14
 
11
15
  export const SESSION_METADATA = '.browser-commander-session.json';
12
16
  const activity = new WeakMap();
@@ -27,13 +31,6 @@ export async function writeSessionMetadata(file, value) {
27
31
  await fs.rm(temporary, { force: true });
28
32
  }
29
33
  }
30
- export function probeSession(endpoint) {
31
- return fetch(`${endpoint}/json/version`, {
32
- signal: AbortSignal.timeout(1000),
33
- })
34
- .then((r) => (r.ok ? r.json() : null))
35
- .catch(() => null);
36
- }
37
34
  export async function acquireLease(file, retries = 50) {
38
35
  let lease;
39
36
  try {
@@ -168,17 +165,7 @@ async function open(options, file, endpoint) {
168
165
  }
169
166
  }
170
167
  async function remember(page, engine, file, metadata, idleTimeoutMs) {
171
- const cdp =
172
- engine === 'playwright'
173
- ? await page.context().newCDPSession(page)
174
- : await page.target().createCDPSession();
175
- try {
176
- metadata.targetId = (
177
- await cdp.send('Target.getTargetInfo')
178
- ).targetInfo.targetId;
179
- } finally {
180
- await cdp.detach();
181
- }
168
+ metadata.targetId = await pageTargetId({ engine, page });
182
169
  metadata.lastActive = Date.now();
183
170
  metadata.idleTimeoutMs = idleTimeoutMs;
184
171
  await writeSessionMetadata(file, metadata);
@@ -365,44 +352,6 @@ async function lifecycle(
365
352
  };
366
353
  return result;
367
354
  }
368
- export async function closeRemoteBrowser(endpoint) {
369
- await new Promise((resolve, reject) => {
370
- const socket = new globalThis.WebSocket(endpoint);
371
- let acknowledged = false;
372
- const timer = setTimeout(() => {
373
- socket.close();
374
- reject(new Error('Browser.close timed out'));
375
- }, 3000);
376
- socket.onopen = () =>
377
- socket.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
378
- socket.onclose = () => {
379
- clearTimeout(timer);
380
- resolve();
381
- };
382
- socket.onerror = () => {
383
- // Chrome can close the TCP connection without a WebSocket close frame
384
- // after acknowledging Browser.close. Its ensuing close event completes
385
- // shutdown; a pre-acknowledgement connection error remains a failure.
386
- if (!acknowledged) {
387
- clearTimeout(timer);
388
- reject(new Error('Browser.close connection failed'));
389
- }
390
- };
391
- socket.onmessage = ({ data }) => {
392
- const response = JSON.parse(data);
393
- if (response.id !== 1) {
394
- return;
395
- }
396
- if (response.error) {
397
- clearTimeout(timer);
398
- reject(new Error(response.error.message ?? 'Browser.close failed'));
399
- socket.close();
400
- } else {
401
- acknowledged = true;
402
- }
403
- };
404
- });
405
- }
406
355
  /** Reuse an owned profile/port; detach survives the controller; close terminates it. */
407
356
  export async function connectOrLaunch(settings = {}) {
408
357
  const options = {