browser-commander 0.27.0 → 0.29.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 (93) hide show
  1. package/.jscpd-baseline.json +5 -19
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +23 -9
  4. package/eslint.config.js +11 -0
  5. package/package.json +5 -2
  6. package/src/browser/browser-profile-files.js +5 -2
  7. package/src/browser/connector.js +215 -36
  8. package/src/browser/launch-options.js +2 -1
  9. package/src/browser/launch-restrictions.json +33 -2
  10. package/src/browser/launcher.js +26 -1
  11. package/src/browser/persistent-session.js +432 -0
  12. package/src/browser/profile-directory.js +20 -0
  13. package/src/browser/real-browser.js +7 -2
  14. package/src/browser/restrictions.js +13 -4
  15. package/src/browser/session-adoption.js +96 -0
  16. package/src/browser/session-watchdog.js +71 -0
  17. package/src/browser/session-worker.js +82 -0
  18. package/src/browser/webdriver.js +4 -0
  19. package/src/capture/encoding.js +327 -0
  20. package/src/capture/errors.js +12 -0
  21. package/src/capture/files.js +9 -0
  22. package/src/capture/index.js +5 -0
  23. package/src/capture/recording.js +136 -0
  24. package/src/capture/screenshot.js +222 -0
  25. package/src/capture/video.js +186 -0
  26. package/src/capture/viewport.js +68 -0
  27. package/src/capture/worker.js +22 -0
  28. package/src/cli/args.js +69 -1
  29. package/src/cli/commands.js +116 -2
  30. package/src/cli/high-level-methods.js +47 -3
  31. package/src/cli/sessions.js +14 -3
  32. package/src/core/commander-features.js +110 -0
  33. package/src/core/constants.js +1 -0
  34. package/src/core/navigation-manager.js +4 -2
  35. package/src/core/navigation-readiness.js +30 -2
  36. package/src/core/navigation-safety.js +52 -0
  37. package/src/core/page-trigger-manager.js +280 -156
  38. package/src/core/readiness.js +10 -2
  39. package/src/elements/locators.js +26 -32
  40. package/src/elements/selectors.js +37 -81
  41. package/src/elements/visibility.js +32 -10
  42. package/src/factory.js +13 -12
  43. package/src/index.js +4 -0
  44. package/src/interactions/click.js +44 -0
  45. package/src/interactions/overlays.js +73 -0
  46. package/src/traces/bundle.js +55 -8
  47. package/src/traces/checkpoints.js +103 -0
  48. package/src/traces/links.js +157 -2
  49. package/src/traces/mutation-stream.js +9 -2
  50. package/src/traces/network.js +309 -0
  51. package/src/traces/page-capture.js +203 -68
  52. package/src/traces/reader.js +8 -10
  53. package/src/traces/recorder.js +253 -247
  54. package/src/traces/redaction.js +89 -6
  55. package/src/traces/render.js +168 -0
  56. package/src/traces/rolling-bundle.js +240 -0
  57. package/src/traces/rolling.js +77 -0
  58. package/src/traces/start-cleanup.js +15 -0
  59. package/src/traces/stop.js +160 -0
  60. package/src/traces/storage.js +43 -0
  61. package/src/utilities/subprocess.js +32 -0
  62. package/tests/e2e/cli.e2e.test.js +20 -1
  63. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  64. package/tests/e2e/issue-160.e2e.test.js +291 -0
  65. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
  66. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
  67. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
  68. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
  69. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
  70. package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
  71. package/tests/unit/browser/issue-160-connector.test.js +157 -0
  72. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  73. package/tests/unit/browser/launcher.test.js +5 -1
  74. package/tests/unit/browser/persistent-session.test.js +59 -0
  75. package/tests/unit/browser/quiet-ui.test.js +16 -0
  76. package/tests/unit/browser/tab-selection.test.js +38 -0
  77. package/tests/unit/capture/capture.test.js +52 -0
  78. package/tests/unit/capture/cleanup.test.js +24 -0
  79. package/tests/unit/capture/viewport.test.js +168 -0
  80. package/tests/unit/cli/dispatcher.test.js +25 -0
  81. package/tests/unit/core/interrupted-navigation.test.js +32 -0
  82. package/tests/unit/core/readiness.test.js +21 -0
  83. package/tests/unit/core/trigger-readiness.test.js +39 -0
  84. package/tests/unit/core/trigger-stop-race.test.js +80 -0
  85. package/tests/unit/elements/visible-count.test.js +24 -0
  86. package/tests/unit/elements/wait-navigation.test.js +26 -0
  87. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  88. package/tests/unit/interactions/overlays.test.js +58 -0
  89. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  90. package/tests/unit/traces/bundle.test.js +16 -0
  91. package/tests/unit/traces/issue-160-privacy.test.js +110 -0
  92. package/tests/unit/traces/issue-160-rotation.test.js +140 -0
  93. package/tests/unit/traces/network.test.js +149 -0
