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.
- package/.jscpd-baseline.json +24 -23
- package/CHANGELOG.md +22 -0
- package/README.md +6 -0
- package/eslint.config.js +11 -0
- package/package.json +9 -4
- 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/connector.js +117 -13
- 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/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +54 -9
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +142 -70
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/storage-state.js +32 -6
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -0
- package/src/core/constants.js +1 -0
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +66 -80
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +68 -16
- package/src/core/navigation-safety.js +51 -0
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +130 -42
- package/src/core/readiness.js +76 -14
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +61 -37
- package/src/elements/reusable.js +92 -0
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +38 -21
- package/src/exports.js +14 -0
- package/src/factory.js +17 -16
- package/src/high-level/universal-logic.js +57 -21
- package/src/index.js +2 -0
- package/src/interactions/click.js +47 -0
- package/src/interactions/overlays.js +28 -0
- package/src/interactions/scroll.js +8 -2
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +47 -4
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- 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/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -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/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/links.test.js +2 -2
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
import { browserFamily } from './browser-sources.js';
|
|
4
|
+
import { matchesDomains } from './migration/domains.js';
|
|
5
|
+
import {
|
|
6
|
+
resolveBrowserProfile,
|
|
7
|
+
resolveSourceBrowser,
|
|
8
|
+
} from './browser-profiles.js';
|
|
9
|
+
import { saveStorageState } from './storage-state.js';
|
|
10
|
+
import { setCookies } from './session-cookies.js';
|
|
11
|
+
|
|
12
|
+
/** Decrypt through the source browser in a disposable cookie-only snapshot. */
|
|
13
|
+
export async function readBrowserCookieSession(options, dependencies = {}) {
|
|
14
|
+
const browser = await resolveSourceBrowser(options.browser, dependencies);
|
|
15
|
+
if (browserFamily(browser) !== 'chromium') {
|
|
16
|
+
throw new TypeError(
|
|
17
|
+
'Browser-backed cookie reading requires a Chromium snapshot; use via: database for Firefox or Safari'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
const profile = options.profileDir
|
|
21
|
+
? { path: options.profileDir }
|
|
22
|
+
: await resolveBrowserProfile({
|
|
23
|
+
...dependencies,
|
|
24
|
+
browser,
|
|
25
|
+
profile: options.profile,
|
|
26
|
+
});
|
|
27
|
+
const launch =
|
|
28
|
+
dependencies.launch ??
|
|
29
|
+
(await import('./real-browser.js')).launchRealBrowser;
|
|
30
|
+
const result = await launch({
|
|
31
|
+
...options.launchOptions,
|
|
32
|
+
channel: browser,
|
|
33
|
+
engine: options.engine ?? 'playwright',
|
|
34
|
+
attach: {
|
|
35
|
+
mode: 'snapshot',
|
|
36
|
+
browser,
|
|
37
|
+
profile: path.basename(profile.path),
|
|
38
|
+
userDataDir: path.dirname(profile.path),
|
|
39
|
+
include: ['cookies'],
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
const state = await saveStorageState(result.page);
|
|
44
|
+
return state.cookies.filter((cookie) =>
|
|
45
|
+
matchesDomains(
|
|
46
|
+
cookie.domain,
|
|
47
|
+
options.domains ?? (options.domainFilter ? [options.domainFilter] : [])
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
} finally {
|
|
51
|
+
await result.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readDatabaseSession(source, options, dependencies) {
|
|
56
|
+
const read =
|
|
57
|
+
dependencies.readCookies ??
|
|
58
|
+
(await import('./browser-cookies.js')).readBrowserCookies;
|
|
59
|
+
const cookies = (
|
|
60
|
+
await read({
|
|
61
|
+
browser: source.browser,
|
|
62
|
+
profileDir: source.path,
|
|
63
|
+
via: 'database',
|
|
64
|
+
cache: false,
|
|
65
|
+
})
|
|
66
|
+
).filter((cookie) => matchesDomains(cookie.domain, options.domains));
|
|
67
|
+
let loggedIn = null;
|
|
68
|
+
if (options.isLoggedIn) {
|
|
69
|
+
const launch =
|
|
70
|
+
dependencies.launch ?? (await import('./launcher.js')).launchBrowser;
|
|
71
|
+
const engine = source.engine ?? options.engine ?? 'playwright';
|
|
72
|
+
const result = await launch({
|
|
73
|
+
...options.launchOptions,
|
|
74
|
+
...source.launchOptions,
|
|
75
|
+
engine,
|
|
76
|
+
launch: 'engine',
|
|
77
|
+
userDataDir: undefined,
|
|
78
|
+
attach: undefined,
|
|
79
|
+
persistSessionCookies: false,
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
await setCookies({ page: result.page, engine, cookies });
|
|
83
|
+
loggedIn = Boolean(
|
|
84
|
+
await options.isLoggedIn({ ...result, source, cookies })
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
await result.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ...source, cookies, loggedIn };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Discover candidate profiles and optionally validate their live sign-in state. */
|
|
94
|
+
export async function findSiteSessions(options = {}, dependencies = {}) {
|
|
95
|
+
if (!Array.isArray(options.domains) || !options.domains.length) {
|
|
96
|
+
throw new TypeError('findSiteSessions requires domains');
|
|
97
|
+
}
|
|
98
|
+
const list =
|
|
99
|
+
dependencies.listSources ??
|
|
100
|
+
(await import('./browser-cookies.js')).listCookieSources;
|
|
101
|
+
const sources = [
|
|
102
|
+
...(options.sources ?? (await list({ domains: options.domains }))),
|
|
103
|
+
...(options.profiles ?? []),
|
|
104
|
+
];
|
|
105
|
+
const sessions = [];
|
|
106
|
+
for (const source of sources) {
|
|
107
|
+
let result;
|
|
108
|
+
let snapshot;
|
|
109
|
+
try {
|
|
110
|
+
if (browserFamily(source.browser) !== 'chromium') {
|
|
111
|
+
sessions.push(await readDatabaseSession(source, options, dependencies));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const attach = {
|
|
115
|
+
mode: 'snapshot',
|
|
116
|
+
browser: source.browser,
|
|
117
|
+
profile: path.basename(source.path),
|
|
118
|
+
userDataDir: path.dirname(source.path),
|
|
119
|
+
include: ['cookies'],
|
|
120
|
+
};
|
|
121
|
+
if (source.launch === 'engine') {
|
|
122
|
+
const copy =
|
|
123
|
+
dependencies.snapshot ??
|
|
124
|
+
(await import('./attach/snapshot.js')).snapshotUserDataDir;
|
|
125
|
+
snapshot = await copy(attach);
|
|
126
|
+
const launch =
|
|
127
|
+
dependencies.launch ?? (await import('./launcher.js')).launchBrowser;
|
|
128
|
+
result = await launch({
|
|
129
|
+
...options.launchOptions,
|
|
130
|
+
...source.launchOptions,
|
|
131
|
+
engine: source.engine ?? options.engine ?? 'playwright',
|
|
132
|
+
launch: 'engine',
|
|
133
|
+
userDataDir: snapshot.target,
|
|
134
|
+
args: [
|
|
135
|
+
...(options.launchOptions?.args ?? []),
|
|
136
|
+
...(source.launchOptions?.args ?? []),
|
|
137
|
+
`--profile-directory=${attach.profile}`,
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
const launch =
|
|
142
|
+
dependencies.launch ??
|
|
143
|
+
(await import('./real-browser.js')).launchRealBrowser;
|
|
144
|
+
result = await launch({
|
|
145
|
+
...options.launchOptions,
|
|
146
|
+
engine: source.engine ?? options.engine ?? 'playwright',
|
|
147
|
+
channel: source.browser,
|
|
148
|
+
attach,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const state = await saveStorageState(result.page);
|
|
152
|
+
const cookies = state.cookies.filter((cookie) =>
|
|
153
|
+
matchesDomains(cookie.domain, options.domains)
|
|
154
|
+
);
|
|
155
|
+
const loggedIn = options.isLoggedIn
|
|
156
|
+
? Boolean(await options.isLoggedIn({ ...result, source, cookies }))
|
|
157
|
+
: null;
|
|
158
|
+
sessions.push({ ...source, cookies, loggedIn });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
sessions.push({
|
|
161
|
+
...source,
|
|
162
|
+
cookies: [],
|
|
163
|
+
loggedIn: null,
|
|
164
|
+
error: error.message,
|
|
165
|
+
});
|
|
166
|
+
} finally {
|
|
167
|
+
try {
|
|
168
|
+
await result?.close();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
sessions.at(-1).error = error.message;
|
|
171
|
+
} finally {
|
|
172
|
+
if (snapshot) {
|
|
173
|
+
try {
|
|
174
|
+
await rm(snapshot.target, { recursive: true, force: true });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
sessions.at(-1).error = error.message;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return sessions;
|
|
183
|
+
}
|
|
@@ -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:
|
package/src/browser/connector.js
CHANGED
|
@@ -45,6 +45,7 @@ async function connectSelenium(options, dependencies) {
|
|
|
45
45
|
...options,
|
|
46
46
|
debuggerAddress: new URL(options.cdpEndpoint).host,
|
|
47
47
|
});
|
|
48
|
+
await selectWebDriverTab(connected.driver, options);
|
|
48
49
|
return {
|
|
49
50
|
...connected,
|
|
50
51
|
browser: connected.driver,
|
|
@@ -52,6 +53,52 @@ async function connectSelenium(options, dependencies) {
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
async function selectWebDriverTab(driver, options) {
|
|
57
|
+
if (!driver?.getAllWindowHandles) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const original = await driver.getWindowHandle();
|
|
61
|
+
const handles = await driver.getAllWindowHandles();
|
|
62
|
+
let selected = original;
|
|
63
|
+
if (options.targetId || options.url) {
|
|
64
|
+
selected = null;
|
|
65
|
+
for (const handle of handles) {
|
|
66
|
+
if (
|
|
67
|
+
options.targetId &&
|
|
68
|
+
handle !== options.targetId &&
|
|
69
|
+
handle !== `CDwindow-${options.targetId}`
|
|
70
|
+
) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
await driver.switchTo().window(handle);
|
|
74
|
+
const url = await driver.getCurrentUrl();
|
|
75
|
+
if (
|
|
76
|
+
options.url &&
|
|
77
|
+
!(typeof options.url === 'function'
|
|
78
|
+
? options.url(url)
|
|
79
|
+
: options.url instanceof RegExp
|
|
80
|
+
? options.url.test(url)
|
|
81
|
+
: url === options.url)
|
|
82
|
+
) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
selected = handle;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
if (!selected) {
|
|
89
|
+
await driver.switchTo().window(original);
|
|
90
|
+
throw new Error('No tab matches the requested targetId or URL');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (options.singleTab) {
|
|
94
|
+
for (const handle of handles.filter((handle) => handle !== selected)) {
|
|
95
|
+
await driver.switchTo().window(handle);
|
|
96
|
+
await driver.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await driver.switchTo().window(selected);
|
|
100
|
+
}
|
|
101
|
+
|
|
55
102
|
function addDefinedOptions(options, values) {
|
|
56
103
|
for (const [key, value] of Object.entries(values)) {
|
|
57
104
|
if (value !== undefined) {
|
|
@@ -111,19 +158,75 @@ async function prepareStorageState({ storageState, seedCookies }) {
|
|
|
111
158
|
* @param {Object[]} pages - Engine page handles
|
|
112
159
|
* @returns {Promise<Object|undefined>}
|
|
113
160
|
*/
|
|
114
|
-
export async function pickForegroundPage(pages) {
|
|
161
|
+
export async function pickForegroundPage(pages, options = {}) {
|
|
162
|
+
const targets = new Map();
|
|
163
|
+
// Playwright emulates focus on every attached tab. Remove the emulation
|
|
164
|
+
// before observing visibility; DevTools target order has no active-tab flag.
|
|
115
165
|
for (const page of pages) {
|
|
116
|
-
|
|
117
|
-
|
|
166
|
+
let session;
|
|
167
|
+
try {
|
|
168
|
+
session =
|
|
169
|
+
(await page.context?.().newCDPSession?.(page)) ??
|
|
170
|
+
(await page.target?.().createCDPSession?.());
|
|
171
|
+
if (!session) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const { targetInfo } = await session.send('Target.getTargetInfo');
|
|
175
|
+
targets.set(page, targetInfo.targetId);
|
|
176
|
+
await session.send('Emulation.setFocusEmulationEnabled', {
|
|
177
|
+
enabled: false,
|
|
178
|
+
});
|
|
179
|
+
} catch {
|
|
180
|
+
// Non-Chromium engines do not expose CDP. Explicit URL matching still
|
|
181
|
+
// works there, and normal visibility is not affected by this emulation.
|
|
182
|
+
} finally {
|
|
183
|
+
await session?.detach?.().catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let selected;
|
|
187
|
+
if (options.targetId || options.url) {
|
|
188
|
+
selected = pages.find((page) => {
|
|
189
|
+
if (options.targetId && targets.get(page) !== options.targetId) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
const url = page.url();
|
|
193
|
+
const matcher = options.url;
|
|
194
|
+
if (matcher instanceof RegExp) {
|
|
195
|
+
matcher.lastIndex = 0;
|
|
196
|
+
return matcher.test(url);
|
|
197
|
+
}
|
|
198
|
+
if (typeof matcher === 'function') {
|
|
199
|
+
return matcher(url);
|
|
200
|
+
}
|
|
201
|
+
return !matcher || matcher === url;
|
|
202
|
+
});
|
|
203
|
+
if (!selected) {
|
|
204
|
+
throw new RangeError('No tab matches the requested targetId/URL');
|
|
118
205
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
206
|
+
}
|
|
207
|
+
if (!selected) {
|
|
208
|
+
for (const page of pages) {
|
|
209
|
+
if (typeof page?.evaluate !== 'function') {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const state = await page
|
|
213
|
+
.evaluate(() => document.visibilityState)
|
|
214
|
+
.catch(() => null);
|
|
215
|
+
if (state === 'visible') {
|
|
216
|
+
selected = page;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
selected ??= pages[0];
|
|
222
|
+
if (options.singleTab && selected) {
|
|
223
|
+
for (const page of pages) {
|
|
224
|
+
if (page !== selected) {
|
|
225
|
+
await page.close();
|
|
226
|
+
}
|
|
124
227
|
}
|
|
125
228
|
}
|
|
126
|
-
return
|
|
229
|
+
return selected;
|
|
127
230
|
}
|
|
128
231
|
|
|
129
232
|
async function connectPlaywright({ options, loadPlaywright, storageState }) {
|
|
@@ -138,10 +241,11 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
|
|
|
138
241
|
throw new Error('Connected Playwright browser has no default context');
|
|
139
242
|
}
|
|
140
243
|
const page =
|
|
141
|
-
(await pickForegroundPage(context.pages())) ??
|
|
244
|
+
(await pickForegroundPage(context.pages(), options)) ??
|
|
245
|
+
(await context.newPage());
|
|
142
246
|
|
|
143
247
|
await restorePlaywrightStorageState({ context, storageState });
|
|
144
|
-
return { browser, page };
|
|
248
|
+
return { browser, page, detach: browser.close?.bind(browser) };
|
|
145
249
|
}
|
|
146
250
|
|
|
147
251
|
async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
|
|
@@ -151,11 +255,11 @@ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
|
|
|
151
255
|
buildPuppeteerConnectOptions(options)
|
|
152
256
|
);
|
|
153
257
|
const page =
|
|
154
|
-
(await pickForegroundPage(await browser.pages())) ??
|
|
258
|
+
(await pickForegroundPage(await browser.pages(), options)) ??
|
|
155
259
|
(await browser.newPage());
|
|
156
260
|
|
|
157
261
|
await restorePuppeteerStorageState({ page, storageState });
|
|
158
|
-
return { browser, page };
|
|
262
|
+
return { browser, page, detach: browser.disconnect?.bind(browser) };
|
|
159
263
|
}
|
|
160
264
|
|
|
161
265
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -26,6 +26,7 @@ export function resolveChromeArgs({
|
|
|
26
26
|
extraArgs = [],
|
|
27
27
|
ignoreDefaultArgs = [],
|
|
28
28
|
restrictions = [],
|
|
29
|
+
disableFeatures = [],
|
|
29
30
|
} = {}) {
|
|
30
31
|
assertStringArray(args, 'args');
|
|
31
32
|
assertStringArray(extraArgs, 'extraArgs');
|
|
@@ -41,7 +42,7 @@ export function resolveChromeArgs({
|
|
|
41
42
|
|
|
42
43
|
return {
|
|
43
44
|
args: mergeFeatureSwitches([
|
|
44
|
-
...resolveRestrictions(restrictions).args,
|
|
45
|
+
...resolveRestrictions(restrictions, { disableFeatures }).args,
|
|
45
46
|
...args,
|
|
46
47
|
...extraArgs,
|
|
47
48
|
]),
|
|
@@ -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
|
}
|