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
@@ -1,3 +1,8 @@
1
+ import { launchFailure, redactLaunchEvidence } from './launch-diagnostics.js';
2
+ import {
3
+ installSessionPersistence,
4
+ sessionPersistencePath,
5
+ } from './session-persistence.js';
1
6
  import { readFile } from 'node:fs/promises';
2
7
  import path from 'node:path';
3
8
  import { findBrowserSource } from './browser-sources.js';
@@ -98,6 +103,7 @@ export function buildRealBrowserArgs({
98
103
  remoteDebuggingPort,
99
104
  headless = false,
100
105
  restrictions = [],
106
+ disableFeatures = [],
101
107
  args = [],
102
108
  extraArgs = [],
103
109
  automationParity = true,
@@ -122,7 +128,7 @@ export function buildRealBrowserArgs({
122
128
  `--user-data-dir=${userDataDir}`,
123
129
  `--remote-debugging-port=${remoteDebuggingPort}`,
124
130
  ...(headless ? ['--headless=new'] : []),
125
- ...resolveRestrictions(restrictions).args,
131
+ ...resolveRestrictions(restrictions, { disableFeatures }).args,
126
132
  ...customArgs,
127
133
  ]);
128
134
  if (
@@ -187,56 +193,66 @@ export async function waitForCdpEndpoint({
187
193
  timeout = 30_000,
188
194
  fetchImplementation = globalThis.fetch,
189
195
  }) {
190
- const port = assertFixedDebuggingPort(remoteDebuggingPort);
191
- const endpoint = `http://${LOOPBACK_HOST}:${port}`;
192
- const deadline = Date.now() + timeout;
193
- while (Date.now() < deadline) {
194
- const output = devToolsOutput.state();
195
- let ownership = devToolsOutput.available
196
- ? classifyDevToolsOwnership(output, port)
197
- : 'unknown';
198
- if (ownership === 'race') {
199
- throw new PortRaceError(port, output.listening?.url ?? 'bind failed');
200
- }
201
- if (browserProcess.exitCode !== null) {
202
- throw new Error(
203
- `Browser exited before its DevTools endpoint was ready (exit ${browserProcess.exitCode})`
204
- );
205
- }
206
- if (ownership !== 'owned') {
207
- const activePort = await readDevToolsActivePort(userDataDir);
208
- if (activePort?.port === port) {
209
- ownership = 'owned';
196
+ try {
197
+ const port = assertFixedDebuggingPort(remoteDebuggingPort);
198
+ const endpoint = `http://${LOOPBACK_HOST}:${port}`;
199
+ const deadline = Date.now() + timeout;
200
+ while (Date.now() < deadline) {
201
+ const output = devToolsOutput.state();
202
+ let ownership = devToolsOutput.available
203
+ ? classifyDevToolsOwnership(output, port)
204
+ : 'unknown';
205
+ if (ownership === 'race') {
206
+ throw new PortRaceError(port, output.listening?.url ?? 'bind failed');
210
207
  }
211
- }
212
-
213
- if (ownership === 'owned') {
214
- let version = null;
215
- try {
216
- version = await fetchCdpVersion(
217
- endpoint,
218
- fetchImplementation,
219
- Math.max(1, deadline - Date.now())
208
+ if (browserProcess.exitCode !== null) {
209
+ throw (
210
+ browserProcess.spawnError ??
211
+ Object.assign(
212
+ new Error(
213
+ `Browser exited before its DevTools endpoint was ready (exit ${browserProcess.exitCode})`
214
+ ),
215
+ { stderrTail: browserProcess.stderrTail }
216
+ )
220
217
  );
221
- } catch {
222
- // The HTTP handler can lag the listening line by a moment.
223
218
  }
224
- if (version) {
225
- const expected = output.listening?.url;
226
- if (expected && version.webSocketDebuggerUrl !== expected) {
227
- throw new PortRaceError(
228
- port,
229
- `port serves ${version.webSocketDebuggerUrl}, browser announced ${expected}`
219
+ if (ownership !== 'owned') {
220
+ const activePort = await readDevToolsActivePort(userDataDir);
221
+ if (activePort?.port === port) {
222
+ ownership = 'owned';
223
+ }
224
+ }
225
+
226
+ if (ownership === 'owned') {
227
+ let version = null;
228
+ try {
229
+ version = await fetchCdpVersion(
230
+ endpoint,
231
+ fetchImplementation,
232
+ Math.max(1, deadline - Date.now())
230
233
  );
234
+ } catch {
235
+ // The HTTP handler can lag the listening line by a moment.
236
+ }
237
+ if (version) {
238
+ const expected = output.listening?.url;
239
+ if (expected && version.webSocketDebuggerUrl !== expected) {
240
+ throw new PortRaceError(
241
+ port,
242
+ `port serves ${version.webSocketDebuggerUrl}, browser announced ${expected}`
243
+ );
244
+ }
245
+ return endpoint;
231
246
  }
232
- return endpoint;
233
247
  }
248
+ await new Promise((resolve) => setTimeout(resolve, 100));
234
249
  }
235
- await new Promise((resolve) => setTimeout(resolve, 100));
250
+ throw new Error(
251
+ `Timed out after ${timeout}ms waiting for the DevTools endpoint on port ${port}`
252
+ );
253
+ } finally {
254
+ devToolsOutput.dispose?.();
236
255
  }
237
- throw new Error(
238
- `Timed out after ${timeout}ms waiting for the DevTools endpoint on port ${port}`
239
- );
240
256
  }
241
257
 
242
258
  function spawnWithStartProcess(executablePath, args, { env, verbose }) {
@@ -251,13 +267,19 @@ async function waitForExit(browserProcess, timeout) {
251
267
  return false;
252
268
  }
253
269
  let timer;
270
+ const onExit = () => resolveExit(true);
271
+ let resolveExit;
254
272
  const exited = await Promise.race([
255
- new Promise((resolve) => browserProcess.once('exit', () => resolve(true))),
273
+ new Promise((resolve) => {
274
+ resolveExit = resolve;
275
+ browserProcess.once('exit', onExit);
276
+ }),
256
277
  new Promise((resolve) => {
257
278
  timer = setTimeout(() => resolve(false), timeout);
258
279
  }),
259
280
  ]);
260
281
  clearTimeout(timer);
282
+ browserProcess.removeListener?.('exit', onExit);
261
283
  return exited;
262
284
  }
263
285
 
@@ -369,6 +391,7 @@ async function spawnOnFreePort({
369
391
  userDataDir,
370
392
  startupTimeout,
371
393
  dependencies,
394
+ diagnostics,
372
395
  }) {
373
396
  const reservePort = dependencies.reservePort ?? reserveLoopbackPort;
374
397
  const spawnBrowser = dependencies.spawnBrowser ?? spawnWithStartProcess;
@@ -381,10 +404,20 @@ async function spawnOnFreePort({
381
404
  userDataDir,
382
405
  remoteDebuggingPort: port,
383
406
  });
384
- const browserProcess = spawnBrowser(executablePath, browserArgs, {
385
- env,
386
- verbose,
387
- });
407
+ let browserProcess;
408
+ try {
409
+ browserProcess = await spawnBrowser(executablePath, browserArgs, {
410
+ env,
411
+ verbose: false,
412
+ });
413
+ } catch (error) {
414
+ throw launchFailure(error, {
415
+ ...diagnostics,
416
+ phase: 'spawn',
417
+ executablePath,
418
+ userDataDir,
419
+ });
420
+ }
388
421
  try {
389
422
  const cdpEndpoint = await waitForEndpoint({
390
423
  remoteDebuggingPort: port,
@@ -397,13 +430,24 @@ async function spawnOnFreePort({
397
430
  if (browserProcess.exitCode === null) {
398
431
  browserProcess.kill();
399
432
  }
433
+ await waitForExit(browserProcess, 5_000);
400
434
  if (!(error instanceof PortRaceError) || attempt >= attempts) {
401
- throw error;
435
+ throw launchFailure(error, {
436
+ ...diagnostics,
437
+ phase: 'endpoint',
438
+ browserProcess,
439
+ executablePath,
440
+ userDataDir,
441
+ });
402
442
  }
403
443
  if (verbose) {
404
- console.log(`${error.message}; retrying with a new port`);
444
+ console.log(
445
+ redactLaunchEvidence(
446
+ `${error.message}; retrying with a new port`,
447
+ diagnostics?.diagnosticRedactor
448
+ )
449
+ );
405
450
  }
406
- await waitForExit(browserProcess, 5_000);
407
451
  }
408
452
  }
409
453
  }
@@ -565,6 +609,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
565
609
  options = {},
566
610
  dependencies = {}
567
611
  ) {
612
+ sessionPersistencePath(options);
568
613
  if (isSafariChannel(options.channel ?? options.browser)) {
569
614
  return launchSafari(options, dependencies);
570
615
  }
@@ -598,6 +643,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
598
643
  const argOptions = {
599
644
  headless,
600
645
  restrictions,
646
+ disableFeatures: options.disableFeatures,
601
647
  args,
602
648
  extraArgs,
603
649
  automationParity,
@@ -614,10 +660,22 @@ export async function launchAndConnectRealBrowserWithDependencies(
614
660
 
615
661
  const resolveExecutable =
616
662
  dependencies.resolveExecutable ?? resolveSystemBrowserExecutable;
617
- const executablePath = await resolveExecutable({
618
- channel,
619
- executablePath: requestedExecutablePath,
620
- });
663
+ const launchChannel = attach?.browser ?? channel;
664
+ const executablePath = await Promise.resolve()
665
+ .then(() =>
666
+ resolveExecutable({
667
+ channel: launchChannel,
668
+ executablePath: requestedExecutablePath,
669
+ })
670
+ )
671
+ .catch((error) => {
672
+ throw launchFailure(error, {
673
+ ...options,
674
+ phase: 'discovery',
675
+ engine,
676
+ executablePath: requestedExecutablePath,
677
+ });
678
+ });
621
679
 
622
680
  const temporaryProfile = !requestedUserDataDir;
623
681
  const { userDataDir, snapshotLaunch } = await prepareProfile({
@@ -644,7 +702,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
644
702
  await removingTemporaryProfileOnError(profile, () =>
645
703
  configureUserDataDir(userDataDir, {
646
704
  defaultBrowserCheck,
647
- preferences,
705
+ preferences: {
706
+ ...resolveRestrictions(restrictions).preferences,
707
+ ...preferences,
708
+ },
648
709
  localState,
649
710
  profileDirectory: attach?.profile ?? 'Default',
650
711
  })
@@ -661,6 +722,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
661
722
  userDataDir,
662
723
  startupTimeout,
663
724
  dependencies,
725
+ diagnostics: { engine, diagnosticRedactor: options.diagnosticRedactor },
664
726
  })
665
727
  );
666
728
  const { browserProcess, cdpEndpoint: resolvedCdpEndpoint } = launched;
@@ -694,19 +756,22 @@ export async function launchAndConnectRealBrowserWithDependencies(
694
756
  closeTimeout,
695
757
  closeConnection: engine === 'selenium' ? connection.close : undefined,
696
758
  });
697
- return {
698
- ...connection,
699
- close,
700
- browserProcess,
701
- cdpEndpoint: resolvedCdpEndpoint,
702
- remoteDebuggingPort: launched.port,
703
- executablePath,
704
- userDataDir,
705
- temporaryProfile,
706
- args: launched.browserArgs,
707
- ...(migration ? { migration } : {}),
708
- ...(snapshotLaunch ? { attach: snapshotLaunch.attach } : {}),
709
- };
759
+ return await installSessionPersistence(
760
+ {
761
+ ...connection,
762
+ close,
763
+ browserProcess,
764
+ cdpEndpoint: resolvedCdpEndpoint,
765
+ remoteDebuggingPort: launched.port,
766
+ executablePath,
767
+ userDataDir,
768
+ temporaryProfile,
769
+ args: launched.browserArgs,
770
+ ...(migration ? { migration } : {}),
771
+ ...(snapshotLaunch ? { attach: snapshotLaunch.attach } : {}),
772
+ },
773
+ options
774
+ );
710
775
  } catch (error) {
711
776
  if (browserProcess.exitCode === null) {
712
777
  browserProcess.kill();
@@ -715,7 +780,14 @@ export async function launchAndConnectRealBrowserWithDependencies(
715
780
  if (temporaryProfile) {
716
781
  await removeUserDataDir(userDataDir);
717
782
  }
718
- throw error;
783
+ throw launchFailure(error, {
784
+ ...options,
785
+ phase: 'connect',
786
+ engine,
787
+ browserProcess,
788
+ executablePath,
789
+ userDataDir,
790
+ });
719
791
  }
720
792
  }
721
793
 
@@ -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,78 @@
1
+ import { matchesDomains } from './migration/domains.js';
2
+ import { restoreWebDriverStorageState } from './storage-state.js';
3
+
4
+ /** Normalize cookie shape without turning session cookies into expired cookies. */
5
+ export function normalizeSessionCookies(cookies, engine) {
6
+ if (!Array.isArray(cookies)) {
7
+ throw new TypeError('cookies must be an array');
8
+ }
9
+ return cookies.map(({ expires, ...cookie }) => ({
10
+ ...cookie,
11
+ ...(cookie.sameSite
12
+ ? {
13
+ sameSite:
14
+ cookie.sameSite[0].toUpperCase() +
15
+ cookie.sameSite.slice(1).toLowerCase(),
16
+ }
17
+ : {}),
18
+ ...(Number.isFinite(expires) && expires > 0
19
+ ? { expires }
20
+ : engine === 'playwright' && expires !== undefined
21
+ ? { expires: -1 }
22
+ : {}),
23
+ }));
24
+ }
25
+
26
+ /** Add cookies to the current context, including cookies for other origins. */
27
+ export async function setCookies({ page, engine, cookies } = {}) {
28
+ const normalized = normalizeSessionCookies(cookies, engine);
29
+ if (engine === 'playwright') {
30
+ return page.context().addCookies(normalized);
31
+ }
32
+ if (engine === 'selenium' && !(await page.bidi?.())) {
33
+ return restoreWebDriverStorageState({
34
+ page,
35
+ storageState: { cookies: normalized, origins: [] },
36
+ });
37
+ }
38
+ const context = page.browserContext?.();
39
+ if (typeof context?.setCookie === 'function') {
40
+ return context.setCookie(...normalized);
41
+ }
42
+ if (typeof page.browser?.().setCookie === 'function') {
43
+ return page.browser().setCookie(...normalized);
44
+ }
45
+ return page.setCookie(...normalized);
46
+ }
47
+
48
+ /** Clear the context or only cookies whose domain matches the requested site. */
49
+ export async function clearCookies({ page, engine, domain } = {}) {
50
+ if (engine === 'playwright' && !domain) {
51
+ return page.context().clearCookies();
52
+ }
53
+ const context =
54
+ engine === 'playwright' ? page.context() : page.browserContext?.();
55
+ const cookies =
56
+ engine === 'playwright'
57
+ ? await context.cookies()
58
+ : typeof context?.cookies === 'function'
59
+ ? await context.cookies()
60
+ : await page.cookies();
61
+ const selected = cookies.filter(
62
+ (cookie) => !domain || matchesDomains(cookie.domain, [domain])
63
+ );
64
+ if (engine === 'playwright') {
65
+ for (const cookie of selected) {
66
+ await context.clearCookies({
67
+ name: cookie.name,
68
+ domain: cookie.domain,
69
+ path: cookie.path,
70
+ });
71
+ }
72
+ } else if (typeof context?.deleteCookie === 'function') {
73
+ await context.deleteCookie(...selected);
74
+ } else {
75
+ await page.deleteCookie(...selected);
76
+ }
77
+ return selected.length;
78
+ }
@@ -0,0 +1,72 @@
1
+ import path from 'node:path';
2
+ import { access } from 'node:fs/promises';
3
+ import { assertDedicatedUserDataDir } from './system-browser.js';
4
+ import {
5
+ saveStorageState,
6
+ writeRestrictedState,
7
+ loadStorageState,
8
+ restorePlaywrightStorageState,
9
+ restorePuppeteerStorageState,
10
+ restoreWebDriverStorageState,
11
+ } from './storage-state.js';
12
+
13
+ const installed = Symbol('sessionPersistence');
14
+ export function sessionPersistencePath(options) {
15
+ if (!options.persistSessionCookies) {
16
+ return null;
17
+ }
18
+ if (!options.userDataDir || options.attach) {
19
+ throw new TypeError(
20
+ 'persistSessionCookies requires an explicit dedicated userDataDir'
21
+ );
22
+ }
23
+ assertDedicatedUserDataDir(options.userDataDir);
24
+ return typeof options.persistSessionCookies === 'string'
25
+ ? options.persistSessionCookies
26
+ : path.join(options.userDataDir, 'browser-commander-session.json');
27
+ }
28
+ export async function installSessionPersistence(result, options) {
29
+ const file = sessionPersistencePath(options);
30
+ if (!file || result[installed]) {
31
+ return result;
32
+ }
33
+ const engine = options.engine ?? 'playwright';
34
+ if (
35
+ await access(file).then(
36
+ () => true,
37
+ () => false
38
+ )
39
+ ) {
40
+ const storageState = await loadStorageState(file);
41
+ if (engine === 'playwright') {
42
+ await restorePlaywrightStorageState({
43
+ context: result.page.context(),
44
+ storageState,
45
+ });
46
+ } else if (engine === 'puppeteer') {
47
+ await restorePuppeteerStorageState({ page: result.page, storageState });
48
+ } else {
49
+ await restoreWebDriverStorageState({ page: result.page, storageState });
50
+ }
51
+ }
52
+ const close = result.close;
53
+ let closing;
54
+ const persistAndClose = () =>
55
+ (closing ??= (async () => {
56
+ try {
57
+ const state = await saveStorageState(result.page);
58
+ await writeRestrictedState(file, {
59
+ cookies: state.cookies.filter((cookie) => !(cookie.expires > 0)),
60
+ origins: [],
61
+ });
62
+ } finally {
63
+ await close();
64
+ }
65
+ })());
66
+ result.close = persistAndClose;
67
+ if (typeof result.browser?.close === 'function') {
68
+ result.browser.close = persistAndClose;
69
+ }
70
+ result[installed] = true;
71
+ return result;
72
+ }
@@ -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
+ }