browser-commander 0.26.3 → 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 (115) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +6 -0
  4. package/eslint.config.js +11 -0
  5. package/package.json +9 -4
  6. package/src/bindings.js +19 -0
  7. package/src/browser/attach/snapshot-launch.js +8 -6
  8. package/src/browser/attach/snapshot.js +39 -2
  9. package/src/browser/browser-cookie-database.js +71 -2
  10. package/src/browser/browser-cookie-session.js +183 -0
  11. package/src/browser/browser-cookies.js +29 -0
  12. package/src/browser/connector.js +117 -13
  13. package/src/browser/debugging-port.js +4 -2
  14. package/src/browser/default-browser.js +33 -9
  15. package/src/browser/launch-diagnostics.js +102 -0
  16. package/src/browser/launch-options.js +2 -1
  17. package/src/browser/launch-restrictions.json +33 -2
  18. package/src/browser/launcher.js +54 -9
  19. package/src/browser/migration/sqlite-snapshot.js +5 -27
  20. package/src/browser/navigation.js +173 -98
  21. package/src/browser/persistent-session.js +360 -0
  22. package/src/browser/profile-directory.js +20 -0
  23. package/src/browser/real-browser.js +142 -70
  24. package/src/browser/restrictions.js +13 -4
  25. package/src/browser/session-cookies.js +78 -0
  26. package/src/browser/session-persistence.js +72 -0
  27. package/src/browser/session-watchdog.js +71 -0
  28. package/src/browser/session-worker.js +70 -0
  29. package/src/browser/storage-state.js +32 -6
  30. package/src/browser/webdriver.js +4 -0
  31. package/src/capture/encoding.js +327 -0
  32. package/src/capture/errors.js +12 -0
  33. package/src/capture/files.js +9 -0
  34. package/src/capture/index.js +5 -0
  35. package/src/capture/recording.js +136 -0
  36. package/src/capture/screenshot.js +222 -0
  37. package/src/capture/video.js +186 -0
  38. package/src/capture/viewport.js +68 -0
  39. package/src/capture/worker.js +22 -0
  40. package/src/cli/args.js +66 -1
  41. package/src/cli/commands.js +113 -2
  42. package/src/cli/high-level-methods.js +40 -3
  43. package/src/cli/sessions.js +11 -3
  44. package/src/core/commander-features.js +105 -0
  45. package/src/core/constants.js +1 -0
  46. package/src/core/dialog-manager.js +7 -0
  47. package/src/core/managed-navigation.js +101 -0
  48. package/src/core/navigation-manager.js +66 -80
  49. package/src/core/navigation-operation.js +25 -0
  50. package/src/core/navigation-readiness.js +68 -16
  51. package/src/core/navigation-safety.js +51 -0
  52. package/src/core/network-tracker.js +39 -92
  53. package/src/core/page-session.js +3 -2
  54. package/src/core/page-trigger-manager.js +130 -42
  55. package/src/core/readiness.js +76 -14
  56. package/src/core/subscriptions.js +20 -0
  57. package/src/core/webdriver-page.js +38 -0
  58. package/src/elements/locators.js +61 -37
  59. package/src/elements/reusable.js +92 -0
  60. package/src/elements/selectors.js +37 -81
  61. package/src/elements/visibility.js +38 -21
  62. package/src/exports.js +14 -0
  63. package/src/factory.js +17 -16
  64. package/src/high-level/universal-logic.js +57 -21
  65. package/src/index.js +2 -0
  66. package/src/interactions/click.js +47 -0
  67. package/src/interactions/overlays.js +28 -0
  68. package/src/interactions/scroll.js +8 -2
  69. package/src/traces/bundle.js +14 -5
  70. package/src/traces/checkpoints.js +99 -0
  71. package/src/traces/links.js +117 -1
  72. package/src/traces/mutation-stream.js +1 -0
  73. package/src/traces/network.js +257 -0
  74. package/src/traces/page-capture.js +143 -52
  75. package/src/traces/reader.js +8 -10
  76. package/src/traces/recorder.js +221 -245
  77. package/src/traces/render.js +168 -0
  78. package/src/traces/rolling.js +180 -0
  79. package/src/traces/start-cleanup.js +15 -0
  80. package/src/traces/stop.js +159 -0
  81. package/src/traces/storage.js +43 -0
  82. package/src/utilities/subprocess.js +47 -4
  83. package/tests/e2e/cli.e2e.test.js +20 -1
  84. package/tests/e2e/click-readiness.e2e.test.js +1 -1
  85. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  86. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  87. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  88. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  89. package/tests/unit/browser/default-browser.test.js +18 -0
  90. package/tests/unit/browser/launch-building-blocks.test.js +5 -1
  91. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  92. package/tests/unit/browser/launcher.test.js +5 -1
  93. package/tests/unit/browser/persistent-session.test.js +59 -0
  94. package/tests/unit/browser/quiet-ui.test.js +16 -0
  95. package/tests/unit/browser/real-browser.test.js +1 -1
  96. package/tests/unit/browser/session-workflows.test.js +206 -0
  97. package/tests/unit/browser/storage-state.test.js +7 -2
  98. package/tests/unit/browser/tab-selection.test.js +38 -0
  99. package/tests/unit/capture/capture.test.js +52 -0
  100. package/tests/unit/capture/cleanup.test.js +24 -0
  101. package/tests/unit/capture/viewport.test.js +168 -0
  102. package/tests/unit/cli/dispatcher.test.js +25 -0
  103. package/tests/unit/core/navigation-budget.test.js +120 -0
  104. package/tests/unit/core/navigation-manager.test.js +23 -1
  105. package/tests/unit/core/readiness.test.js +21 -0
  106. package/tests/unit/core/trigger-readiness.test.js +39 -0
  107. package/tests/unit/core/webdriver-page.test.js +33 -0
  108. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  109. package/tests/unit/elements/visible-count.test.js +24 -0
  110. package/tests/unit/elements/wait-navigation.test.js +26 -0
  111. package/tests/unit/interactions/navigation-timeout.test.js +39 -0
  112. package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
  113. package/tests/unit/traces/bundle.test.js +16 -0
  114. package/tests/unit/traces/links.test.js +2 -2
  115. package/tests/unit/traces/network.test.js +97 -0
