browser-commander 0.27.0 → 0.28.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 (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +4 -0
  3. package/eslint.config.js +11 -0
  4. package/package.json +5 -2
  5. package/src/browser/connector.js +117 -13
  6. package/src/browser/launch-options.js +2 -1
  7. package/src/browser/launch-restrictions.json +33 -2
  8. package/src/browser/launcher.js +26 -1
  9. package/src/browser/persistent-session.js +360 -0
  10. package/src/browser/profile-directory.js +20 -0
  11. package/src/browser/real-browser.js +7 -2
  12. package/src/browser/restrictions.js +13 -4
  13. package/src/browser/session-watchdog.js +71 -0
  14. package/src/browser/session-worker.js +70 -0
  15. package/src/browser/webdriver.js +4 -0
  16. package/src/capture/encoding.js +327 -0
  17. package/src/capture/errors.js +12 -0
  18. package/src/capture/files.js +9 -0
  19. package/src/capture/index.js +5 -0
  20. package/src/capture/recording.js +136 -0
  21. package/src/capture/screenshot.js +222 -0
  22. package/src/capture/video.js +186 -0
  23. package/src/capture/viewport.js +68 -0
  24. package/src/capture/worker.js +22 -0
  25. package/src/cli/args.js +66 -1
  26. package/src/cli/commands.js +113 -2
  27. package/src/cli/high-level-methods.js +40 -3
  28. package/src/cli/sessions.js +11 -3
  29. package/src/core/commander-features.js +105 -0
  30. package/src/core/constants.js +1 -0
  31. package/src/core/navigation-manager.js +4 -2
  32. package/src/core/navigation-readiness.js +30 -2
  33. package/src/core/navigation-safety.js +51 -0
  34. package/src/core/page-trigger-manager.js +115 -30
  35. package/src/core/readiness.js +10 -2
  36. package/src/elements/locators.js +26 -32
  37. package/src/elements/selectors.js +37 -81
  38. package/src/elements/visibility.js +32 -10
  39. package/src/factory.js +13 -12
  40. package/src/index.js +2 -0
  41. package/src/interactions/click.js +44 -0
  42. package/src/interactions/overlays.js +28 -0
  43. package/src/traces/bundle.js +14 -5
  44. package/src/traces/checkpoints.js +99 -0
  45. package/src/traces/links.js +117 -1
  46. package/src/traces/mutation-stream.js +1 -0
  47. package/src/traces/network.js +257 -0
  48. package/src/traces/page-capture.js +143 -52
  49. package/src/traces/reader.js +8 -10
  50. package/src/traces/recorder.js +221 -245
  51. package/src/traces/render.js +168 -0
  52. package/src/traces/rolling.js +180 -0
  53. package/src/traces/start-cleanup.js +15 -0
  54. package/src/traces/stop.js +159 -0
  55. package/src/traces/storage.js +43 -0
  56. package/src/utilities/subprocess.js +32 -0
  57. package/tests/e2e/cli.e2e.test.js +20 -1
  58. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  59. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  60. package/tests/unit/browser/launcher.test.js +5 -1
  61. package/tests/unit/browser/persistent-session.test.js +59 -0
  62. package/tests/unit/browser/quiet-ui.test.js +16 -0
  63. package/tests/unit/browser/tab-selection.test.js +38 -0
  64. package/tests/unit/capture/capture.test.js +52 -0
  65. package/tests/unit/capture/cleanup.test.js +24 -0
  66. package/tests/unit/capture/viewport.test.js +168 -0
  67. package/tests/unit/cli/dispatcher.test.js +25 -0
  68. package/tests/unit/core/readiness.test.js +21 -0
  69. package/tests/unit/core/trigger-readiness.test.js +39 -0
  70. package/tests/unit/elements/visible-count.test.js +24 -0
  71. package/tests/unit/elements/wait-navigation.test.js +26 -0
  72. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  73. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  74. package/tests/unit/traces/bundle.test.js +16 -0
  75. package/tests/unit/traces/network.test.js +97 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.28.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cc2a209: Add portable screenshots, bounded recordings and GIF/APNG/WebP encoding, trace
8
+ rendering and summaries, full/text DOM links, bounded redacted network/HAR,
9
+ continuous trace rotation/gzip, and detached reusable browser sessions with idle
10
+ shutdown. Add early trigger readiness, explicit trigger concurrency, visible
11
+ counts and configured overlay dismissal. Fix navigation-interrupted waits,
12
+ Trusted Types capture, Chrome panel restrictions and deterministic tab selection.
13
+ Close ordinary launched CLI browsers even when their connection supports detach;
14
+ only explicitly kept-open sessions survive dispatcher cleanup.
15
+ Add stable viewport capture with native-view CDP, viewport-only engine fallbacks
16
+ and displayed-window regression coverage; document full-page compositor effects.
17
+ Preserve readiness timer expiry when the rounded clock still reports budget.
18
+
3
19
  ## 0.27.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -802,6 +802,10 @@ failed or cancelled download raises the failure rather than returning a path.
802
802
 
803
803
  ### Portable Traces
804
804
 
805
+ See [capture, debugging and reusable sessions](../docs/capture-and-debugging.md)
806
+ for screenshots, recordings, full/text DOM links, network/HAR, rotation,
807
+ persistent browsers, early trigger readiness and engine capability limits.
808
+
805
809
  `startTrace()` records a session into a schema-versioned bundle any of the three
806
810
  languages can read - the manifest, an ordered NDJSON timeline, per-checkpoint
807
811
  DOM snapshots and the mutation batches between them:
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.28.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",
@@ -45,6 +45,7 @@ async function connectSelenium(options, dependencies) {
45
45
  ...options,
46
46
  debuggerAddress: new URL(options.cdpEndpoint).host,
47
47
  });
