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
@@ -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) => {
@@ -284,13 +284,6 @@ export class PlaywrightAdapter extends EngineAdapter {
284
284
  // ============================================================================
285
285
 
286
286
  createLocator(selector) {
287
- // Handle :nth-of-type() pseudo-selectors which don't work in Playwright locators
288
- const nthOfTypeMatch = selector.match(/^(.+):nth-of-type\((\d+)\)$/);
289
- if (nthOfTypeMatch) {
290
- const baseSelector = nthOfTypeMatch[1];
291
- const index = parseInt(nthOfTypeMatch[2], 10) - 1; // Convert to 0-based
292
- return this.page.locator(baseSelector).nth(index);
293
- }
294
287
  return this.page.locator(selector);
295
288
  }
296
289
 
@@ -6,7 +6,7 @@ import {
6
6
 
7
7
  /**
8
8
  * Helper to create Playwright locator from selector string
9
- * Handles :nth-of-type() pseudo-selectors which don't work in Playwright locators
9
+ * Preserves native CSS and text selector semantics.
10
10
  * @param {Object} options - Configuration options
11
11
  * @param {Object} options.page - Browser page object
12
12
  * @param {string} options.selector - CSS selector
@@ -22,15 +22,6 @@ export function createPlaywrightLocator(options = {}) {
22
22
  const { page, selector } = options;
23
23
 
24
24
  requireSelector(selector);
25
- // Check if selector has :nth-of-type(n) pattern
26
- const nthOfTypeMatch = selector.match(/^(.+):nth-of-type\((\d+)\)$/);
27
-
28
- if (nthOfTypeMatch) {
29
- const baseSelector = nthOfTypeMatch[1];
30
- const index = parseInt(nthOfTypeMatch[2], 10) - 1; // Convert to 0-based index
31
- return page.locator(baseSelector).nth(index);
32
- }
33
-
34
25
  return page.locator(selector);
35
26
  }
36
27
 
@@ -4,6 +4,7 @@ import {
4
4
  recoverWaitError,
5
5
  } from '../core/navigation-safety.js';
6
6
  import { createPlaywrightLocator, requireSelector } from './locators.js';
7
+ import { textSelectorInPage } from './text-selector.js';
7
8
 
8
9
  /**
9
10
  * Query single element
@@ -80,75 +81,18 @@ export async function findByText(options = {}) {
80
81
  }
81
82
  }
82
83
 
83
- /**
84
- * Check if a selector is a Playwright-specific text selector
85
- * @param {string} selector - The selector to check
86
- * @returns {boolean} - True if selector contains Playwright text pseudo-selectors
87
- */
88
- function isPlaywrightTextSelector(selector) {
89
- if (typeof selector !== 'string') {
90
- return false;
91
- }
92
- return selector.includes(':has-text(') || selector.includes(':text-is(');
93
- }
94
-
95
- /**
96
- * Split `base:<pseudo>("text")` into its base selector and text.
97
- *
98
- * String operations rather than a regular expression: the former
99
- * /^(.+?):has-text\("(.+?)"\)$/ backtracked quadratically on long selectors
100
- * that do not match (CodeQL js/polynomial-redos). The base is everything before
101
- * the first `:<pseudo>("`, as the lazy `(.+?)` made it; base and text must be
102
- * non-empty.
103
- * @param {string} selector - Selector to split
104
- * @param {string} pseudo - Pseudo-class name, e.g. 'has-text'
105
- * @returns {{baseSelector: string, text: string}|null} - Parts, or null
106
- */
107
- function splitTextPseudo(selector, pseudo) {
108
- const open = `:${pseudo}("`;
109
- const start = selector.indexOf(open, 1);
110
- if (start === -1 || !selector.endsWith('")')) {
111
- return null;
112
- }
113
- const text = selector.slice(start + open.length, -2);
114
- if (!text) {
115
- return null;
116
- }
117
- return { baseSelector: selector.slice(0, start), text };
118
- }
119
-
120
- /**
121
- * Parse a Playwright text selector to extract base selector and text
122
- * @param {string} selector - Playwright text selector like 'a:has-text("text")'
123
- * @returns {Object|null} - { baseSelector, text, exact } or null if not parseable
124
- */
125
- function parsePlaywrightTextSelector(selector) {
126
- // Match patterns like 'a:has-text("text")' or 'button:text-is("exact text")'
127
- const hasText = splitTextPseudo(selector, 'has-text');
128
- if (hasText) {
129
- return { ...hasText, exact: false };
130
- }
131
-
132
- const textIs = splitTextPseudo(selector, 'text-is');
133
- if (textIs) {
134
- return { ...textIs, exact: true };
135
- }
136
-
137
- return null;
138
- }
139
-
140
84
  /**
141
85
  * Normalize selector to handle both Puppeteer and Playwright text selectors
142
- * Converts engine-specific text selectors to valid CSS selectors for browser context
86
+ * Preserves native Playwright strings; resolves text objects to unique CSS paths.
143
87
  *
144
88
  * @param {Object} options - Configuration options
145
89
  * @param {Object} options.page - Browser page object
146
90
  * @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
147
91
  * @param {string|Object} options.selector - CSS selector or text selector object
148
- * @returns {Promise<string|null>} - Valid CSS selector or null if not found
92
+ * @returns {Promise<string|null>} - Native selector, unique CSS path, or null
149
93
  */
150
94
  export async function normalizeSelector(options = {}) {
151
- const { page, engine, selector } = options;
95
+ const { page, selector } = options;
152
96
 
153
97
  requireSelector(selector);
154
98
 
@@ -171,59 +115,6 @@ export async function normalizeSelector(options = {}) {
171
115
  return null;
172
116
  }
173
117
 
174
- // Handle Playwright text selectors (strings containing :has-text or :text-is)
175
- // These are valid for Playwright's locator API but NOT for document.querySelectorAll
176
- if (
177
- typeof selector === 'string' &&
178
- engine === 'playwright' &&
179
- isPlaywrightTextSelector(selector)
180
- ) {
181
- const parsed = parsePlaywrightTextSelector(selector);
182
- if (!parsed) {
183
- // Could not parse, return as-is and hope for the best
184
- return selector;
185
- }
186
-
187
- try {
188
- // Use page.evaluate to find matching element and generate a valid CSS selector
189
- const result = await page.evaluate(({ baseSelector, text, exact }) => {
190
- const elements = Array.from(document.querySelectorAll(baseSelector));
191
- const matchingElement = elements.find((el) => {
192
- const elementText = el.textContent.trim();
193
- return exact ? elementText === text : elementText.includes(text);
194
- });
195
-
196
- if (!matchingElement) {
197
- return null;
198
- }
199
-
200
- // Generate a unique selector using data-qa or nth-of-type
201
- const dataQa = matchingElement.getAttribute('data-qa');
202
- if (dataQa) {
203
- return `[data-qa="${dataQa}"]`;
204
- }
205
-
206
- // Use nth-of-type as fallback
207
- const tagName = matchingElement.tagName.toLowerCase();
208
- const siblings = Array.from(
209
- matchingElement.parentElement.children
210
- ).filter((el) => el.tagName.toLowerCase() === tagName);
211
- const index = siblings.indexOf(matchingElement);
212
- return `${tagName}:nth-of-type(${index + 1})`;
213
- }, parsed);
214
-
215
- return result;
216
- } catch (error) {
217
- if (isNavigationError(error)) {
218
- console.log(
219
- '⚠️ Navigation detected during normalizeSelector (Playwright), returning null'
220
- );
221
- return null;
222
- }
223
- throw error;
224
- }
225
- }
226
-
227
118
  // Plain string selector - return as-is
228
119
  if (typeof selector === 'string') {
229
120
  return selector;
@@ -233,36 +124,11 @@ export async function normalizeSelector(options = {}) {
233
124
  if (selector._isPuppeteerTextSelector) {
234
125
  try {
235
126
  // Find element by text and generate a unique selector
236
- const result = await page.evaluate(
237
- (baseSelector, text, exact) => {
238
- const elements = Array.from(document.querySelectorAll(baseSelector));
239
- const matchingElement = elements.find((el) => {
240
- const elementText = el.textContent.trim();
241
- return exact ? elementText === text : elementText.includes(text);
242
- });
243
-
244
- if (!matchingElement) {
245
- return null;
246
- }
247
-
248
- // Generate a unique selector using data-qa or nth-of-type
249
- const dataQa = matchingElement.getAttribute('data-qa');
250
- if (dataQa) {
251
- return `[data-qa="${dataQa}"]`;
252
- }
253
-
254
- // Use nth-of-type as fallback
255
- const tagName = matchingElement.tagName.toLowerCase();
256
- const siblings = Array.from(
257
- matchingElement.parentElement.children
258
- ).filter((el) => el.tagName.toLowerCase() === tagName);
259
- const index = siblings.indexOf(matchingElement);
260
- return `${tagName}:nth-of-type(${index + 1})`;
261
- },
262
- selector.baseSelector,
263
- selector.text,
264
- selector.exact
265
- );
127
+ const result = await page.evaluate(textSelectorInPage, {
128
+ baseSelector: selector.baseSelector,
129
+ text: selector.text,
130
+ exact: selector.exact,
131
+ });
266
132
 
267
133
  return result;
268
134
  } catch (error) {
@@ -296,6 +162,7 @@ export function withTextSelectorSupport(fn, engine, page) {
296
162
  // selenium)
297
163
  if (
298
164
  engine !== 'playwright' &&
165
+ selector &&
299
166
  typeof selector === 'object' &&
300
167
  selector._isPuppeteerTextSelector
301
168
  ) {
@@ -305,18 +172,6 @@ export function withTextSelectorSupport(fn, engine, page) {
305
172
  }
306
173
  }
307
174
 
308
- // Normalize Playwright text selectors (string format with :has-text or :text-is)
309
- if (
310
- engine === 'playwright' &&
311
- typeof selector === 'string' &&
312
- isPlaywrightTextSelector(selector)
313
- ) {
314
- selector = await normalizeSelector({ page, engine, selector });
315
- if (!selector) {
316
- throw new Error('Element with specified text not found');
317
- }
318
- }
319
-
320
175
  return fn({ ...options, selector });
321
176
  };
322
177
  }
@@ -0,0 +1,23 @@
1
+ /** Resolve a text match to an unambiguous CSS path without changing the DOM. */
2
+ export function textSelectorInPage({ baseSelector, text, exact }) {
3
+ const matching = Array.from(document.querySelectorAll(baseSelector)).find(
4
+ (element) => {
5
+ const content = element.textContent.trim();
6
+ return exact ? content === text : content.includes(text);
7
+ }
8
+ );
9
+ if (!matching) {
10
+ return null;
11
+ }
12
+ const steps = [];
13
+ for (let node = matching; node; node = node.parentElement) {
14
+ const siblings = Array.from(node.parentElement?.children ?? []).filter(
15
+ (sibling) => sibling.localName === node.localName
16
+ );
17
+ const index = siblings.indexOf(node) + 1;
18
+ steps.unshift(
19
+ `${window.CSS.escape(node.localName)}:nth-of-type(${Math.max(1, index)})`
20
+ );
21
+ }
22
+ return steps.join(' > ');
23
+ }
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,