browser-commander 0.26.2 → 0.27.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 (58) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +2 -0
  4. package/package.json +5 -3
  5. package/src/bindings.js +19 -0
  6. package/src/browser/attach/snapshot-launch.js +8 -6
  7. package/src/browser/attach/snapshot.js +39 -2
  8. package/src/browser/browser-cookie-database.js +71 -2
  9. package/src/browser/browser-cookie-session.js +183 -0
  10. package/src/browser/browser-cookies.js +29 -0
  11. package/src/browser/browser-sources.js +13 -0
  12. package/src/browser/browser-sources.json +13 -0
  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/launcher.js +28 -8
  17. package/src/browser/migration/sqlite-snapshot.js +5 -27
  18. package/src/browser/navigation.js +173 -98
  19. package/src/browser/real-browser.js +135 -68
  20. package/src/browser/session-cookies.js +78 -0
  21. package/src/browser/session-persistence.js +72 -0
  22. package/src/browser/storage-state.js +32 -6
  23. package/src/browser/system-browser.js +2 -2
  24. package/src/core/dialog-manager.js +7 -0
  25. package/src/core/managed-navigation.js +101 -0
  26. package/src/core/navigation-manager.js +62 -78
  27. package/src/core/navigation-operation.js +25 -0
  28. package/src/core/navigation-readiness.js +40 -16
  29. package/src/core/network-tracker.js +39 -92
  30. package/src/core/page-session.js +3 -2
  31. package/src/core/page-trigger-manager.js +15 -12
  32. package/src/core/readiness.js +67 -13
  33. package/src/core/subscriptions.js +20 -0
  34. package/src/core/webdriver-page.js +38 -0
  35. package/src/elements/locators.js +35 -5
  36. package/src/elements/reusable.js +92 -0
  37. package/src/elements/visibility.js +6 -11
  38. package/src/exports.js +14 -0
  39. package/src/factory.js +4 -4
  40. package/src/high-level/universal-logic.js +57 -21
  41. package/src/interactions/click.js +3 -0
  42. package/src/interactions/scroll.js +8 -2
  43. package/src/utilities/subprocess.js +15 -4
  44. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  45. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  46. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  47. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  48. package/tests/unit/browser/catalogue-launch.test.js +50 -2
  49. package/tests/unit/browser/default-browser.test.js +18 -0
  50. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  51. package/tests/unit/browser/real-browser.test.js +1 -1
  52. package/tests/unit/browser/session-workflows.test.js +206 -0
  53. package/tests/unit/browser/storage-state.test.js +7 -2
  54. package/tests/unit/core/navigation-budget.test.js +120 -0
  55. package/tests/unit/core/navigation-manager.test.js +23 -1
  56. package/tests/unit/core/webdriver-page.test.js +33 -0
  57. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  58. package/tests/unit/traces/links.test.js +2 -2
@@ -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:
@@ -141,6 +141,19 @@ export function resolveBrowserRoots(name, options = {}) {
141
141
  return resolveTemplates(templates, options);
142
142
  }
143
143
 
144
+ /**
145
+ * Browser-owned paths forbidden as automation profiles. Import discovery may
146
+ * search broader locations; use explicit protection roots when declared.
147
+ */
148
+ export function resolveBrowserProtectionRoots(name, options = {}) {
149
+ const source = normalizeBrowserSource(name);
150
+ const roots = source.protectionRoots ?? source.roots;
151
+ return resolveTemplates(
152
+ roots?.[options.platform ?? process.platform] ?? [],
153
+ options
154
+ );
155
+ }
156
+
144
157
  function resolveTemplates(
145
158
  templates,
146
159
  {
@@ -764,6 +764,13 @@
764
764
  "{home}/Library"
765
765
  ]
766
766
  },
767
+ "protectionRoots": {
768
+ "darwin": [
769
+ "{home}/Library/Safari",
770
+ "{home}/Library/Containers/com.apple.Safari",
771
+ "{home}/Library/Cookies"
772
+ ]
773
+ },
767
774
  "default": {
768
775
  "darwin": ["com.apple.safari"]
769
776
  },
@@ -781,6 +788,12 @@
781
788
  "{home}/Library/Safari Technology Preview"
782
789
  ]
783
790
  },
791
+ "protectionRoots": {
792
+ "darwin": [
793
+ "{home}/Library/Safari Technology Preview",
794
+ "{home}/Library/Containers/com.apple.SafariTechnologyPreview"
795
+ ]
796
+ },
784
797
  "default": {
785
798
  "darwin": ["com.apple.safaritechnologypreview"]
786
799
  },