@@ -1,10 +1,8 @@
1
1
  {
2
2
  "version": 1,
3
3
  "fingerprints": {
4
- "00504cdec3a262ed": 1,
5
4
  "00d381dd611ae8ac": 1,
6
5
  "029a62ef1f48e856": 1,
7
- "02fc9852e9ba4c49": 1,
8
6
  "038170e72e0af1f5": 1,
9
7
  "0413f601c8911a92": 1,
10
8
  "04b28d01d0bb47dc": 1,
@@ -14,22 +12,18 @@
14
12
  "08e28c7706cdddd6": 1,
15
13
  "090a69d9528e4cdb": 1,
16
14
  "09694dc8fb9d683c": 1,
17
- "0a3459aad03492bb": 1,
18
15
  "0ae281facb3c28dd": 1,
19
- "0b48b63049c42591": 1,
20
16
  "0b8c71391c107a5e": 1,
21
17
  "0ba2d165b56db0b3": 1,
22
18
  "0ca2d9c2f2b44d6c": 1,
23
19
  "0caa402c4eae32d8": 1,
24
20
  "0fd23be8410c2c95": 1,
25
21
  "0fe4f3b5e5e939b3": 1,
26
- "126a1cfe8f2264ce": 1,
27
22
  "1273365073edd790": 1,
28
23
  "13c4b58d3ea0719b": 1,
29
24
  "14beedf656e57fba": 1,
30
25
  "150c8efe188ac225": 1,
31
26
  "16340a16ed52849e": 1,
32
- "17c26c0db2d35ad1": 1,
33
27
  "19ff70fc80fad524": 1,
34
28
  "1a9f0f0f68ed445c": 1,
35
29
  "1ad4dc7e19a77f68": 1,
@@ -42,7 +36,6 @@
42
36
  "22755b35701c5bb6": 1,
43
37
  "2296c06fa00da254": 1,
44
38
  "229b903386b17538": 1,
45
- "22ab9a1ca6316d24": 1,
46
39
  "22cbd954358d25f8": 1,
47
40
  "230bfe57cfda164d": 1,
48
41
  "2357ae472525f6bf": 1,
@@ -51,6 +44,7 @@
51
44
  "266b9cb95f783cdf": 1,
52
45
  "26bb09996580437c": 1,
53
46
  "26ebb2e251c9a39e": 1,
47
+ "282eb8d84bbe54a4": 1,
54
48
  "29edfb9e08094991": 1,
55
49
  "2a78ee4b05679c8d": 1,
56
50
  "2b1a759e2cfd21a2": 1,
@@ -65,7 +59,6 @@
65
59
  "33357ce94e07cc4b": 1,
66
60
  "35cf2769645f3234": 1,
67
61
  "37bd4114d0089f49": 1,
68
- "39cf8134a7fdaa31": 1,
69
62
  "3adca52a3f04837c": 1,
70
63
  "3c1017def7b3216c": 1,
71
64
  "3c5cb4b62d3739e3": 1,
@@ -79,7 +72,6 @@
79
72
  "441570d51d6f70a4": 1,
80
73
  "457c71f32f53833d": 1,
81
74
  "4715a17d690865ee": 1,
82
- "47dc143be0bd8be5": 1,
83
75
  "4b0d3cc5c27227e7": 1,
84
76
  "4c3b94caa28045d0": 1,
85
77
  "4d1ff99bb19f5685": 1,
@@ -107,16 +99,15 @@
107
99
  "5ba2f3218754f04c": 1,
108
100
  "60052da86e7bb241": 1,
109
101
  "608e1dd27138ec6f": 1,
110
- "60a3149247760d9b": 1,
111
102
  "60d0c58a01fc8746": 1,
112
103
  "61ae5da6cc003836": 1,
113
104
  "61bcadcd8dc9f63e": 1,
114
105
  "63ff613b1780c67a": 1,
115
106
  "642cc89ed49f8a21": 1,
116
107
  "6479c3604c153ec4": 1,
108
+ "65ce52f8afd6a952": 1,
117
109
  "66fb97eebd7ff0dc": 1,
118
110
  "67a95e428b8fa31c": 1,
119
- "67c309590c5d69a1": 1,
120
111
  "6880d620e0e67229": 1,
121
112
  "69d50e6f6a5f2ee9": 1,
122
113
  "6a7ed9ad3fc00a39": 1,
@@ -153,6 +144,7 @@
153
144
  "86aae81b1aa0d21b": 1,
154
145
  "87484a2744e09748": 1,
155
146
  "8c2b1408886c1db1": 1,
147
+ "8d24ce7d6e8f6795": 1,
156
148
  "8e55579a412bc058": 1,
157
149
  "8ec18cdbd4a96518": 1,
158
150
  "901317bc4d7d30d0": 1,
@@ -178,10 +170,8 @@
178
170
  "a6ad7ab6c05656dc": 1,
179
171
  "a6ffab1aacd00af6": 1,
180
172
  "a71a4b720d466e32": 1,
181
- "a71fab6edf38c846": 1,
182
173
  "a78d76908162699e": 1,
183
174
  "a9bd4ffef6da5faf": 1,
184
- "ac3fe4ef966e0209": 1,
185
175
  "acc95d694eb2f581": 1,
186
176
  "ad4cfa76daa1b6fc": 1,
187
177
  "ad50b48938af8aef": 1,
@@ -203,7 +193,6 @@
203
193
  "bb5b0c8d22c9667a": 1,
204
194
  "bc9115be3a462930": 1,
205
195
  "bd0ae42c13d5b4f9": 1,
206
- "bd17b166d89cb709": 1,
207
196
  "bdb8ca169be36b80": 1,
208
197
  "bdcd11c2bfb5fde0": 1,
209
198
  "be0900fb3e87164e": 1,
@@ -235,8 +224,6 @@
235
224
  "d6fa9884eee815f0": 1,
236
225
  "d86bafb915488255": 1,
237
226
  "da2c10a66acdb52f": 1,
238
- "dabb9fc09638e178": 1,
239
- "dcc616702e5b0cb4": 1,
240
227
  "de0bf2317c53d427": 1,
241
228
  "de1b481bfe8c3e58": 1,
242
229
  "df2dc922f40485e7": 1,
@@ -256,7 +243,6 @@
256
243
  "eb9a70c2a21094ff": 1,
257
244
  "ebe03c5f8c2f5147": 1,
258
245
  "ee19c8b9b332ee98": 1,
259
- "ef6b9112e659147b": 1,
260
246
  "f07f979f8c2706e8": 1,
261
247
  "f38f72095a67c95e": 1,
262
248
  "f40ba84763547f41": 1,
@@ -265,7 +251,6 @@
265
251
  "f4bfa7e503443cd1": 1,
266
252
  "f6678d6748e9c902": 1,
267
253
  "f67a07a611f39cb2": 1,
268
- "f762b61eef0af530": 1,
269
254
  "f7b1f9d699bcd580": 1,
270
255
  "f81e07af5d3a1c3c": 1,
271
256
  "f904844f89cab699": 1,
@@ -275,6 +260,7 @@
275
260
  "fb4a079a529a6285": 1,
276
261
  "fc0d7bde0033411d": 1,
277
262
  "fccfd8b004c12dd4": 1,
278
- "fe859df9f97c75d4": 1
263
+ "fe859df9f97c75d4": 1,
264
+ "ff3b6c3e0b8bf33b": 1
279
265
  }
280
266
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.29.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4939956: Harden trace privacy and bounded mutation capture; rotate continuous bundles before writes and retain segments by default. Fix CDP selection cleanup and zero-page reconnect, add persistent profile adoption, idle heartbeats and tab enforcement, preserve queued trigger starts during stop, and make overlay dismissal report failures safely. Export trace rendering/summary tools and document the 0.28 trigger migration and packaged guides.
8
+
9
+ ## 0.28.0
10
+
11
+ ### Minor Changes
12
+
13
+ - cc2a209: Add portable screenshots, bounded recordings and GIF/APNG/WebP encoding, trace
14
+ rendering and summaries, full/text DOM links, bounded redacted network/HAR,
15
+ continuous trace rotation/gzip, and detached reusable browser sessions with idle
16
+ shutdown. Add early trigger readiness, explicit trigger concurrency, visible
17
+ counts and configured overlay dismissal. Fix navigation-interrupted waits,
18
+ Trusted Types capture, Chrome panel restrictions and deterministic tab selection.
19
+ Close ordinary launched CLI browsers even when their connection supports detach;
20
+ only explicitly kept-open sessions survive dispatcher cleanup.
21
+ Add stable viewport capture with native-view CDP, viewport-only engine fallbacks
22
+ and displayed-window regression coverage; document full-page compositor effects.
23
+ Preserve readiness timer expiry when the rounded clock still reports budget.
24
+
3
25
  ## 0.27.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A universal browser automation library for JavaScript/TypeScript that supports Playwright, Puppeteer, and Selenium with a unified API. The key focus is on **stoppable page triggers** - ensuring automation logic is properly mounted/unmounted during page navigation.
4
4
 
5
- See [engine support and native/CLI defaults](../docs/engine-support.md) for the
5
+ See [engine support and native/CLI defaults](https://github.com/link-foundation/browser-commander/blob/main/docs/engine-support.md) for the
6
6
  cross-language API matrix and Selenium examples.
7
7
 
8
8
  ## Installation
@@ -119,7 +119,7 @@ const { browser, page } = await launchBrowser({
119
119
  ```
120
120
 
121
121
  Every switch Browser Commander used to add on its own is available as a named
122
- [launch restriction](../docs/feature-parity.md#launch-command-line-and-opt-in-restrictions);
122
+ [launch restriction](https://github.com/link-foundation/browser-commander/blob/main/docs/feature-parity.md#launch-command-line-and-opt-in-restrictions);
123
123
  `restrictions: ['legacy-defaults']` restores the old `CHROME_ARGS`. Pass
124
124
  `launch: 'engine'` to use Playwright's `launchPersistentContext()` or
125
125
  `puppeteer.launch()` instead; the engine then adds its own switches, and
@@ -379,6 +379,10 @@ commander.pageTrigger({
379
379
  });
380
380
  ```
381
381
 
382
+ For trace budgets/privacy, persistent session adoption and heartbeats, CDP
383
+ `noDefaults`, overlay reporting, and the `readyOn`/`concurrency` trigger migration,
384
+ see [trace and session hardening](https://github.com/link-foundation/browser-commander/blob/f5e426d6933c681189c4989bac182a1558d8edb8/docs/trace-session-hardening.md).
385
+
382
386
  ## API Reference
383
387
 
384
388
  ### launchBrowser(options)
@@ -520,7 +524,7 @@ headless. `connectWebDriver({ serverUrl, capabilities })` opens a session on a
520
524
  server that is already running (a Selenium Grid, a cloud provider); pass
521
525
  `capabilities: { webSocketUrl: true }` for BiDi. Media emulation, fingerprint
522
526
  overrides and managed downloads need CDP and throw on this engine; see
523
- [docs/feature-parity.md](../docs/feature-parity.md#webdriver-engine).
527
+ [docs/feature-parity.md](https://github.com/link-foundation/browser-commander/blob/main/docs/feature-parity.md#webdriver-engine).
524
528
 
525
529
  ### listBrowserProfiles(options)
526
530
 
@@ -686,6 +690,8 @@ const commander = makeBrowserCommander({
686
690
  verbose: false, // Enable debug logging
687
691
  enableNetworkTracking: true, // Track HTTP requests
688
692
  enableNavigationManager: true, // Enable navigation events
693
+ overlays: [{ selector: '.consent-close', timeout: 1000 }],
694
+ onOverlayDismiss: (report) => console.log(report.selector, report.status),
689
695
  });
690
696
  ```
691
697
 
@@ -693,10 +699,14 @@ const commander = makeBrowserCommander({
693
699
 
694
700
  ```javascript
695
701
  const unregister = commander.pageTrigger({
696
- name: 'trigger-name', // For debugging
697
- condition: (ctx) => boolean, // When to run (receives {url, commander})
698
- action: async (ctx) => void, // What to do
699
- priority: 0, // Higher runs first
702
+ name: 'application-ready',
703
+ readyOn: 'domcontentloaded', // Default: networkidle
704
+ concurrency: 'queue', // Default: skip; also restart
705
+ condition: ({ url }) => url.includes('/application'),
706
+ action: async (ctx) => {
707
+ ctx.checkStopped();
708
+ },
709
+ priority: 0, // Higher runs first
700
710
  });
701
711
  ```
702
712
 
@@ -802,6 +812,10 @@ failed or cancelled download raises the failure rather than returning a path.
802
812
 
803
813
  ### Portable Traces
804
814
 
815
+ See [capture, debugging and reusable sessions](https://github.com/link-foundation/browser-commander/blob/main/docs/capture-and-debugging.md)
816
+ for screenshots, recordings, full/text DOM links, network/HAR, rotation,
817
+ persistent browsers, early trigger readiness and engine capability limits.
818
+
805
819
  `startTrace()` records a session into a schema-versioned bundle any of the three
806
820
  languages can read - the manifest, an ordered NDJSON timeline, per-checkpoint
807
821
  DOM snapshots and the mutation batches between them:
@@ -1024,7 +1038,7 @@ This is the **official extensibility mechanism** while awaiting browser-commande
1024
1038
 
1025
1039
  ## Command Line
1026
1040
 
1027
- The package installs a `browser-commander` command. The Rust and Python packages ship the same command, and the contract all three follow is [docs/cli-and-bridge.md](../docs/cli-and-bridge.md). Every command prints exactly one JSON document to stdout. The exit code is `0` on success and `1` on error. `doctor` exits `2` when it finds an unlisted difference, and a usage error exits `64`.
1041
+ The package installs a `browser-commander` command. The Rust and Python packages ship the same command, and the contract all three follow is [docs/cli-and-bridge.md](https://github.com/link-foundation/browser-commander/blob/main/docs/cli-and-bridge.md). Every command prints exactly one JSON document to stdout. The exit code is `0` on success and `1` on error. `doctor` exits `2` when it finds an unlisted difference, and a usage error exits `64`.
1028
1042
 
1029
1043
  ```bash
1030
1044
  npx browser-commander version
@@ -1064,4 +1078,4 @@ See [src/ARCHITECTURE.md](src/ARCHITECTURE.md) for detailed architecture documen
1064
1078
 
1065
1079
  [UNLICENSE](../LICENSE)
1066
1080
 
1067
- See [navigation budgets, launch diagnostics, reusable helpers and sessions](../docs/navigation-launch-and-sessions.md) for the shared API contracts and engine limitations.
1081
+ See [navigation budgets, launch diagnostics, reusable helpers and sessions](https://github.com/link-foundation/browser-commander/blob/main/docs/navigation-launch-and-sessions.md) for the shared API contracts and engine limitations.
package/eslint.config.js CHANGED
@@ -141,6 +141,17 @@ export default [
141
141
  'max-lines-per-function': 'off',
142
142
  },
143
143
  },
144
+ {
145
+ // Browser init scripts are serialized as one self-contained function.
146
+ // Keep its DOM/redaction helpers inside the payload.
147
+ files: ['src/traces/page-capture.js'],
148
+ rules: {
149
+ 'max-lines-per-function': [
150
+ 'warn',
151
+ { max: 350, skipBlankLines: true, skipComments: true },
152
+ ],
153
+ },
154
+ },
144
155
  {
145
156
  // Test files have different requirements
146
157
  files: ['tests/**/*.js', '**/*.test.js'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.27.0",
3
+ "version": "0.29.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": {
@@ -83,10 +83,13 @@
83
83
  "bplist-parser": "^0.5.0",
84
84
  "command-stream": "^1.4.0",
85
85
  "csv-parse": "^7.0.3",
86
+ "gifenc": "^1.0.3",
86
87
  "links-notation": "^0.23.0",
87
88
  "log-lazy": "^1.0.4",
88
89
  "plist": "^5.0.0",
89
- "test-anywhere": "^0.9.1"
90
+ "test-anywhere": "^0.9.1",
91
+ "upng-js": "^2.1.0",
92
+ "webp-wasm": "^1.0.6"
90
93
  },
91
94
  "devDependencies": {
92
95
  "@changesets/cli": "^3.0.3",
@@ -10,13 +10,16 @@ export function localStatePathForProfile(profileDir) {
10
10
  }
11
11
 
12
12
  /** Resolve existing ancestors even when the target profile does not exist. */
13
- export function physicalPath(value) {
13
+ export function physicalPath(value, resolve = realpathSync) {
14
14
  let existing = path.resolve(value);
15
15
  const suffix = [];
16
16
  while (true) {
17
17
  try {
18
- return path.join(realpathSync(existing), ...suffix.reverse());
18
+ return path.join(resolve(existing), ...suffix.reverse());
19
19
  } catch (error) {
20
+ if (['EPERM', 'EACCES'].includes(error.code)) {
21
+ return path.resolve(value);
22
+ }
20
23
  if (!['ENOENT', 'ENOTDIR'].includes(error.code)) {
21
24
  throw error;
22
25
  }
@@ -45,13 +45,73 @@ async function connectSelenium(options, dependencies) {
45
45
  ...options,
46
46
  debuggerAddress: new URL(options.cdpEndpoint).host,
47
47
  });
48
+ try {
49
+ await selectWebDriverTab(connected.driver, options);
50
+ } catch (error) {
51
+ await ignoreCleanup(() => connected.close());
52
+ throw error;
53
+ }
48
54
  return {
49
55
  ...connected,
50
56
  browser: connected.driver,
51
57
  disconnect: connected.close,
58
+ detach: connected.close,
52
59
  };
53
60
  }
54
61
 
62
+ async function selectWebDriverTab(driver, options) {
63
+ if (!driver?.getAllWindowHandles) {
64
+ return;
65
+ }
66
+ const original = await driver.getWindowHandle();
67
+ const handles = await driver.getAllWindowHandles();
68
+ let selected = original;
69
+ const matchers = urlMatchers(options);
70
+ if (options.targetId || matchers.length) {
71
+ selected = null;
72
+ const tabs = [];
73
+ for (const handle of handles) {
74
+ if (
75
+ options.targetId &&
76
+ !options.fallback &&
77
+ handle !== options.targetId &&
78
+ handle !== `CDwindow-${options.targetId}`
79
+ ) {
80
+ continue;
81
+ }
82
+ await driver.switchTo().window(handle);
83
+ const url = await driver.getCurrentUrl();
84
+ tabs.push({ handle, url });
85
+ }
86
+ selected = matchers.length
87
+ ? matchers
88
+ .map(
89
+ (matcher) =>
90
+ tabs.find((tab) => matchesUrl(matcher, tab.url))?.handle
91
+ )
92
+ .find(Boolean)
93
+ : tabs.find(
94
+ (tab) =>
95
+ tab.handle === options.targetId ||
96
+ tab.handle === `CDwindow-${options.targetId}`
97
+ )?.handle;
98
+ if (!selected) {
99
+ if (!options.fallback) {
100
+ await driver.switchTo().window(original);
101
+ throw new Error('No tab matches the requested targetId or URL');
102
+ }
103
+ selected = handles.includes(original) ? original : handles[0];
104
+ }
105
+ }
106
+ if (options.singleTab) {
107
+ for (const handle of handles.filter((handle) => handle !== selected)) {
108
+ await driver.switchTo().window(handle);
109
+ await driver.close();
110
+ }
111
+ }
112
+ await driver.switchTo().window(selected);
113
+ }
114
+
55
115
  function addDefinedOptions(options, values) {
56
116
  for (const [key, value] of Object.entries(values)) {
57
117
  if (value !== undefined) {
@@ -61,8 +121,13 @@ function addDefinedOptions(options, values) {
61
121
  return options;
62
122
  }
63
123
 
64
- function buildPlaywrightConnectOptions({ slowMo, timeout, headers }) {
65
- return addDefinedOptions({}, { slowMo, timeout, headers });
124
+ function buildPlaywrightConnectOptions({
125
+ slowMo,
126
+ timeout,
127
+ headers,
128
+ noDefaults,
129
+ }) {
130
+ return addDefinedOptions({}, { slowMo, timeout, headers, noDefaults });
66
131
  }
67
132
 
68
133
  function buildPuppeteerConnectOptions({
@@ -83,6 +148,55 @@ function buildPuppeteerConnectOptions({
83
148
  );
84
149
  }
85
150
 
151
+ export function matchesUrl(matcher, url) {
152
+ if (matcher instanceof RegExp) {
153
+ matcher.lastIndex = 0;
154
+ return matcher.test(url);
155
+ }
156
+ return typeof matcher === 'function' ? matcher(url) : matcher === url;
157
+ }
158
+
159
+ function urlMatchers(options) {
160
+ return (
161
+ options.urlMatchers ??
162
+ (Array.isArray(options.url)
163
+ ? options.url
164
+ : options.url
165
+ ? [options.url]
166
+ : [])
167
+ );
168
+ }
169
+
170
+ async function ignoreCleanup(cleanup) {
171
+ try {
172
+ await cleanup();
173
+ } catch {
174
+ /* Preserve the connection failure. */
175
+ }
176
+ }
177
+
178
+ function requestedPage(pages, targets, options) {
179
+ const matchers = urlMatchers(options);
180
+ if (!options.targetId && !matchers.length) {
181
+ return undefined;
182
+ }
183
+ const candidates = options.targetId
184
+ ? pages.filter((page) => targets.get(page) === options.targetId)
185
+ : pages;
186
+ const eligible = candidates.length || !options.fallback ? candidates : pages;
187
+ const selected = matchers.length
188
+ ? matchers
189
+ .map((matcher) =>
190
+ eligible.find((page) => matchesUrl(matcher, page.url()))
191
+ )
192
+ .find(Boolean)
193
+ : candidates[0];
194
+ if (!selected && !options.fallback) {
195
+ throw new RangeError('No tab matches the requested targetId/URL');
196
+ }
197
+ return selected;
198
+ }
199
+
86
200
  async function prepareStorageState({ storageState, seedCookies }) {
87
201
  if (seedCookies !== undefined && !Array.isArray(seedCookies)) {
88
202
  throw new TypeError('seedCookies must be an array');
@@ -111,37 +225,92 @@ async function prepareStorageState({ storageState, seedCookies }) {
111
225
  * @param {Object[]} pages - Engine page handles
112
226
  * @returns {Promise<Object|undefined>}
113
227
  */
114
- export async function pickForegroundPage(pages) {
228
+ export async function pickForegroundPage(pages, options = {}) {
229
+ const targets = new Map();
230
+ // Playwright emulates focus on every attached tab. Remove the emulation
231
+ // before observing visibility; DevTools target order has no active-tab flag.
115
232
  for (const page of pages) {
116
- if (typeof page?.evaluate !== 'function') {
117
- continue;
233
+ let session;
234
+ try {
235
+ session =
236
+ (await page.context?.().newCDPSession?.(page)) ??
237
+ (await page.target?.().createCDPSession?.());
238
+ if (!session) {
239
+ continue;
240
+ }
241
+ const { targetInfo } = await session.send('Target.getTargetInfo');
242
+ targets.set(page, targetInfo.targetId);
243
+ await session.send('Emulation.setFocusEmulationEnabled', {
244
+ enabled: false,
245
+ });
246
+ } catch {
247
+ // Non-Chromium engines do not expose CDP. Explicit URL matching still
248
+ // works there, and normal visibility is not affected by this emulation.
249
+ } finally {
250
+ await ignoreCleanup(() => session?.detach?.());
118
251
  }
119
- const state = await page
120
- .evaluate(() => document.visibilityState)
121
- .catch(() => null);
122
- if (state === 'visible') {
123
- return page;
252
+ }
253
+ let selected = requestedPage(pages, targets, options);
254
+ if (!selected) {
255
+ for (const page of pages) {
256
+ if (typeof page?.evaluate !== 'function') {
257
+ continue;
258
+ }
259
+ const state = await page
260
+ .evaluate(() => document.visibilityState)
261
+ .catch(() => null);
262
+ if (state === 'visible') {
263
+ selected = page;
264
+ break;
265
+ }
124
266
  }
125
267
  }
126
- return pages[0];
268
+ selected ??= pages[0];
269
+ if (options.singleTab && selected) {
270
+ for (const page of pages) {
271
+ if (page !== selected) {
272
+ await page.close();
273
+ }
274
+ }
275
+ }
276
+ return selected;
127
277
  }
128
278
 
129
279
  async function connectPlaywright({ options, loadPlaywright, storageState }) {
130
280
  const { chromium } = await loadPlaywright();
131
281
  const endpoint = options.cdpEndpoint ?? options.wsEndpoint;
132
- const browser = await chromium.connectOverCDP(
133
- endpoint,
134
- buildPlaywrightConnectOptions(options)
135
- );
136
- const context = browser.contexts()[0];
137
- if (!context) {
138
- throw new Error('Connected Playwright browser has no default context');
282
+ const settings = buildPlaywrightConnectOptions(options);
283
+ let browser;
284
+ try {
285
+ browser = await chromium.connectOverCDP(endpoint, settings);
286
+ } catch (error) {
287
+ if (
288
+ settings.noDefaults !== undefined ||
289
+ !/Browser\.setDownloadBehavior/.test(error.message) ||
290
+ !/Browser context management is not supported/.test(error.message)
291
+ ) {
292
+ throw error;
293
+ }
294
+ browser = await chromium.connectOverCDP(endpoint, {
295
+ ...settings,
296
+ noDefaults: true,
297
+ });
139
298
  }
140
- const page =
141
- (await pickForegroundPage(context.pages())) ?? (await context.newPage());
299
+ try {
300
+ const context = browser.contexts()[0];
301
+ if (!context) {
302
+ throw new Error('Connected Playwright browser has no default context');
303
+ }
304
+ const page =
305
+ (await pickForegroundPage(context.pages(), options)) ??
306
+ (await context.newPage());
142
307
 
143
- await restorePlaywrightStorageState({ context, storageState });
144
- return { browser, page };
308
+ await restorePlaywrightStorageState({ context, storageState });
309
+ return { browser, page, detach: browser.close?.bind(browser) };
310
+ } catch (error) {
311
+ await ignoreCleanup(() => browser.close?.());
312
+ throw error;
313
+ }
145
314
  }
146
315
 
147
316
  async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
@@ -150,12 +319,17 @@ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
150
319
  const browser = await puppeteer.connect(
151
320
  buildPuppeteerConnectOptions(options)
152
321
  );
153
- const page =
154
- (await pickForegroundPage(await browser.pages())) ??
155
- (await browser.newPage());
322
+ try {
323
+ const page =
324
+ (await pickForegroundPage(await browser.pages(), options)) ??
325
+ (await browser.newPage());
156
326
 
157
- await restorePuppeteerStorageState({ page, storageState });
158
- return { browser, page };
327
+ await restorePuppeteerStorageState({ page, storageState });
328
+ return { browser, page, detach: browser.disconnect?.bind(browser) };
329
+ } catch (error) {
330
+ await ignoreCleanup(() => browser.disconnect?.());
331
+ throw error;
332
+ }
159
333
  }
160
334
 
161
335
  /**
@@ -209,7 +383,7 @@ export async function connectBrowserWithDependencies(
209
383
  });
210
384
  return { ...result, downloads };
211
385
  } catch (error) {
212
- await result.close();
386
+ await ignoreCleanup(() => result.close());
213
387
  throw error;
214
388
  }
215
389
  }
@@ -242,14 +416,19 @@ export async function connectBrowserWithDependencies(
242
416
 
243
417
  // An attached browser gets the same managed lifecycle as a launched one:
244
418
  // the manager is built from the browser and page, not from how we got them.
245
- const downloads = await attachDownloads({
246
- engine,
247
- browser: result.browser,
248
- page: result.page,
249
- downloads: normalizedOptions.downloads,
250
- });
251
-
252
- return { ...result, downloads };
419
+ try {
420
+ const downloads = await attachDownloads({
421
+ engine,
422
+ browser: result.browser,
423
+ page: result.page,
424
+ downloads: normalizedOptions.downloads,
425
+ });
426
+
427
+ return { ...result, downloads };
428
+ } catch (error) {
429
+ await ignoreCleanup(() => result.detach?.());
430
+ throw error;
431
+ }
253
432
  }
254
433
 
255
434
  export { buildPlaywrightConnectOptions, buildPuppeteerConnectOptions };
@@ -26,6 +26,7 @@ export function resolveChromeArgs({
26
26
  extraArgs = [],
27
27
  ignoreDefaultArgs = [],
28
28
  restrictions = [],
29
+ disableFeatures = [],
29
30
  } = {}) {
30
31
  assertStringArray(args, 'args');
31
32
  assertStringArray(extraArgs, 'extraArgs');
@@ -41,7 +42,7 @@ export function resolveChromeArgs({
41
42
 
42
43
  return {
43
44
  args: mergeFeatureSwitches([
44
- ...resolveRestrictions(restrictions).args,
45
+ ...resolveRestrictions(restrictions, { disableFeatures }).args,
45
46
  ...args,
46
47
  ...extraArgs,
47
48
  ]),