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
@@ -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) {
@@ -0,0 +1,432 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { connectBrowser, pickForegroundPage } from './connector.js';
6
+ import { launchAndConnectRealBrowserWithDependencies } from './real-browser.js';
7
+ import { assertDedicatedUserDataDir } from './system-browser.js';
8
+ import { findSessionProcess } from './session-adoption.js';
9
+ import { startDetachedProcess } from '../utilities/subprocess.js';
10
+
11
+ export const SESSION_METADATA = '.browser-commander-session.json';
12
+ const activity = new WeakMap();
13
+ export function touchSessionPage(page) {
14
+ activity.get(page)?.();
15
+ }
16
+ const read = (file) =>
17
+ fs
18
+ .readFile(file, 'utf8')
19
+ .then(JSON.parse)
20
+ .catch(() => null);
21
+ export async function writeSessionMetadata(file, value) {
22
+ const temporary = `${file}.${randomUUID()}.tmp`;
23
+ try {
24
+ await fs.writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
25
+ await fs.rename(temporary, file);
26
+ } finally {
27
+ await fs.rm(temporary, { force: true });
28
+ }
29
+ }
30
+ export function probeSession(endpoint) {
31
+ return fetch(`${endpoint}/json/version`, {
32
+ signal: AbortSignal.timeout(1000),
33
+ })
34
+ .then((r) => (r.ok ? r.json() : null))
35
+ .catch(() => null);
36
+ }
37
+ export async function acquireLease(file, retries = 50) {
38
+ let lease;
39
+ try {
40
+ lease = await fs.open(file, 'wx', 0o600);
41
+ } catch (error) {
42
+ if (error.code !== 'EEXIST') {
43
+ throw error;
44
+ }
45
+ const owner = await read(file);
46
+ if (retries > 0) {
47
+ let alive = !owner?.pid;
48
+ if (owner?.pid) {
49
+ try {
50
+ process.kill(owner.pid, 0);
51
+ alive = true;
52
+ } catch (error) {
53
+ alive = error.code !== 'ESRCH';
54
+ }
55
+ }
56
+ if (alive) {
57
+ await new Promise((resolve) => setTimeout(resolve, 20));
58
+ return acquireLease(file, retries - 1);
59
+ }
60
+ }
61
+ if (!owner?.pid) {
62
+ throw new Error('Persistent session launch is already in progress', {
63
+ cause: error,
64
+ });
65
+ }
66
+ let alive = true;
67
+ try {
68
+ process.kill(owner.pid, 0);
69
+ } catch (error) {
70
+ alive = error.code !== 'ESRCH';
71
+ }
72
+ if (alive) {
73
+ throw new Error('Persistent session launch is already in progress', {
74
+ cause: error,
75
+ });
76
+ }
77
+ await fs.rm(file);
78
+ lease = await fs.open(file, 'wx', 0o600);
79
+ }
80
+ const token = randomUUID();
81
+ await lease.writeFile(JSON.stringify({ pid: process.pid, token }));
82
+ return async () => {
83
+ await lease.close();
84
+ if ((await read(file))?.token === token) {
85
+ await fs.rm(file, { force: true });
86
+ }
87
+ };
88
+ }
89
+ function validate(options) {
90
+ if (
91
+ !options.userDataDir ||
92
+ !Number.isInteger(options.remoteDebuggingPort) ||
93
+ options.remoteDebuggingPort < 1 ||
94
+ options.remoteDebuggingPort > 65535
95
+ ) {
96
+ throw new TypeError(
97
+ 'connectOrLaunch requires a dedicated userDataDir and fixed remoteDebuggingPort'
98
+ );
99
+ }
100
+ if (!Number.isFinite(options.idleTimeoutMs) || options.idleTimeoutMs <= 0) {
101
+ throw new RangeError('idleTimeoutMs must be positive');
102
+ }
103
+ if (!['playwright', 'puppeteer'].includes(options.engine)) {
104
+ throw new TypeError('Persistent sessions require a Chromium CDP engine');
105
+ }
106
+ assertDedicatedUserDataDir(options.userDataDir);
107
+ }
108
+ async function open(options, file, endpoint) {
109
+ let metadata = await read(file);
110
+ const version = await probeSession(endpoint);
111
+ if (version) {
112
+ let adopted = false;
113
+ if (!metadata && options.adoptExisting) {
114
+ const pid = await findSessionProcess(options);
115
+ metadata = {
116
+ token: randomUUID(),
117
+ pid,
118
+ userDataDir: path.resolve(options.userDataDir),
119
+ remoteDebuggingPort: options.remoteDebuggingPort,
120
+ webSocketDebuggerUrl: version.webSocketDebuggerUrl,
121
+ };
122
+ adopted = true;
123
+ }
124
+ if (
125
+ !metadata ||
126
+ path.resolve(metadata.userDataDir ?? '') !==
127
+ path.resolve(options.userDataDir) ||
128
+ metadata.remoteDebuggingPort !== options.remoteDebuggingPort ||
129
+ metadata.webSocketDebuggerUrl !== version.webSocketDebuggerUrl
130
+ ) {
131
+ throw new Error('Debugging port belongs to another browser/profile');
132
+ }
133
+ const session = await connectBrowser({
134
+ ...options,
135
+ cdpEndpoint: endpoint,
136
+ targetId:
137
+ options.targetId ??
138
+ (!options.url && !options.urlMatchers?.length
139
+ ? metadata.targetId
140
+ : undefined),
141
+ fallback: !options.targetId,
142
+ });
143
+ return { session, metadata, reused: true, adopted };
144
+ }
145
+ const session = await launchAndConnectRealBrowserWithDependencies(
146
+ { ...options, keepOpen: false },
147
+ {
148
+ spawnBrowser: (file, args, settings) =>
149
+ startDetachedProcess(file, args, settings),
150
+ }
151
+ );
152
+ try {
153
+ const version = await probeSession(endpoint);
154
+ if (!version) {
155
+ throw new Error('Persistent browser endpoint disappeared after launch');
156
+ }
157
+ metadata = {
158
+ token: randomUUID(),
159
+ pid: session.browserProcess.pid,
160
+ userDataDir: path.resolve(options.userDataDir),
161
+ remoteDebuggingPort: options.remoteDebuggingPort,
162
+ webSocketDebuggerUrl: version.webSocketDebuggerUrl,
163
+ };
164
+ return { session, metadata, reused: false };
165
+ } catch (error) {
166
+ await session.close();
167
+ throw error;
168
+ }
169
+ }
170
+ async function remember(page, engine, file, metadata, idleTimeoutMs) {
171
+ const cdp =
172
+ engine === 'playwright'
173
+ ? await page.context().newCDPSession(page)
174
+ : await page.target().createCDPSession();
175
+ try {
176
+ metadata.targetId = (
177
+ await cdp.send('Target.getTargetInfo')
178
+ ).targetInfo.targetId;
179
+ } finally {
180
+ await cdp.detach();
181
+ }
182
+ metadata.lastActive = Date.now();
183
+ metadata.idleTimeoutMs = idleTimeoutMs;
184
+ await writeSessionMetadata(file, metadata);
185
+ }
186
+ export async function closeOwnedSession(file, metadata) {
187
+ if ((await read(file))?.token !== metadata.token) {
188
+ return;
189
+ }
190
+ const version = await probeSession(
191
+ `http://127.0.0.1:${metadata.remoteDebuggingPort}`
192
+ );
193
+ if (version?.webSocketDebuggerUrl === metadata.webSocketDebuggerUrl) {
194
+ await closeRemoteBrowser(version.webSocketDebuggerUrl);
195
+ }
196
+ await fs.rm(file, { force: true });
197
+ }
198
+ async function lifecycle(
199
+ { session, metadata, reused, adopted },
200
+ options,
201
+ file,
202
+ endpoint
203
+ ) {
204
+ const { engine, idleTimeoutMs } = options;
205
+ let page = session.page,
206
+ disconnected = false,
207
+ closed = false,
208
+ activityQueue = Promise.resolve();
209
+ const touch = () => {
210
+ activityQueue = activityQueue
211
+ .then(async () => {
212
+ const release = await acquireLease(`${file}.lock`);
213
+ try {
214
+ const current = await read(file);
215
+ if (current?.token === metadata.token) {
216
+ current.lastActive = Date.now();
217
+ await writeSessionMetadata(file, current);
218
+ }
219
+ } finally {
220
+ await release();
221
+ }
222
+ })
223
+ .catch(() => {});
224
+ };
225
+ const bind = (value) => {
226
+ activity.set(value, touch);
227
+ value.on?.('framenavigated', touch);
228
+ };
229
+ const unbind = (value) => {
230
+ activity.delete(value);
231
+ value.off?.('framenavigated', touch);
232
+ };
233
+ await remember(page, engine, file, metadata, idleTimeoutMs);
234
+ if (!reused || adopted) {
235
+ await startDetachedProcess(process.execPath, [
236
+ fileURLToPath(new URL('./session-watchdog.js', import.meta.url)),
237
+ file,
238
+ metadata.token,
239
+ ]);
240
+ }
241
+ bind(page);
242
+ const heartbeat = setInterval(
243
+ touch,
244
+ Math.max(20, Math.min(30_000, idleTimeoutMs / 3))
245
+ );
246
+ heartbeat.unref?.();
247
+ const tabSource = engine === 'playwright' ? page.context() : session.browser;
248
+ const tabTasks = new Set();
249
+ let selecting = false;
250
+ const closeNewTab = (value) => {
251
+ const task = Promise.resolve()
252
+ .then(async () => {
253
+ const candidate =
254
+ engine === 'playwright' ? value : await value.page?.();
255
+ if (candidate && candidate !== page && !disconnected && !selecting) {
256
+ await candidate.close();
257
+ }
258
+ })
259
+ .catch((error) =>
260
+ options.log?.debug?.(() => `session tab close: ${error.message}`)
261
+ );
262
+ tabTasks.add(task);
263
+ void task.finally(() => tabTasks.delete(task));
264
+ };
265
+ const tabEvent = engine === 'playwright' ? 'page' : 'targetcreated';
266
+ if (options.closeNewTabs) {
267
+ tabSource.on?.(tabEvent, closeNewTab);
268
+ }
269
+ const detach = async () => {
270
+ if (disconnected) {
271
+ return;
272
+ }
273
+ clearInterval(heartbeat);
274
+ tabSource.off?.(tabEvent, closeNewTab);
275
+ await Promise.allSettled([...tabTasks]);
276
+ touch();
277
+ await activityQueue;
278
+ unbind(page);
279
+ disconnected = true;
280
+ try {
281
+ await session.downloads?.dispose();
282
+ } finally {
283
+ await session.detach();
284
+ }
285
+ };
286
+ const close = async () => {
287
+ if (closed) {
288
+ return;
289
+ }
290
+ if (!disconnected) {
291
+ await detach().catch(() => {});
292
+ }
293
+ const release = await acquireLease(`${file}.lock`);
294
+ try {
295
+ await closeOwnedSession(file, metadata);
296
+ } finally {
297
+ await release();
298
+ }
299
+ closed = true;
300
+ };
301
+ const result = {
302
+ ...session,
303
+ detach,
304
+ close,
305
+ touch,
306
+ reused,
307
+ adopted: Boolean(adopted),
308
+ cdpEndpoint: endpoint,
309
+ userDataDir: path.resolve(options.userDataDir),
310
+ remoteDebuggingPort: options.remoteDebuggingPort,
311
+ temporaryProfile: false,
312
+ };
313
+ result.reusePage = async (selection = {}) => {
314
+ selecting = true;
315
+ try {
316
+ if (disconnected) {
317
+ throw new Error('Persistent controller is detached');
318
+ }
319
+ const release = await acquireLease(`${file}.lock`);
320
+ try {
321
+ const current = await read(file);
322
+ if (current?.token !== metadata.token) {
323
+ throw new Error('Persistent session ownership changed');
324
+ }
325
+ const pages =
326
+ engine === 'playwright'
327
+ ? page.context().pages()
328
+ : await session.browser.pages();
329
+ const selected =
330
+ (await pickForegroundPage(pages, {
331
+ targetId:
332
+ selection.targetId ??
333
+ (!selection.url && !selection.urlMatchers?.length
334
+ ? metadata.targetId
335
+ : undefined),
336
+ fallback: !selection.targetId,
337
+ ...selection,
338
+ })) ??
339
+ (engine === 'playwright'
340
+ ? await page.context().newPage()
341
+ : await session.browser.newPage());
342
+ unbind(page);
343
+ page = selected;
344
+ bind(page);
345
+ result.page = page;
346
+ await remember(page, engine, file, metadata, idleTimeoutMs);
347
+ } finally {
348
+ await release();
349
+ }
350
+ return page;
351
+ } finally {
352
+ selecting = false;
353
+ if (options.closeNewTabs && !disconnected) {
354
+ const pages =
355
+ engine === 'playwright'
356
+ ? page.context().pages()
357
+ : await session.browser.pages();
358
+ await Promise.allSettled(
359
+ pages
360
+ .filter((candidate) => candidate !== page)
361
+ .map((candidate) => candidate.close())
362
+ );
363
+ }
364
+ }
365
+ };
366
+ return result;
367
+ }
368
+ export async function closeRemoteBrowser(endpoint) {
369
+ await new Promise((resolve, reject) => {
370
+ const socket = new globalThis.WebSocket(endpoint);
371
+ let acknowledged = false;
372
+ const timer = setTimeout(() => {
373
+ socket.close();
374
+ reject(new Error('Browser.close timed out'));
375
+ }, 3000);
376
+ socket.onopen = () =>
377
+ socket.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
378
+ socket.onclose = () => {
379
+ clearTimeout(timer);
380
+ resolve();
381
+ };
382
+ socket.onerror = () => {
383
+ // Chrome can close the TCP connection without a WebSocket close frame
384
+ // after acknowledging Browser.close. Its ensuing close event completes
385
+ // shutdown; a pre-acknowledgement connection error remains a failure.
386
+ if (!acknowledged) {
387
+ clearTimeout(timer);
388
+ reject(new Error('Browser.close connection failed'));
389
+ }
390
+ };
391
+ socket.onmessage = ({ data }) => {
392
+ const response = JSON.parse(data);
393
+ if (response.id !== 1) {
394
+ return;
395
+ }
396
+ if (response.error) {
397
+ clearTimeout(timer);
398
+ reject(new Error(response.error.message ?? 'Browser.close failed'));
399
+ socket.close();
400
+ } else {
401
+ acknowledged = true;
402
+ }
403
+ };
404
+ });
405
+ }
406
+ /** Reuse an owned profile/port; detach survives the controller; close terminates it. */
407
+ export async function connectOrLaunch(settings = {}) {
408
+ const options = {
409
+ engine: 'playwright',
410
+ idleTimeoutMs: 30 * 60 * 1000,
411
+ ...settings,
412
+ };
413
+ validate(options);
414
+ await fs.mkdir(options.userDataDir, { recursive: true, mode: 0o700 });
415
+ const file = path.join(path.resolve(options.userDataDir), SESSION_METADATA);
416
+ const release = await acquireLease(`${file}.lock`);
417
+ const endpoint = `http://127.0.0.1:${options.remoteDebuggingPort}`;
418
+ let owned;
419
+ try {
420
+ owned = await open(options, file, endpoint);
421
+ return await lifecycle(owned, options, file, endpoint);
422
+ } catch (error) {
423
+ if (owned) {
424
+ await (
425
+ owned.reused ? owned.session.detach() : owned.session.close()
426
+ ).catch(() => {});
427
+ }
428
+ throw error;
429
+ } finally {
430
+ await release();
431
+ }
432
+ }
@@ -106,6 +106,26 @@ export async function configureUserDataDir(
106
106
  ) {
107
107
  assertObject(preferences, 'preferences');
108
108
  assertObject(localState, 'localState');
109
+ for (const key of [
110
+ 'session.restore_on_startup',
111
+ 'session.startup_urls',
112
+ 'homepage',
113
+ 'homepage_is_newtabpage',
114
+ 'browser.show_home_button',
115
+ 'extensions.settings',
116
+ 'default_search_provider_data',
117
+ ]) {
118
+ let value = preferences;
119
+ for (const part of key.split('.')) {
120
+ value = value?.[part];
121
+ }
122
+ if (value !== undefined || Object.hasOwn(preferences, key)) {
123
+ process.emitWarning(
124
+ `Chrome protects ${key} in Secure Preferences and may reset this value. Use browser settings or managed policy to set it.`,
125
+ { code: 'PROTECTED_CHROME_PREFERENCE' }
126
+ );
127
+ }
128
+ }
109
129
  if (
110
130
  defaultBrowserCheck !== undefined &&
111
131
  typeof defaultBrowserCheck !== 'boolean'
@@ -103,6 +103,7 @@ export function buildRealBrowserArgs({
103
103
  remoteDebuggingPort,
104
104
  headless = false,
105
105
  restrictions = [],
106
+ disableFeatures = [],
106
107
  args = [],
107
108
  extraArgs = [],
108
109
  automationParity = true,
@@ -127,7 +128,7 @@ export function buildRealBrowserArgs({
127
128
  `--user-data-dir=${userDataDir}`,
128
129
  `--remote-debugging-port=${remoteDebuggingPort}`,
129
130
  ...(headless ? ['--headless=new'] : []),
130
- ...resolveRestrictions(restrictions).args,
131
+ ...resolveRestrictions(restrictions, { disableFeatures }).args,
131
132
  ...customArgs,
132
133
  ]);
133
134
  if (
@@ -642,6 +643,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
642
643
  const argOptions = {
643
644
  headless,
644
645
  restrictions,
646
+ disableFeatures: options.disableFeatures,
645
647
  args,
646
648
  extraArgs,
647
649
  automationParity,
@@ -700,7 +702,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
700
702
  await removingTemporaryProfileOnError(profile, () =>
701
703
  configureUserDataDir(userDataDir, {
702
704
  defaultBrowserCheck,
703
- preferences,
705
+ preferences: {
706
+ ...resolveRestrictions(restrictions).preferences,
707
+ ...preferences,
708
+ },
704
709
  localState,
705
710
  profileDirectory: attach?.profile ?? 'Default',
706
711
  })
@@ -73,11 +73,14 @@ function expand(names) {
73
73
  * @param {string[]} [names]
74
74
  * @returns {{ids: string[], args: string[], env: Object<string,string>}}
75
75
  */
76
- export function resolveRestrictions(names = []) {
76
+ export function resolveRestrictions(names = [], options = {}) {
77
77
  const ids = expand(names);
78
78
  const args = [];
79
- const disableFeatures = [];
79
+ const disableFeatures = [
80
+ ...assertStringArray(options.disableFeatures ?? [], 'disableFeatures'),
81
+ ];
80
82
  const env = {};
83
+ const preferences = {};
81
84
  for (const id of ids) {
82
85
  const restriction = LAUNCH_RESTRICTIONS.find((entry) => entry.id === id);
83
86
  if (!restriction) {
@@ -91,11 +94,17 @@ export function resolveRestrictions(names = []) {
91
94
  args.push(...(restriction.args ?? []));
92
95
  disableFeatures.push(...(restriction.disableFeatures ?? []));
93
96
  Object.assign(env, restriction.env ?? {});
97
+ for (const [key, value] of Object.entries(restriction.preferences ?? {})) {
98
+ preferences[key] =
99
+ value && typeof value === 'object'
100
+ ? { ...preferences[key], ...value }
101
+ : value;
102
+ }
94
103
  }
95
104
  if (disableFeatures.length > 0) {
96
- args.push(`--disable-features=${disableFeatures.join(',')}`);
105
+ args.push(`--disable-features=${[...new Set(disableFeatures)].join(',')}`);
97
106
  }
98
- return { ids, args, env };
107
+ return { ids, args, env, preferences };
99
108
  }
100
109
 
101
110
  const LIST_SWITCHES = [