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
@@ -0,0 +1,360 @@
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 { startDetachedProcess } from '../utilities/subprocess.js';
9
+
10
+ export const SESSION_METADATA = '.browser-commander-session.json';
11
+ const activity = new WeakMap();
12
+ export function touchSessionPage(page) {
13
+ activity.get(page)?.();
14
+ }
15
+ const read = (file) =>
16
+ fs
17
+ .readFile(file, 'utf8')
18
+ .then(JSON.parse)
19
+ .catch(() => null);
20
+ export async function writeSessionMetadata(file, value) {
21
+ const temporary = `${file}.${randomUUID()}.tmp`;
22
+ try {
23
+ await fs.writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
24
+ await fs.rename(temporary, file);
25
+ } finally {
26
+ await fs.rm(temporary, { force: true });
27
+ }
28
+ }
29
+ export function probeSession(endpoint) {
30
+ return fetch(`${endpoint}/json/version`, {
31
+ signal: AbortSignal.timeout(1000),
32
+ })
33
+ .then((r) => (r.ok ? r.json() : null))
34
+ .catch(() => null);
35
+ }
36
+ export async function acquireLease(file, retries = 50) {
37
+ let lease;
38
+ try {
39
+ lease = await fs.open(file, 'wx', 0o600);
40
+ } catch (error) {
41
+ if (error.code !== 'EEXIST') {
42
+ throw error;
43
+ }
44
+ const owner = await read(file);
45
+ if (retries > 0) {
46
+ let alive = !owner?.pid;
47
+ if (owner?.pid) {
48
+ try {
49
+ process.kill(owner.pid, 0);
50
+ alive = true;
51
+ } catch (error) {
52
+ alive = error.code !== 'ESRCH';
53
+ }
54
+ }
55
+ if (alive) {
56
+ await new Promise((resolve) => setTimeout(resolve, 20));
57
+ return acquireLease(file, retries - 1);
58
+ }
59
+ }
60
+ if (!owner?.pid) {
61
+ throw new Error('Persistent session launch is already in progress', {
62
+ cause: error,
63
+ });
64
+ }
65
+ let alive = true;
66
+ try {
67
+ process.kill(owner.pid, 0);
68
+ } catch (error) {
69
+ alive = error.code !== 'ESRCH';
70
+ }
71
+ if (alive) {
72
+ throw new Error('Persistent session launch is already in progress', {
73
+ cause: error,
74
+ });
75
+ }
76
+ await fs.rm(file);
77
+ lease = await fs.open(file, 'wx', 0o600);
78
+ }
79
+ const token = randomUUID();
80
+ await lease.writeFile(JSON.stringify({ pid: process.pid, token }));
81
+ return async () => {
82
+ await lease.close();
83
+ if ((await read(file))?.token === token) {
84
+ await fs.rm(file, { force: true });
85
+ }
86
+ };
87
+ }
88
+ function validate(options) {
89
+ if (
90
+ !options.userDataDir ||
91
+ !Number.isInteger(options.remoteDebuggingPort) ||
92
+ options.remoteDebuggingPort < 1 ||
93
+ options.remoteDebuggingPort > 65535
94
+ ) {
95
+ throw new TypeError(
96
+ 'connectOrLaunch requires a dedicated userDataDir and fixed remoteDebuggingPort'
97
+ );
98
+ }
99
+ if (!Number.isFinite(options.idleTimeoutMs) || options.idleTimeoutMs <= 0) {
100
+ throw new RangeError('idleTimeoutMs must be positive');
101
+ }
102
+ if (!['playwright', 'puppeteer'].includes(options.engine)) {
103
+ throw new TypeError('Persistent sessions require a Chromium CDP engine');
104
+ }
105
+ assertDedicatedUserDataDir(options.userDataDir);
106
+ }
107
+ async function open(options, file, endpoint) {
108
+ let metadata = await read(file);
109
+ const version = await probeSession(endpoint);
110
+ if (version) {
111
+ if (
112
+ !metadata ||
113
+ metadata.remoteDebuggingPort !== options.remoteDebuggingPort ||
114
+ metadata.webSocketDebuggerUrl !== version.webSocketDebuggerUrl
115
+ ) {
116
+ throw new Error('Debugging port belongs to another browser/profile');
117
+ }
118
+ const session = await connectBrowser({
119
+ ...options,
120
+ cdpEndpoint: endpoint,
121
+ targetId:
122
+ options.targetId ?? (!options.url ? metadata.targetId : undefined),
123
+ });
124
+ return { session, metadata, reused: true };
125
+ }
126
+ const session = await launchAndConnectRealBrowserWithDependencies(
127
+ { ...options, keepOpen: false },
128
+ {
129
+ spawnBrowser: (file, args, settings) =>
130
+ startDetachedProcess(file, args, settings),
131
+ }
132
+ );
133
+ try {
134
+ const version = await probeSession(endpoint);
135
+ if (!version) {
136
+ throw new Error('Persistent browser endpoint disappeared after launch');
137
+ }
138
+ metadata = {
139
+ token: randomUUID(),
140
+ pid: session.browserProcess.pid,
141
+ userDataDir: path.resolve(options.userDataDir),
142
+ remoteDebuggingPort: options.remoteDebuggingPort,
143
+ webSocketDebuggerUrl: version.webSocketDebuggerUrl,
144
+ };
145
+ return { session, metadata, reused: false };
146
+ } catch (error) {
147
+ await session.close();
148
+ throw error;
149
+ }
150
+ }
151
+ async function remember(page, engine, file, metadata, idleTimeoutMs) {
152
+ const cdp =
153
+ engine === 'playwright'
154
+ ? await page.context().newCDPSession(page)
155
+ : await page.target().createCDPSession();
156
+ try {
157
+ metadata.targetId = (
158
+ await cdp.send('Target.getTargetInfo')
159
+ ).targetInfo.targetId;
160
+ } finally {
161
+ await cdp.detach();
162
+ }
163
+ metadata.lastActive = Date.now();
164
+ metadata.idleTimeoutMs = idleTimeoutMs;
165
+ await writeSessionMetadata(file, metadata);
166
+ }
167
+ export async function closeOwnedSession(file, metadata) {
168
+ if ((await read(file))?.token !== metadata.token) {
169
+ return;
170
+ }
171
+ const version = await probeSession(
172
+ `http://127.0.0.1:${metadata.remoteDebuggingPort}`
173
+ );
174
+ if (version?.webSocketDebuggerUrl === metadata.webSocketDebuggerUrl) {
175
+ await closeRemoteBrowser(version.webSocketDebuggerUrl);
176
+ }
177
+ await fs.rm(file, { force: true });
178
+ }
179
+ async function lifecycle(
180
+ { session, metadata, reused },
181
+ options,
182
+ file,
183
+ endpoint
184
+ ) {
185
+ const { engine, idleTimeoutMs } = options;
186
+ let page = session.page,
187
+ disconnected = false,
188
+ closed = false,
189
+ activityQueue = Promise.resolve();
190
+ const touch = () => {
191
+ activityQueue = activityQueue
192
+ .then(async () => {
193
+ const release = await acquireLease(`${file}.lock`);
194
+ try {
195
+ const current = await read(file);
196
+ if (current?.token === metadata.token) {
197
+ current.lastActive = Date.now();
198
+ await writeSessionMetadata(file, current);
199
+ }
200
+ } finally {
201
+ await release();
202
+ }
203
+ })
204
+ .catch(() => {});
205
+ };
206
+ const bind = (value) => {
207
+ activity.set(value, touch);
208
+ value.on?.('framenavigated', touch);
209
+ };
210
+ const unbind = (value) => {
211
+ activity.delete(value);
212
+ value.off?.('framenavigated', touch);
213
+ };
214
+ await remember(page, engine, file, metadata, idleTimeoutMs);
215
+ bind(page);
216
+ const detach = async () => {
217
+ if (disconnected) {
218
+ return;
219
+ }
220
+ touch();
221
+ await activityQueue;
222
+ unbind(page);
223
+ disconnected = true;
224
+ try {
225
+ await session.downloads?.dispose();
226
+ } finally {
227
+ await session.detach();
228
+ }
229
+ };
230
+ const close = async () => {
231
+ if (closed) {
232
+ return;
233
+ }
234
+ if (!disconnected) {
235
+ await detach().catch(() => {});
236
+ }
237
+ const release = await acquireLease(`${file}.lock`);
238
+ try {
239
+ await closeOwnedSession(file, metadata);
240
+ } finally {
241
+ await release();
242
+ }
243
+ closed = true;
244
+ };
245
+ const result = {
246
+ ...session,
247
+ detach,
248
+ close,
249
+ touch,
250
+ reused,
251
+ cdpEndpoint: endpoint,
252
+ userDataDir: path.resolve(options.userDataDir),
253
+ remoteDebuggingPort: options.remoteDebuggingPort,
254
+ temporaryProfile: false,
255
+ };
256
+ result.reusePage = async (selection = {}) => {
257
+ if (disconnected) {
258
+ throw new Error('Persistent controller is detached');
259
+ }
260
+ const release = await acquireLease(`${file}.lock`);
261
+ try {
262
+ const current = await read(file);
263
+ if (current?.token !== metadata.token) {
264
+ throw new Error('Persistent session ownership changed');
265
+ }
266
+ const pages =
267
+ engine === 'playwright'
268
+ ? page.context().pages()
269
+ : await session.browser.pages();
270
+ const selected = await pickForegroundPage(pages, {
271
+ targetId:
272
+ selection.targetId ??
273
+ (!selection.url ? metadata.targetId : undefined),
274
+ ...selection,
275
+ });
276
+ if (!selected) {
277
+ throw new Error('No reusable browser page');
278
+ }
279
+ unbind(page);
280
+ page = selected;
281
+ bind(page);
282
+ result.page = page;
283
+ await remember(page, engine, file, metadata, idleTimeoutMs);
284
+ } finally {
285
+ await release();
286
+ }
287
+ return page;
288
+ };
289
+ if (!reused) {
290
+ await startDetachedProcess(process.execPath, [
291
+ fileURLToPath(new URL('./session-watchdog.js', import.meta.url)),
292
+ file,
293
+ metadata.token,
294
+ ]);
295
+ }
296
+ return result;
297
+ }
298
+ export async function closeRemoteBrowser(endpoint) {
299
+ await new Promise((resolve, reject) => {
300
+ const socket = new globalThis.WebSocket(endpoint);
301
+ let acknowledged = false;
302
+ const timer = setTimeout(() => {
303
+ socket.close();
304
+ reject(new Error('Browser.close timed out'));
305
+ }, 3000);
306
+ socket.onopen = () =>
307
+ socket.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
308
+ socket.onclose = () => {
309
+ clearTimeout(timer);
310
+ resolve();
311
+ };
312
+ socket.onerror = () => {
313
+ // Chrome can close the TCP connection without a WebSocket close frame
314
+ // after acknowledging Browser.close. Its ensuing close event completes
315
+ // shutdown; a pre-acknowledgement connection error remains a failure.
316
+ if (!acknowledged) {
317
+ clearTimeout(timer);
318
+ reject(new Error('Browser.close connection failed'));
319
+ }
320
+ };
321
+ socket.onmessage = ({ data }) => {
322
+ const response = JSON.parse(data);
323
+ if (response.id !== 1) {
324
+ return;
325
+ }
326
+ if (response.error) {
327
+ clearTimeout(timer);
328
+ reject(new Error(response.error.message ?? 'Browser.close failed'));
329
+ socket.close();
330
+ } else {
331
+ acknowledged = true;
332
+ }
333
+ };
334
+ });
335
+ }
336
+ /** Reuse an owned profile/port; detach survives the controller; close terminates it. */
337
+ export async function connectOrLaunch(settings = {}) {
338
+ const options = {
339
+ engine: 'playwright',
340
+ idleTimeoutMs: 30 * 60 * 1000,
341
+ ...settings,
342
+ };
343
+ validate(options);
344
+ await fs.mkdir(options.userDataDir, { recursive: true, mode: 0o700 });
345
+ const file = path.join(path.resolve(options.userDataDir), SESSION_METADATA);
346
+ const release = await acquireLease(`${file}.lock`);
347
+ const endpoint = `http://127.0.0.1:${options.remoteDebuggingPort}`;
348
+ let owned;
349
+ try {
350
+ owned = await open(options, file, endpoint);
351
+ return await lifecycle(owned, options, file, endpoint);
352
+ } catch (error) {
353
+ if (owned && !owned.reused) {
354
+ await owned.session.close().catch(() => {});
355
+ }
356
+ throw error;
357
+ } finally {
358
+ await release();
359
+ }
360
+ }
@@ -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 = [
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs/promises';
2
+ import {
3
+ probeSession,
4
+ closeRemoteBrowser,
5
+ acquireLease,
6
+ } from './persistent-session.js';
7
+ const [, , file, token] = process.argv;
8
+ const read = () =>
9
+ fs
10
+ .readFile(file, 'utf8')
11
+ .then(JSON.parse)
12
+ .catch(() => null);
13
+ while (true) {
14
+ const session = await read();
15
+ if (session?.token !== token) {
16
+ break;
17
+ }
18
+ await new Promise((resolve) =>
19
+ setTimeout(
20
+ resolve,
21
+ Math.min(30_000, Math.max(100, session.idleTimeoutMs / 10))
22
+ )
23
+ );
24
+ const current = await read();
25
+ if (current?.token !== token) {
26
+ break;
27
+ }
28
+ if (Date.now() - current.lastActive < current.idleTimeoutMs) {
29
+ continue;
30
+ }
31
+ let release;
32
+ try {
33
+ release = await acquireLease(`${file}.lock`);
34
+ } catch {
35
+ continue;
36
+ }
37
+ try {
38
+ const version = await probeSession(
39
+ `http://127.0.0.1:${current.remoteDebuggingPort}`
40
+ );
41
+ const latest = await read();
42
+ if (latest?.token !== token) {
43
+ break;
44
+ }
45
+ if (Date.now() - latest.lastActive < latest.idleTimeoutMs) {
46
+ continue;
47
+ }
48
+ if (!version) {
49
+ try {
50
+ process.kill(current.pid, 0);
51
+ continue;
52
+ } catch (error) {
53
+ if (error.code !== 'ESRCH') {
54
+ continue;
55
+ }
56
+ }
57
+ } else if (version.webSocketDebuggerUrl === current.webSocketDebuggerUrl) {
58
+ try {
59
+ await closeRemoteBrowser(version.webSocketDebuggerUrl);
60
+ } catch {
61
+ continue;
62
+ }
63
+ }
64
+ if ((await read())?.token === token) {
65
+ await fs.rm(file, { force: true });
66
+ }
67
+ break;
68
+ } finally {
69
+ await release();
70
+ }
71
+ }
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ connectOrLaunch,
5
+ SESSION_METADATA,
6
+ closeOwnedSession,
7
+ writeSessionMetadata,
8
+ acquireLease,
9
+ } from './persistent-session.js';
10
+ let input = '';
11
+ for await (const chunk of process.stdin) {
12
+ input += chunk;
13
+ if (input.length > 1024 * 1024) {
14
+ throw new RangeError('session options exceed 1 MiB');
15
+ }
16
+ }
17
+ const options = JSON.parse(input);
18
+ const file = path.join(path.resolve(options.userDataDir), SESSION_METADATA);
19
+ if (options.operation) {
20
+ const release = await acquireLease(`${file}.lock`);
21
+ try {
22
+ let metadata;
23
+ try {
24
+ metadata = JSON.parse(await fs.readFile(file, 'utf8'));
25
+ } catch (error) {
26
+ if (
27
+ error.code === 'ENOENT' &&
28
+ ['close', 'touch'].includes(options.operation)
29
+ ) {
30
+ process.stdout.write('{}');
31
+ process.exitCode = 0;
32
+ } else {
33
+ throw error;
34
+ }
35
+ }
36
+ if (metadata) {
37
+ if (metadata.token !== options.token) {
38
+ throw new Error('Persistent session ownership changed');
39
+ }
40
+ if (options.operation === 'close') {
41
+ await closeOwnedSession(file, metadata);
42
+ } else if (options.operation === 'touch') {
43
+ metadata.lastActive = Date.now();
44
+ if (options.targetId) {
45
+ metadata.targetId = options.targetId;
46
+ }
47
+ await writeSessionMetadata(file, metadata);
48
+ } else {
49
+ throw new TypeError('Unknown session operation');
50
+ }
51
+ process.stdout.write('{}');
52
+ }
53
+ } finally {
54
+ await release();
55
+ }
56
+ } else {
57
+ const session = await connectOrLaunch(options);
58
+ await session.detach();
59
+ const metadata = JSON.parse(await fs.readFile(file, 'utf8'));
60
+ process.stdout.write(
61
+ JSON.stringify({
62
+ cdpEndpoint: session.cdpEndpoint,
63
+ userDataDir: session.userDataDir,
64
+ remoteDebuggingPort: session.remoteDebuggingPort,
65
+ reused: session.reused,
66
+ token: metadata.token,
67
+ targetId: metadata.targetId,
68
+ })
69
+ );
70
+ }
@@ -326,6 +326,7 @@ export function buildBrowserOptions({
326
326
  remoteDebuggingPort,
327
327
  headless,
328
328
  restrictions,
329
+ disableFeatures,
329
330
  args,
330
331
  automationParity,
331
332
  bidi,
@@ -358,6 +359,7 @@ export function buildBrowserOptions({
358
359
  remoteDebuggingPort,
359
360
  headless,
360
361
  restrictions,
362
+ disableFeatures,
361
363
  args,
362
364
  automationParity,
363
365
  });
@@ -556,6 +558,7 @@ async function launchWebDriverOnce(options, dependencies) {
556
558
  bidi = false,
557
559
  args = [],
558
560
  restrictions = [],
561
+ disableFeatures = [],
559
562
  automationParity = true,
560
563
  env,
561
564
  startupTimeout = 30_000,
@@ -627,6 +630,7 @@ async function launchWebDriverOnce(options, dependencies) {
627
630
  browser === 'chrome' ? await reservePort() : undefined,
628
631
  headless,
629
632
  restrictions,
633
+ disableFeatures,
630
634
  args,
631
635
  automationParity,
632
636
  bidi,