48
+ await selectWebDriverTab(connected.driver, options);
48
49
  return {
49
50
  ...connected,
50
51
  browser: connected.driver,
@@ -52,6 +53,52 @@ async function connectSelenium(options, dependencies) {
52
53
  };
53
54
  }
54
55
 
56
+ async function selectWebDriverTab(driver, options) {
57
+ if (!driver?.getAllWindowHandles) {
58
+ return;
59
+ }
60
+ const original = await driver.getWindowHandle();
61
+ const handles = await driver.getAllWindowHandles();
62
+ let selected = original;
63
+ if (options.targetId || options.url) {
64
+ selected = null;
65
+ for (const handle of handles) {
66
+ if (
67
+ options.targetId &&
68
+ handle !== options.targetId &&
69
+ handle !== `CDwindow-${options.targetId}`
70
+ ) {
71
+ continue;
72
+ }
73
+ await driver.switchTo().window(handle);
74
+ const url = await driver.getCurrentUrl();
75
+ if (
76
+ options.url &&
77
+ !(typeof options.url === 'function'
78
+ ? options.url(url)
79
+ : options.url instanceof RegExp
80
+ ? options.url.test(url)
81
+ : url === options.url)
82
+ ) {
83
+ continue;
84
+ }
85
+ selected = handle;
86
+ break;
87
+ }
88
+ if (!selected) {
89
+ await driver.switchTo().window(original);
90
+ throw new Error('No tab matches the requested targetId or URL');
91
+ }
92
+ }
93
+ if (options.singleTab) {
94
+ for (const handle of handles.filter((handle) => handle !== selected)) {
95
+ await driver.switchTo().window(handle);
96
+ await driver.close();
97
+ }
98
+ }
99
+ await driver.switchTo().window(selected);
100
+ }
101
+
55
102
  function addDefinedOptions(options, values) {
56
103
  for (const [key, value] of Object.entries(values)) {
57
104
  if (value !== undefined) {
@@ -111,19 +158,75 @@ async function prepareStorageState({ storageState, seedCookies }) {
111
158
  * @param {Object[]} pages - Engine page handles
112
159
  * @returns {Promise<Object|undefined>}
113
160
  */
114
- export async function pickForegroundPage(pages) {
161
+ export async function pickForegroundPage(pages, options = {}) {
162
+ const targets = new Map();
163
+ // Playwright emulates focus on every attached tab. Remove the emulation
164
+ // before observing visibility; DevTools target order has no active-tab flag.
115
165
  for (const page of pages) {
116
- if (typeof page?.evaluate !== 'function') {
117
- continue;
166
+ let session;
167
+ try {
168
+ session =
169
+ (await page.context?.().newCDPSession?.(page)) ??
170
+ (await page.target?.().createCDPSession?.());
171
+ if (!session) {
172
+ continue;
173
+ }
174
+ const { targetInfo } = await session.send('Target.getTargetInfo');
175
+ targets.set(page, targetInfo.targetId);
176
+ await session.send('Emulation.setFocusEmulationEnabled', {
177
+ enabled: false,
178
+ });
179
+ } catch {
180
+ // Non-Chromium engines do not expose CDP. Explicit URL matching still
181
+ // works there, and normal visibility is not affected by this emulation.
182
+ } finally {
183
+ await session?.detach?.().catch(() => {});
184
+ }
185
+ }
186
+ let selected;
187
+ if (options.targetId || options.url) {
188
+ selected = pages.find((page) => {
189
+ if (options.targetId && targets.get(page) !== options.targetId) {
190
+ return false;
191
+ }
192
+ const url = page.url();
193
+ const matcher = options.url;
194
+ if (matcher instanceof RegExp) {
195
+ matcher.lastIndex = 0;
196
+ return matcher.test(url);
197
+ }
198
+ if (typeof matcher === 'function') {
199
+ return matcher(url);
200
+ }
201
+ return !matcher || matcher === url;
202
+ });
203
+ if (!selected) {
204
+ throw new RangeError('No tab matches the requested targetId/URL');
118
205
  }
119
- const state = await page
120
- .evaluate(() => document.visibilityState)
121
- .catch(() => null);
122
- if (state === 'visible') {
123
- return page;
206
+ }
207
+ if (!selected) {
208
+ for (const page of pages) {
209
+ if (typeof page?.evaluate !== 'function') {
210
+ continue;
211
+ }
212
+ const state = await page
213
+ .evaluate(() => document.visibilityState)
214
+ .catch(() => null);
215
+ if (state === 'visible') {
216
+ selected = page;
217
+ break;
218
+ }
219
+ }
220
+ }
221
+ selected ??= pages[0];
222
+ if (options.singleTab && selected) {
223
+ for (const page of pages) {
224
+ if (page !== selected) {
225
+ await page.close();
226
+ }
124
227
  }
125
228
  }
126
- return pages[0];
229
+ return selected;
127
230
  }
128
231
 
129
232
  async function connectPlaywright({ options, loadPlaywright, storageState }) {
@@ -138,10 +241,11 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
138
241
  throw new Error('Connected Playwright browser has no default context');
139
242
  }
140
243
  const page =
141
- (await pickForegroundPage(context.pages())) ?? (await context.newPage());
244
+ (await pickForegroundPage(context.pages(), options)) ??
245
+ (await context.newPage());
142
246
 
143
247
  await restorePlaywrightStorageState({ context, storageState });
144
- return { browser, page };
248
+ return { browser, page, detach: browser.close?.bind(browser) };
145
249
  }
146
250
 
147
251
  async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
@@ -151,11 +255,11 @@ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
151
255
  buildPuppeteerConnectOptions(options)
152
256
  );
153
257
  const page =
154
- (await pickForegroundPage(await browser.pages())) ??
258
+ (await pickForegroundPage(await browser.pages(), options)) ??
155
259
  (await browser.newPage());
156
260
 
157
261
  await restorePuppeteerStorageState({ page, storageState });
158
- return { browser, page };
262
+ return { browser, page, detach: browser.disconnect?.bind(browser) };
159
263
  }
160
264
 
161
265
  /**
@@ -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
  ]),
@@ -17,7 +17,8 @@
17
17
  "--disable-session-crashed-bubble",
18
18
  "--hide-crash-restore-bubble",
19
19
  "--disable-crash-restore"
20
- ]
20
+ ],
21
+ "disableFeatures": ["SessionRestoreInfobar"]
21
22
  },
22
23
  {
23
24
  "id": "no-infobars",
@@ -60,7 +61,12 @@
60
61
  {
61
62
  "id": "no-translate",
62
63
  "description": "Disable the Translate feature and its bubble.",
63
- "disableFeatures": ["Translate"]
64
+ "disableFeatures": ["Translate"],
65
+ "preferences": {
66
+ "translate": {
67
+ "enabled": false
68
+ }
69
+ }
64
70
  },
65
71
  {
66
72
  "id": "no-component-update",
@@ -124,6 +130,24 @@
124
130
  "id": "start-maximized",
125
131
  "description": "Start with a maximized window.",
126
132
  "args": ["--start-maximized"]
133
+ },
134
+ {
135
+ "id": "no-save-bubbles",
136
+ "description": "Suppress password and payment-card saving bubbles and browser promos.",
137
+ "disableFeatures": [
138
+ "AutofillSaveCardUi",
139
+ "ChromeWhatsNewUI",
140
+ "PromotionalTabs"
141
+ ],
142
+ "preferences": {
143
+ "credentials_enable_service": false,
144
+ "profile": {
145
+ "password_manager_enabled": false
146
+ },
147
+ "autofill": {
148
+ "credit_card_enabled": false
149
+ }
150
+ }
127
151
  }
128
152
  ],
129
153
  "presets": {
@@ -142,6 +166,13 @@
142
166
  "no-default-browser-check",
143
167
  "no-google-services",
144
168
  "no-translate"
169
+ ],
170
+ "quiet-ui": [
171
+ "no-first-run",
172
+ "no-default-browser-check",
173
+ "no-crash-restore",
174
+ "no-translate",
175
+ "no-save-bubbles"
145
176
  ]
146
177
  }
147
178
  }
@@ -7,6 +7,7 @@ import {
7
7
 
8
8
  import { assertSupportedEngine } from './connector.js';
9
9
  import { emulateMedia } from './media.js';
10
+ import { reserveLoopbackPort } from './debugging-port.js';
10
11
  import {
11
12
  buildPlaywrightLaunchOptions,
12
13
  buildPuppeteerLaunchOptions,
@@ -14,6 +15,7 @@ import {
14
15
  } from './launch-options.js';
15
16
  import {
16
17
  createTemporaryUserDataDir,
18
+ configureUserDataDir,
17
19
  removeUserDataDir,
18
20
  } from './profile-directory.js';
19
21
  import { launchRealBrowser } from './real-browser.js';
@@ -168,6 +170,7 @@ async function launchWithEngine(options, dependencies) {
168
170
  extraArgs,
169
171
  ignoreDefaultArgs,
170
172
  restrictions,
173
+ disableFeatures: options.disableFeatures,
171
174
  });
172
175
  const restrictionEnv = resolveRestrictions(restrictions).env;
173
176
  const childEnv =
@@ -177,6 +180,13 @@ async function launchWithEngine(options, dependencies) {
177
180
  const temporaryProfile = !requestedUserDataDir;
178
181
  const userDataDir =
179
182
  requestedUserDataDir ?? (await createTemporaryUserDataDir());
183
+ await configureUserDataDir(userDataDir, {
184
+ preferences: {
185
+ ...resolveRestrictions(restrictions).preferences,
186
+ ...options.preferences,
187
+ },
188
+ localState: options.localState,
189
+ });
180
190
  const engineModule = await (
181
191
  dependencies.loadEngineModule ?? loadEngine[engine]
182
192
  )();
@@ -307,6 +317,15 @@ export async function launchBrowserWithDependencies(
307
317
  options = {},
308
318
  dependencies = {}
309
319
  ) {
320
+ if (options.keepOpen) {
321
+ const { connectOrLaunch } = await import('./persistent-session.js');
322
+ return connectOrLaunch({
323
+ ...options,
324
+ userDataDir: options.userDataDir ?? (await createTemporaryUserDataDir()),
325
+ remoteDebuggingPort:
326
+ options.remoteDebuggingPort ?? (await reserveLoopbackPort()),
327
+ });
328
+ }
310
329
  sessionPersistencePath(options);
311
330
  if (isSafariChannel(options.channel ?? options.browser)) {
312
331
  validateLaunchMode({
@@ -355,7 +374,13 @@ async function launchNonSafariBrowser(options, dependencies) {
355
374
  }
356
375
  }
357
376
  // Validate arguments before anything is started or written to disk.
358
- resolveChromeArgs({ args, extraArgs, ignoreDefaultArgs, restrictions });
377
+ resolveChromeArgs({
378
+ args,
379
+ extraArgs,
380
+ ignoreDefaultArgs,
381
+ restrictions,
382
+ disableFeatures: options.disableFeatures,
383
+ });
359
384
  const resolvedStorageState = await loadStorageState(storageState);
360
385
 
361
386
  if (verbose) {