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.
- package/.jscpd-baseline.json +24 -23
- package/CHANGELOG.md +12 -0
- package/README.md +2 -0
- package/package.json +5 -3
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/browser-sources.js +13 -0
- package/src/browser/browser-sources.json +13 -0
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launcher.js +28 -8
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/real-browser.js +135 -68
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/storage-state.js +32 -6
- package/src/browser/system-browser.js +2 -2
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +62 -78
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +40 -16
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +15 -12
- package/src/core/readiness.js +67 -13
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +35 -5
- package/src/elements/reusable.js +92 -0
- package/src/elements/visibility.js +6 -11
- package/src/exports.js +14 -0
- package/src/factory.js +4 -4
- package/src/high-level/universal-logic.js +57 -21
- package/src/interactions/click.js +3 -0
- package/src/interactions/scroll.js +8 -2
- package/src/utilities/subprocess.js +15 -4
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/catalogue-launch.test.js +50 -2
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- package/tests/unit/core/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -0
- 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
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/browser/launcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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 {
|