@@ -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
+ }
@@ -1,4 +1,9 @@
1
1
  import { access } from 'node:fs/promises';
2
+ import { launchFailure } from './launch-diagnostics.js';
3
+ import {
4
+ installSessionPersistence,
5
+ sessionPersistencePath,
6
+ } from './session-persistence.js';
2
7
 
3
8
  import { assertSupportedEngine } from './connector.js';
4
9
  import { emulateMedia } from './media.js';
@@ -302,6 +307,7 @@ export async function launchBrowserWithDependencies(
302
307
  options = {},
303
308
  dependencies = {}
304
309
  ) {
310
+ sessionPersistencePath(options);
305
311
  if (isSafariChannel(options.channel ?? options.browser)) {
306
312
  validateLaunchMode({
307
313
  engine: options.engine ?? 'playwright',
@@ -310,7 +316,13 @@ export async function launchBrowserWithDependencies(
310
316
  });
311
317
  return await launchSafari(options, dependencies);
312
318
  }
313
- return await launchNonSafariBrowser(options, dependencies);
319
+ const result = await launchNonSafariBrowser(options, dependencies);
320
+ try {
321
+ return await installSessionPersistence(result, options);
322
+ } catch (error) {
323
+ await result.close();
324
+ throw error;
325
+ }
314
326
  }
315
327
 
316
328
  async function launchNonSafariBrowser(options, dependencies) {
@@ -361,13 +373,21 @@ async function launchNonSafariBrowser(options, dependencies) {
361
373
  automationParity,
362
374
  storageState: resolvedStorageState,
363
375
  };
364
- const launched =
365
- launch === 'engine'
366
- ? await launchWithEngine(
367
- { ...common, ignoreDefaultArgs, colorScheme },
368
- dependencies
369
- )
370
- : await launchReal(common, dependencies);
376
+ let launched;
377
+ try {
378
+ launched =
379
+ launch === 'engine'
380
+ ? await launchWithEngine(
381
+ { ...common, ignoreDefaultArgs, colorScheme },
382
+ dependencies
383
+ )
384
+ : await launchReal(common, dependencies);
385
+ } catch (error) {
386
+ throw launchFailure(error, {
387
+ ...common,
388
+ phase: launch === 'engine' ? 'engine_launch' : 'launch',
389
+ });
390
+ }
371
391
  const { browser, page } = launched;
372
392
  if (verbose) {
373
393
  console.log(`✅ Browser launched with ${engine} engine`);
@@ -3,9 +3,10 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { pathExists } from './fs-utils.js';
5
5
 
6
- import BetterSqlite3 from 'better-sqlite3';
7
-
8
- import { openSqliteDatabase } from '../browser-cookie-database.js';
6
+ import {
7
+ openSqliteDatabase,
8
+ backupSqliteDatabase,
9
+ } from '../browser-cookie-database.js';
9
10
 
10
11
  /**
11
12
  * Read a Chromium SQLite database (History, Top Sites, Login Data, Web Data)
@@ -40,36 +41,13 @@ export async function withDatabaseSnapshot({ sourcePath, read }) {
40
41
  try {
41
42
  // The backup API needs to open the source. It opens read-only, so nothing
42
43
  // is written to the user's real database.
43
- let source;
44
44
  try {
45
- source = new BetterSqlite3(sourcePath, {
46
- readonly: true,
47
- fileMustExist: true,
48
- timeout: 1000,
49
- });
50
- // Detect an exclusive lock before better-sqlite3's initial zero-page
51
- // transfer can mistake SQLITE_BUSY for an empty completed backup.
52
- source.pragma('schema_version');
53
- let remaining = Infinity;
54
- let lastProgress = Date.now();
55
- await source.backup(snapshotPath, {
56
- progress: ({ remainingPages }) => {
57
- if (remainingPages < remaining) {
58
- lastProgress = Date.now();
59
- } else if (Date.now() - lastProgress >= 1000) {
60
- throw new Error('SQLite backup made no progress');
61
- }
62
- remaining = remainingPages;
63
- return 256;
64
- },
65
- });
45
+ await backupSqliteDatabase(sourcePath, snapshotPath);
66
46
  } catch (cause) {
67
47
  throw new Error(
68
48
  `Consistent SQLite snapshot unavailable for ${sourcePath}; close the source browser and retry, or supply a consistent read-only snapshot. ${cause.message}`,
69
49
  { cause }
70
50
  );
71
- } finally {
72
- source?.close();
73
51
  }
74
52
  return await read(snapshotPath);
75
53
  } finally {