@@ -0,0 +1,183 @@
1
+ import path from 'node:path';
2
+ import { rm } from 'node:fs/promises';
3
+ import { browserFamily } from './browser-sources.js';
4
+ import { matchesDomains } from './migration/domains.js';
5
+ import {
6
+ resolveBrowserProfile,
7
+ resolveSourceBrowser,
8
+ } from './browser-profiles.js';
9
+ import { saveStorageState } from './storage-state.js';
10
+ import { setCookies } from './session-cookies.js';
11
+
12
+ /** Decrypt through the source browser in a disposable cookie-only snapshot. */
13
+ export async function readBrowserCookieSession(options, dependencies = {}) {
14
+ const browser = await resolveSourceBrowser(options.browser, dependencies);
15
+ if (browserFamily(browser) !== 'chromium') {
16
+ throw new TypeError(
17
+ 'Browser-backed cookie reading requires a Chromium snapshot; use via: database for Firefox or Safari'
18
+ );
19
+ }
20
+ const profile = options.profileDir
21
+ ? { path: options.profileDir }
22
+ : await resolveBrowserProfile({
23
+ ...dependencies,
24
+ browser,
25
+ profile: options.profile,
26
+ });
27
+ const launch =
28
+ dependencies.launch ??
29
+ (await import('./real-browser.js')).launchRealBrowser;
30
+ const result = await launch({
31
+ ...options.launchOptions,
32
+ channel: browser,
33
+ engine: options.engine ?? 'playwright',
34
+ attach: {
35
+ mode: 'snapshot',
36
+ browser,
37
+ profile: path.basename(profile.path),
38
+ userDataDir: path.dirname(profile.path),
39
+ include: ['cookies'],
40
+ },
41
+ });
42
+ try {
43
+ const state = await saveStorageState(result.page);
44
+ return state.cookies.filter((cookie) =>
45
+ matchesDomains(
46
+ cookie.domain,
47
+ options.domains ?? (options.domainFilter ? [options.domainFilter] : [])
48
+ )
49
+ );
50
+ } finally {
51
+ await result.close();
52
+ }
53
+ }
54
+
55
+ async function readDatabaseSession(source, options, dependencies) {
56
+ const read =
57
+ dependencies.readCookies ??
58
+ (await import('./browser-cookies.js')).readBrowserCookies;
59
+ const cookies = (
60
+ await read({
61
+ browser: source.browser,
62
+ profileDir: source.path,
63
+ via: 'database',
64
+ cache: false,
65
+ })
66
+ ).filter((cookie) => matchesDomains(cookie.domain, options.domains));
67
+ let loggedIn = null;
68
+ if (options.isLoggedIn) {
69
+ const launch =
70
+ dependencies.launch ?? (await import('./launcher.js')).launchBrowser;
71
+ const engine = source.engine ?? options.engine ?? 'playwright';
72
+ const result = await launch({
73
+ ...options.launchOptions,
74
+ ...source.launchOptions,
75
+ engine,
76
+ launch: 'engine',
77
+ userDataDir: undefined,
78
+ attach: undefined,
79
+ persistSessionCookies: false,
80
+ });
81
+ try {
82
+ await setCookies({ page: result.page, engine, cookies });
83
+ loggedIn = Boolean(
84
+ await options.isLoggedIn({ ...result, source, cookies })
85
+ );
86
+ } finally {
87
+ await result.close();
88
+ }
89
+ }
90
+ return { ...source, cookies, loggedIn };
91
+ }
92
+
93
+ /** Discover candidate profiles and optionally validate their live sign-in state. */
94
+ export async function findSiteSessions(options = {}, dependencies = {}) {
95
+ if (!Array.isArray(options.domains) || !options.domains.length) {
96
+ throw new TypeError('findSiteSessions requires domains');
97
+ }
98
+ const list =
99
+ dependencies.listSources ??
100
+ (await import('./browser-cookies.js')).listCookieSources;
101
+ const sources = [
102
+ ...(options.sources ?? (await list({ domains: options.domains }))),
103
+ ...(options.profiles ?? []),
104
+ ];
105
+ const sessions = [];
106
+ for (const source of sources) {
107
+ let result;
108
+ let snapshot;
109
+ try {
110
+ if (browserFamily(source.browser) !== 'chromium') {
111
+ sessions.push(await readDatabaseSession(source, options, dependencies));
112
+ continue;
113
+ }
114
+ const attach = {
115
+ mode: 'snapshot',
116
+ browser: source.browser,
117
+ profile: path.basename(source.path),
118
+ userDataDir: path.dirname(source.path),
119
+ include: ['cookies'],
120
+ };
121
+ if (source.launch === 'engine') {
122
+ const copy =
123
+ dependencies.snapshot ??
124
+ (await import('./attach/snapshot.js')).snapshotUserDataDir;
125
+ snapshot = await copy(attach);
126
+ const launch =
127
+ dependencies.launch ?? (await import('./launcher.js')).launchBrowser;
128
+ result = await launch({
129
+ ...options.launchOptions,
130
+ ...source.launchOptions,
131
+ engine: source.engine ?? options.engine ?? 'playwright',
132
+ launch: 'engine',
133
+ userDataDir: snapshot.target,
134
+ args: [
135
+ ...(options.launchOptions?.args ?? []),
136
+ ...(source.launchOptions?.args ?? []),
137
+ `--profile-directory=${attach.profile}`,
138
+ ],
139
+ });
140
+ } else {
141
+ const launch =
142
+ dependencies.launch ??
143
+ (await import('./real-browser.js')).launchRealBrowser;
144
+ result = await launch({
145
+ ...options.launchOptions,
146
+ engine: source.engine ?? options.engine ?? 'playwright',
147
+ channel: source.browser,
148
+ attach,
149
+ });
150
+ }
151
+ const state = await saveStorageState(result.page);
152
+ const cookies = state.cookies.filter((cookie) =>
153
+ matchesDomains(cookie.domain, options.domains)
154
+ );
155
+ const loggedIn = options.isLoggedIn
156
+ ? Boolean(await options.isLoggedIn({ ...result, source, cookies }))
157
+ : null;
158
+ sessions.push({ ...source, cookies, loggedIn });
159
+ } catch (error) {
160
+ sessions.push({
161
+ ...source,
162
+ cookies: [],
163
+ loggedIn: null,
164
+ error: error.message,
165
+ });
166
+ } finally {
167
+ try {
168
+ await result?.close();
169
+ } catch (error) {
170
+ sessions.at(-1).error = error.message;
171
+ } finally {
172
+ if (snapshot) {
173
+ try {
174
+ await rm(snapshot.target, { recursive: true, force: true });
175
+ } catch (error) {
176
+ sessions.at(-1).error = error.message;
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ return sessions;
183
+ }
@@ -1,3 +1,4 @@
1
+ import { readBrowserCookieSession } from './browser-cookie-session.js';
1
2
  import { localStatePathForProfile } from './browser-profile-files.js';
2
3
  import os from 'node:os';
3
4
 
@@ -126,6 +127,17 @@ function encryptionPrefix(encryptedValue) {
126
127
  }
127
128
 
128
129
  function chromiumKeyForPrefix(context, prefix) {
130
+ if (context.keystore === 'mock') {
131
+ if (context.platform === 'win32') {
132
+ throw new Error(
133
+ 'Use via: browser for Windows engine mock-keystore profiles'
134
+ );
135
+ }
136
+ return deriveChromiumCookieKey(
137
+ context.platform === 'darwin' ? 'mock_password' : 'peanuts',
138
+ context.platform
139
+ );
140
+ }
129
141
  if (context.platform === 'linux' && prefix === 'v10') {
130
142
  return deriveChromiumCookieKey('peanuts', 'linux');
131
143
  }
@@ -279,6 +291,21 @@ export async function readBrowserCookiesWithDependencies(
279
291
  throw new TypeError('readBrowserCookies requires an options object');
280
292
  }
281
293
  const platform = dependencies.platform ?? process.platform;
294
+ const via =
295
+ options.via ??
296
+ (platform === 'darwin' &&
297
+ options.keystore !== 'mock' &&
298
+ browserFamily(
299
+ await resolveSourceBrowser(options.browser, { ...dependencies, platform })
300
+ ) === 'chromium'
301
+ ? 'browser'
302
+ : 'database');
303
+ if (!['browser', 'database', 'keychain'].includes(via)) {
304
+ throw new TypeError('via must be browser, database, or keychain');
305
+ }
306
+ if (via === 'browser') {
307
+ return readBrowserCookieSession(options, { ...dependencies, platform });
308
+ }
282
309
  const homeDir = dependencies.homeDir ?? os.homedir();
283
310
  const environment = dependencies.environment ?? process.env;
284
311
  const runCommand = dependencies.runCommand;
@@ -316,6 +343,7 @@ export async function readBrowserCookiesWithDependencies(
316
343
  profile: profilePath,
317
344
  domainFilter: options.domainFilter ?? null,
318
345
  ignoreDecryptionErrors: options.ignoreDecryptionErrors === true,
346
+ keystore: options.keystore ?? 'os',
319
347
  });
320
348
  const now = dependencies.now ?? Date.now;
321
349
  const cachedCookies = await readCookieResultCache({
@@ -348,6 +376,7 @@ export async function readBrowserCookiesWithDependencies(
348
376
  const rows = readChromiumRows(database, options.domainFilter);
349
377
  cookies = await mapChromiumRows(rows, databaseVersion, {
350
378
  browser,
379
+ keystore: options.keystore,
351
380
  cache,
352
381
  credentialKeys: new Map(),
353
382
  decryptWindowsDpapi:
@@ -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
  /**
@@ -117,7 +117,7 @@ export function watchDevToolsOutput(stream, { forward = false } = {}) {
117
117
  }
118
118
  let text = '';
119
119
  let settled = null;
120
- stream.on('data', (chunk) => {
120
+ const onData = (chunk) => {
121
121
  if (forward) {
122
122
  process.stderr.write(chunk);
123
123
  }
@@ -130,9 +130,11 @@ export function watchDevToolsOutput(stream, { forward = false } = {}) {
130
130
  if (parsed.listening) {
131
131
  settled = parsed;
132
132
  }
133
- });
133
+ };
134
+ stream.on('data', onData);
134
135
  return {
135
136
  available: true,
137
+ dispose: () => stream.off?.('data', onData),
136
138
  state: () => settled ?? parseDevToolsOutput(text),
137
139
  };
138
140
  }
@@ -68,16 +68,40 @@ function parseMacLaunchServicesHandler(output) {
68
68
  }
69
69
 
70
70
  async function resolveDarwinDefault(runCommand, environment) {
71
- const output = await runCommand(
72
- 'defaults',
73
- [
74
- 'read',
75
- 'com.apple.LaunchServices/com.apple.launchservices.secure',
76
- 'LSHandlers',
77
- ],
78
- environment
71
+ const output = await Promise.resolve()
72
+ .then(() =>
73
+ runCommand(
74
+ 'defaults',
75
+ [
76
+ 'read',
77
+ 'com.apple.LaunchServices/com.apple.launchservices.secure',
78
+ 'LSHandlers',
79
+ ],
80
+ environment
81
+ )
82
+ )
83
+ .catch(() => '');
84
+ const legacy = browserForIdentifier(
85
+ parseMacLaunchServicesHandler(output),
86
+ 'darwin'
79
87
  );
80
- return browserForIdentifier(parseMacLaunchServicesHandler(output), 'darwin');
88
+ if (legacy) {
89
+ return legacy;
90
+ }
91
+ try {
92
+ const script =
93
+ "ObjC.import('AppKit'); var app = $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL($.NSURL.URLWithString('https://example.invalid')); app ? ObjC.unwrap($.NSBundle.bundleWithURL(app).bundleIdentifier) : ''";
94
+ return browserForIdentifier(
95
+ await runCommand(
96
+ 'osascript',
97
+ ['-l', 'JavaScript', '-e', script],
98
+ environment
99
+ ),
100
+ 'darwin'
101
+ );
102
+ } catch {
103
+ return null;
104
+ }
81
105
  }
82
106
 
83
107
  async function resolveLinuxDefault(runCommand, environment) {
@@ -0,0 +1,102 @@
1
+ /** Bounded launch evidence with a stable contract independent of engine text. */
2
+ export class BrowserLaunchError extends Error {
3
+ constructor({
4
+ phase,
5
+ engine,
6
+ category,
7
+ exitCode = null,
8
+ signal = null,
9
+ stderrTail = '',
10
+ cause,
11
+ }) {
12
+ super(
13
+ `Browser launch failed (${phase}: ${category}): ${stderrTail || cause?.message || ''}`,
14
+ { cause }
15
+ );
16
+ this.name = 'BrowserLaunchError';
17
+ Object.assign(this, {
18
+ phase,
19
+ engine,
20
+ category,
21
+ exitCode,
22
+ signal,
23
+ stderrTail,
24
+ });
25
+ }
26
+ }
27
+
28
+ /** Remove paths, URL credentials/query strings and common secret assignments. */
29
+ export function redactLaunchEvidence(value, redactor) {
30
+ let text = String(value ?? '')
31
+ .replace(/\b(?:https?|wss?):\/\/[^\s"'<>]+/giu, '[url]')
32
+ .replace(/(?:[A-Za-z]:\\|\/)(?:[^\s:;"'<>]+)/gu, '[path]')
33
+ .replace(
34
+ /\b(token|password|secret|authorization|cookie)\s*[=:]\s*[^\s,;]+/giu,
35
+ '$1=[redacted]'
36
+ );
37
+ if (redactor) {
38
+ try {
39
+ text = String(redactor(text));
40
+ } catch {
41
+ text = '[diagnostic redaction failed]';
42
+ }
43
+ }
44
+ const bytes = Buffer.from(text, 'utf8');
45
+ let start = Math.max(0, bytes.length - 4096);
46
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) {
47
+ start++;
48
+ }
49
+ return bytes.subarray(start).toString('utf8');
50
+ }
51
+
52
+ export function launchFailure(
53
+ cause,
54
+ {
55
+ phase,
56
+ engine = 'playwright',
57
+ browserProcess,
58
+ diagnosticRedactor,
59
+ executablePath,
60
+ userDataDir,
61
+ } = {}
62
+ ) {
63
+ if (cause instanceof BrowserLaunchError) {
64
+ return cause;
65
+ }
66
+ const raw = String(cause?.message ?? cause);
67
+ const category =
68
+ cause?.code === 'ENOENT' ||
69
+ /not found|does not exist|not an executable|not accessible|could not find an installed|no .*executable/i.test(
70
+ raw
71
+ )
72
+ ? 'missing_executable'
73
+ : cause?.name === 'PortRaceError'
74
+ ? 'port_race'
75
+ : /timed out|timeout/i.test(raw)
76
+ ? 'startup_timeout'
77
+ : browserProcess?.exitCode !== null &&
78
+ browserProcess?.exitCode !== undefined
79
+ ? 'early_exit'
80
+ : 'configuration';
81
+ const scrub = (value) => {
82
+ let text = String(value ?? '');
83
+ for (const sensitive of [executablePath, userDataDir]) {
84
+ if (sensitive) {
85
+ text = text.replaceAll(sensitive, '[path]');
86
+ }
87
+ }
88
+ return redactLaunchEvidence(text, diagnosticRedactor);
89
+ };
90
+ const safeCause = new Error(scrub(raw));
91
+ safeCause.name = cause?.name ?? 'Error';
92
+ safeCause.code = cause?.code;
93
+ return new BrowserLaunchError({
94
+ phase,
95
+ engine,
96
+ category,
97
+ exitCode: browserProcess?.exitCode ?? null,
98
+ signal: browserProcess?.signalCode ?? null,
99
+ stderrTail: scrub(browserProcess?.stderrTail ?? cause?.stderrTail ?? ''),
100
+ cause: safeCause,
101
+ });
102
+ }
@@ -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
  }