browser-commander 0.27.0 → 0.29.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 +5 -19
- package/CHANGELOG.md +22 -0
- package/README.md +23 -9
- package/eslint.config.js +11 -0
- package/package.json +5 -2
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +215 -36
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +26 -1
- package/src/browser/persistent-session.js +432 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +7 -2
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +82 -0
- 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 +69 -1
- package/src/cli/commands.js +116 -2
- package/src/cli/high-level-methods.js +47 -3
- package/src/cli/sessions.js +14 -3
- package/src/core/commander-features.js +110 -0
- package/src/core/constants.js +1 -0
- package/src/core/navigation-manager.js +4 -2
- package/src/core/navigation-readiness.js +30 -2
- package/src/core/navigation-safety.js +52 -0
- package/src/core/page-trigger-manager.js +280 -156
- package/src/core/readiness.js +10 -2
- package/src/elements/locators.js +26 -32
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +32 -10
- package/src/factory.js +13 -12
- package/src/index.js +4 -0
- package/src/interactions/click.js +44 -0
- package/src/interactions/overlays.js +73 -0
- package/src/traces/bundle.js +55 -8
- package/src/traces/checkpoints.js +103 -0
- package/src/traces/links.js +157 -2
- package/src/traces/mutation-stream.js +9 -2
- package/src/traces/network.js +309 -0
- package/src/traces/page-capture.js +203 -68
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +253 -247
- package/src/traces/redaction.js +89 -6
- package/src/traces/render.js +168 -0
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +77 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +160 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +32 -0
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- 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/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/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -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/interactions/overlays.test.js +58 -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/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- package/tests/unit/traces/network.test.js +149 -0
|
@@ -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
|
}
|
package/src/browser/launcher.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
import { assertSupportedEngine } from './connector.js';
|
|
9
9
|
import { emulateMedia } from './media.js';
|
|
10
|
+
import { reserveLoopbackPort } from './debugging-port.js';
|
|
10
11
|
import {
|
|
11
12
|
buildPlaywrightLaunchOptions,
|
|
12
13
|
buildPuppeteerLaunchOptions,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
} from './launch-options.js';
|
|
15
16
|
import {
|
|
16
17
|
createTemporaryUserDataDir,
|
|
18
|
+
configureUserDataDir,
|
|
17
19
|
removeUserDataDir,
|
|
18
20
|
} from './profile-directory.js';
|
|
19
21
|
import { launchRealBrowser } from './real-browser.js';
|
|
@@ -168,6 +170,7 @@ async function launchWithEngine(options, dependencies) {
|
|
|
168
170
|
extraArgs,
|
|
169
171
|
ignoreDefaultArgs,
|
|
170
172
|
restrictions,
|
|
173
|
+
disableFeatures: options.disableFeatures,
|
|
171
174
|
});
|
|
172
175
|
const restrictionEnv = resolveRestrictions(restrictions).env;
|
|
173
176
|
const childEnv =
|
|
@@ -177,6 +180,13 @@ async function launchWithEngine(options, dependencies) {
|
|
|
177
180
|
const temporaryProfile = !requestedUserDataDir;
|
|
178
181
|
const userDataDir =
|
|
179
182
|
requestedUserDataDir ?? (await createTemporaryUserDataDir());
|
|
183
|
+
await configureUserDataDir(userDataDir, {
|
|
184
|
+
preferences: {
|
|
185
|
+
...resolveRestrictions(restrictions).preferences,
|
|
186
|
+
...options.preferences,
|
|
187
|
+
},
|
|
188
|
+
localState: options.localState,
|
|
189
|
+
});
|
|
180
190
|
const engineModule = await (
|
|
181
191
|
dependencies.loadEngineModule ?? loadEngine[engine]
|
|
182
192
|
)();
|
|
@@ -307,6 +317,15 @@ export async function launchBrowserWithDependencies(
|
|
|
307
317
|
options = {},
|
|
308
318
|
dependencies = {}
|
|
309
319
|
) {
|
|
320
|
+
if (options.keepOpen) {
|
|
321
|
+
const { connectOrLaunch } = await import('./persistent-session.js');
|
|
322
|
+
return connectOrLaunch({
|
|
323
|
+
...options,
|
|
324
|
+
userDataDir: options.userDataDir ?? (await createTemporaryUserDataDir()),
|
|
325
|
+
remoteDebuggingPort:
|
|
326
|
+
options.remoteDebuggingPort ?? (await reserveLoopbackPort()),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
310
329
|
sessionPersistencePath(options);
|
|
311
330
|
if (isSafariChannel(options.channel ?? options.browser)) {
|
|
312
331
|
validateLaunchMode({
|
|
@@ -355,7 +374,13 @@ async function launchNonSafariBrowser(options, dependencies) {
|
|
|
355
374
|
}
|
|
356
375
|
}
|
|
357
376
|
// Validate arguments before anything is started or written to disk.
|
|
358
|
-
resolveChromeArgs({
|
|
377
|
+
resolveChromeArgs({
|
|
378
|
+
args,
|
|
379
|
+
extraArgs,
|
|
380
|
+
ignoreDefaultArgs,
|
|
381
|
+
restrictions,
|
|
382
|
+
disableFeatures: options.disableFeatures,
|
|
383
|
+
});
|
|
359
384
|
const resolvedStorageState = await loadStorageState(storageState);
|
|
360
385
|
|
|
361
386
|
if (verbose) {
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { connectBrowser, pickForegroundPage } from './connector.js';
|
|
6
|
+
import { launchAndConnectRealBrowserWithDependencies } from './real-browser.js';
|
|
7
|
+
import { assertDedicatedUserDataDir } from './system-browser.js';
|
|
8
|
+
import { findSessionProcess } from './session-adoption.js';
|
|
9
|
+
import { startDetachedProcess } from '../utilities/subprocess.js';
|
|
10
|
+
|
|
11
|
+
export const SESSION_METADATA = '.browser-commander-session.json';
|
|
12
|
+
const activity = new WeakMap();
|
|
13
|
+
export function touchSessionPage(page) {
|
|
14
|
+
activity.get(page)?.();
|
|
15
|
+
}
|
|
16
|
+
const read = (file) =>
|
|
17
|
+
fs
|
|
18
|
+
.readFile(file, 'utf8')
|
|
19
|
+
.then(JSON.parse)
|
|
20
|
+
.catch(() => null);
|
|
21
|
+
export async function writeSessionMetadata(file, value) {
|
|
22
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
23
|
+
try {
|
|
24
|
+
await fs.writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
|
|
25
|
+
await fs.rename(temporary, file);
|
|
26
|
+
} finally {
|
|
27
|
+
await fs.rm(temporary, { force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function probeSession(endpoint) {
|
|
31
|
+
return fetch(`${endpoint}/json/version`, {
|
|
32
|
+
signal: AbortSignal.timeout(1000),
|
|
33
|
+
})
|
|
34
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
35
|
+
.catch(() => null);
|
|
36
|
+
}
|
|
37
|
+
export async function acquireLease(file, retries = 50) {
|
|
38
|
+
let lease;
|
|
39
|
+
try {
|
|
40
|
+
lease = await fs.open(file, 'wx', 0o600);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code !== 'EEXIST') {
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
const owner = await read(file);
|
|
46
|
+
if (retries > 0) {
|
|
47
|
+
let alive = !owner?.pid;
|
|
48
|
+
if (owner?.pid) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(owner.pid, 0);
|
|
51
|
+
alive = true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
alive = error.code !== 'ESRCH';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (alive) {
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
58
|
+
return acquireLease(file, retries - 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!owner?.pid) {
|
|
62
|
+
throw new Error('Persistent session launch is already in progress', {
|
|
63
|
+
cause: error,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
let alive = true;
|
|
67
|
+
try {
|
|
68
|
+
process.kill(owner.pid, 0);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
alive = error.code !== 'ESRCH';
|
|
71
|
+
}
|
|
72
|
+
if (alive) {
|
|
73
|
+
throw new Error('Persistent session launch is already in progress', {
|
|
74
|
+
cause: error,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
await fs.rm(file);
|
|
78
|
+
lease = await fs.open(file, 'wx', 0o600);
|
|
79
|
+
}
|
|
80
|
+
const token = randomUUID();
|
|
81
|
+
await lease.writeFile(JSON.stringify({ pid: process.pid, token }));
|
|
82
|
+
return async () => {
|
|
83
|
+
await lease.close();
|
|
84
|
+
if ((await read(file))?.token === token) {
|
|
85
|
+
await fs.rm(file, { force: true });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function validate(options) {
|
|
90
|
+
if (
|
|
91
|
+
!options.userDataDir ||
|
|
92
|
+
!Number.isInteger(options.remoteDebuggingPort) ||
|
|
93
|
+
options.remoteDebuggingPort < 1 ||
|
|
94
|
+
options.remoteDebuggingPort > 65535
|
|
95
|
+
) {
|
|
96
|
+
throw new TypeError(
|
|
97
|
+
'connectOrLaunch requires a dedicated userDataDir and fixed remoteDebuggingPort'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!Number.isFinite(options.idleTimeoutMs) || options.idleTimeoutMs <= 0) {
|
|
101
|
+
throw new RangeError('idleTimeoutMs must be positive');
|
|
102
|
+
}
|
|
103
|
+
if (!['playwright', 'puppeteer'].includes(options.engine)) {
|
|
104
|
+
throw new TypeError('Persistent sessions require a Chromium CDP engine');
|
|
105
|
+
}
|
|
106
|
+
assertDedicatedUserDataDir(options.userDataDir);
|
|
107
|
+
}
|
|
108
|
+
async function open(options, file, endpoint) {
|
|
109
|
+
let metadata = await read(file);
|
|
110
|
+
const version = await probeSession(endpoint);
|
|
111
|
+
if (version) {
|
|
112
|
+
let adopted = false;
|
|
113
|
+
if (!metadata && options.adoptExisting) {
|
|
114
|
+
const pid = await findSessionProcess(options);
|
|
115
|
+
metadata = {
|
|
116
|
+
token: randomUUID(),
|
|
117
|
+
pid,
|
|
118
|
+
userDataDir: path.resolve(options.userDataDir),
|
|
119
|
+
remoteDebuggingPort: options.remoteDebuggingPort,
|
|
120
|
+
webSocketDebuggerUrl: version.webSocketDebuggerUrl,
|
|
121
|
+
};
|
|
122
|
+
adopted = true;
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
!metadata ||
|
|
126
|
+
path.resolve(metadata.userDataDir ?? '') !==
|
|
127
|
+
path.resolve(options.userDataDir) ||
|
|
128
|
+
metadata.remoteDebuggingPort !== options.remoteDebuggingPort ||
|
|
129
|
+
metadata.webSocketDebuggerUrl !== version.webSocketDebuggerUrl
|
|
130
|
+
) {
|
|
131
|
+
throw new Error('Debugging port belongs to another browser/profile');
|
|
132
|
+
}
|
|
133
|
+
const session = await connectBrowser({
|
|
134
|
+
...options,
|
|
135
|
+
cdpEndpoint: endpoint,
|
|
136
|
+
targetId:
|
|
137
|
+
options.targetId ??
|
|
138
|
+
(!options.url && !options.urlMatchers?.length
|
|
139
|
+
? metadata.targetId
|
|
140
|
+
: undefined),
|
|
141
|
+
fallback: !options.targetId,
|
|
142
|
+
});
|
|
143
|
+
return { session, metadata, reused: true, adopted };
|
|
144
|
+
}
|
|
145
|
+
const session = await launchAndConnectRealBrowserWithDependencies(
|
|
146
|
+
{ ...options, keepOpen: false },
|
|
147
|
+
{
|
|
148
|
+
spawnBrowser: (file, args, settings) =>
|
|
149
|
+
startDetachedProcess(file, args, settings),
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
try {
|
|
153
|
+
const version = await probeSession(endpoint);
|
|
154
|
+
if (!version) {
|
|
155
|
+
throw new Error('Persistent browser endpoint disappeared after launch');
|
|
156
|
+
}
|
|
157
|
+
metadata = {
|
|
158
|
+
token: randomUUID(),
|
|
159
|
+
pid: session.browserProcess.pid,
|
|
160
|
+
userDataDir: path.resolve(options.userDataDir),
|
|
161
|
+
remoteDebuggingPort: options.remoteDebuggingPort,
|
|
162
|
+
webSocketDebuggerUrl: version.webSocketDebuggerUrl,
|
|
163
|
+
};
|
|
164
|
+
return { session, metadata, reused: false };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
await session.close();
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function remember(page, engine, file, metadata, idleTimeoutMs) {
|
|
171
|
+
const cdp =
|
|
172
|
+
engine === 'playwright'
|
|
173
|
+
? await page.context().newCDPSession(page)
|
|
174
|
+
: await page.target().createCDPSession();
|
|
175
|
+
try {
|
|
176
|
+
metadata.targetId = (
|
|
177
|
+
await cdp.send('Target.getTargetInfo')
|
|
178
|
+
).targetInfo.targetId;
|
|
179
|
+
} finally {
|
|
180
|
+
await cdp.detach();
|
|
181
|
+
}
|
|
182
|
+
metadata.lastActive = Date.now();
|
|
183
|
+
metadata.idleTimeoutMs = idleTimeoutMs;
|
|
184
|
+
await writeSessionMetadata(file, metadata);
|
|
185
|
+
}
|
|
186
|
+
export async function closeOwnedSession(file, metadata) {
|
|
187
|
+
if ((await read(file))?.token !== metadata.token) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const version = await probeSession(
|
|
191
|
+
`http://127.0.0.1:${metadata.remoteDebuggingPort}`
|
|
192
|
+
);
|
|
193
|
+
if (version?.webSocketDebuggerUrl === metadata.webSocketDebuggerUrl) {
|
|
194
|
+
await closeRemoteBrowser(version.webSocketDebuggerUrl);
|
|
195
|
+
}
|
|
196
|
+
await fs.rm(file, { force: true });
|
|
197
|
+
}
|
|
198
|
+
async function lifecycle(
|
|
199
|
+
{ session, metadata, reused, adopted },
|
|
200
|
+
options,
|
|
201
|
+
file,
|
|
202
|
+
endpoint
|
|
203
|
+
) {
|
|
204
|
+
const { engine, idleTimeoutMs } = options;
|
|
205
|
+
let page = session.page,
|
|
206
|
+
disconnected = false,
|
|
207
|
+
closed = false,
|
|
208
|
+
activityQueue = Promise.resolve();
|
|
209
|
+
const touch = () => {
|
|
210
|
+
activityQueue = activityQueue
|
|
211
|
+
.then(async () => {
|
|
212
|
+
const release = await acquireLease(`${file}.lock`);
|
|
213
|
+
try {
|
|
214
|
+
const current = await read(file);
|
|
215
|
+
if (current?.token === metadata.token) {
|
|
216
|
+
current.lastActive = Date.now();
|
|
217
|
+
await writeSessionMetadata(file, current);
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
await release();
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
.catch(() => {});
|
|
224
|
+
};
|
|
225
|
+
const bind = (value) => {
|
|
226
|
+
activity.set(value, touch);
|
|
227
|
+
value.on?.('framenavigated', touch);
|
|
228
|
+
};
|
|
229
|
+
const unbind = (value) => {
|
|
230
|
+
activity.delete(value);
|
|
231
|
+
value.off?.('framenavigated', touch);
|
|
232
|
+
};
|
|
233
|
+
await remember(page, engine, file, metadata, idleTimeoutMs);
|
|
234
|
+
if (!reused || adopted) {
|
|
235
|
+
await startDetachedProcess(process.execPath, [
|
|
236
|
+
fileURLToPath(new URL('./session-watchdog.js', import.meta.url)),
|
|
237
|
+
file,
|
|
238
|
+
metadata.token,
|
|
239
|
+
]);
|
|
240
|
+
}
|
|
241
|
+
bind(page);
|
|
242
|
+
const heartbeat = setInterval(
|
|
243
|
+
touch,
|
|
244
|
+
Math.max(20, Math.min(30_000, idleTimeoutMs / 3))
|
|
245
|
+
);
|
|
246
|
+
heartbeat.unref?.();
|
|
247
|
+
const tabSource = engine === 'playwright' ? page.context() : session.browser;
|
|
248
|
+
const tabTasks = new Set();
|
|
249
|
+
let selecting = false;
|
|
250
|
+
const closeNewTab = (value) => {
|
|
251
|
+
const task = Promise.resolve()
|
|
252
|
+
.then(async () => {
|
|
253
|
+
const candidate =
|
|
254
|
+
engine === 'playwright' ? value : await value.page?.();
|
|
255
|
+
if (candidate && candidate !== page && !disconnected && !selecting) {
|
|
256
|
+
await candidate.close();
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
.catch((error) =>
|
|
260
|
+
options.log?.debug?.(() => `session tab close: ${error.message}`)
|
|
261
|
+
);
|
|
262
|
+
tabTasks.add(task);
|
|
263
|
+
void task.finally(() => tabTasks.delete(task));
|
|
264
|
+
};
|
|
265
|
+
const tabEvent = engine === 'playwright' ? 'page' : 'targetcreated';
|
|
266
|
+
if (options.closeNewTabs) {
|
|
267
|
+
tabSource.on?.(tabEvent, closeNewTab);
|
|
268
|
+
}
|
|
269
|
+
const detach = async () => {
|
|
270
|
+
if (disconnected) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
clearInterval(heartbeat);
|
|
274
|
+
tabSource.off?.(tabEvent, closeNewTab);
|
|
275
|
+
await Promise.allSettled([...tabTasks]);
|
|
276
|
+
touch();
|
|
277
|
+
await activityQueue;
|
|
278
|
+
unbind(page);
|
|
279
|
+
disconnected = true;
|
|
280
|
+
try {
|
|
281
|
+
await session.downloads?.dispose();
|
|
282
|
+
} finally {
|
|
283
|
+
await session.detach();
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const close = async () => {
|
|
287
|
+
if (closed) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!disconnected) {
|
|
291
|
+
await detach().catch(() => {});
|
|
292
|
+
}
|
|
293
|
+
const release = await acquireLease(`${file}.lock`);
|
|
294
|
+
try {
|
|
295
|
+
await closeOwnedSession(file, metadata);
|
|
296
|
+
} finally {
|
|
297
|
+
await release();
|
|
298
|
+
}
|
|
299
|
+
closed = true;
|
|
300
|
+
};
|
|
301
|
+
const result = {
|
|
302
|
+
...session,
|
|
303
|
+
detach,
|
|
304
|
+
close,
|
|
305
|
+
touch,
|
|
306
|
+
reused,
|
|
307
|
+
adopted: Boolean(adopted),
|
|
308
|
+
cdpEndpoint: endpoint,
|
|
309
|
+
userDataDir: path.resolve(options.userDataDir),
|
|
310
|
+
remoteDebuggingPort: options.remoteDebuggingPort,
|
|
311
|
+
temporaryProfile: false,
|
|
312
|
+
};
|
|
313
|
+
result.reusePage = async (selection = {}) => {
|
|
314
|
+
selecting = true;
|
|
315
|
+
try {
|
|
316
|
+
if (disconnected) {
|
|
317
|
+
throw new Error('Persistent controller is detached');
|
|
318
|
+
}
|
|
319
|
+
const release = await acquireLease(`${file}.lock`);
|
|
320
|
+
try {
|
|
321
|
+
const current = await read(file);
|
|
322
|
+
if (current?.token !== metadata.token) {
|
|
323
|
+
throw new Error('Persistent session ownership changed');
|
|
324
|
+
}
|
|
325
|
+
const pages =
|
|
326
|
+
engine === 'playwright'
|
|
327
|
+
? page.context().pages()
|
|
328
|
+
: await session.browser.pages();
|
|
329
|
+
const selected =
|
|
330
|
+
(await pickForegroundPage(pages, {
|
|
331
|
+
targetId:
|
|
332
|
+
selection.targetId ??
|
|
333
|
+
(!selection.url && !selection.urlMatchers?.length
|
|
334
|
+
? metadata.targetId
|
|
335
|
+
: undefined),
|
|
336
|
+
fallback: !selection.targetId,
|
|
337
|
+
...selection,
|
|
338
|
+
})) ??
|
|
339
|
+
(engine === 'playwright'
|
|
340
|
+
? await page.context().newPage()
|
|
341
|
+
: await session.browser.newPage());
|
|
342
|
+
unbind(page);
|
|
343
|
+
page = selected;
|
|
344
|
+
bind(page);
|
|
345
|
+
result.page = page;
|
|
346
|
+
await remember(page, engine, file, metadata, idleTimeoutMs);
|
|
347
|
+
} finally {
|
|
348
|
+
await release();
|
|
349
|
+
}
|
|
350
|
+
return page;
|
|
351
|
+
} finally {
|
|
352
|
+
selecting = false;
|
|
353
|
+
if (options.closeNewTabs && !disconnected) {
|
|
354
|
+
const pages =
|
|
355
|
+
engine === 'playwright'
|
|
356
|
+
? page.context().pages()
|
|
357
|
+
: await session.browser.pages();
|
|
358
|
+
await Promise.allSettled(
|
|
359
|
+
pages
|
|
360
|
+
.filter((candidate) => candidate !== page)
|
|
361
|
+
.map((candidate) => candidate.close())
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
export async function closeRemoteBrowser(endpoint) {
|
|
369
|
+
await new Promise((resolve, reject) => {
|
|
370
|
+
const socket = new globalThis.WebSocket(endpoint);
|
|
371
|
+
let acknowledged = false;
|
|
372
|
+
const timer = setTimeout(() => {
|
|
373
|
+
socket.close();
|
|
374
|
+
reject(new Error('Browser.close timed out'));
|
|
375
|
+
}, 3000);
|
|
376
|
+
socket.onopen = () =>
|
|
377
|
+
socket.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
|
|
378
|
+
socket.onclose = () => {
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
resolve();
|
|
381
|
+
};
|
|
382
|
+
socket.onerror = () => {
|
|
383
|
+
// Chrome can close the TCP connection without a WebSocket close frame
|
|
384
|
+
// after acknowledging Browser.close. Its ensuing close event completes
|
|
385
|
+
// shutdown; a pre-acknowledgement connection error remains a failure.
|
|
386
|
+
if (!acknowledged) {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
reject(new Error('Browser.close connection failed'));
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
socket.onmessage = ({ data }) => {
|
|
392
|
+
const response = JSON.parse(data);
|
|
393
|
+
if (response.id !== 1) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (response.error) {
|
|
397
|
+
clearTimeout(timer);
|
|
398
|
+
reject(new Error(response.error.message ?? 'Browser.close failed'));
|
|
399
|
+
socket.close();
|
|
400
|
+
} else {
|
|
401
|
+
acknowledged = true;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
/** Reuse an owned profile/port; detach survives the controller; close terminates it. */
|
|
407
|
+
export async function connectOrLaunch(settings = {}) {
|
|
408
|
+
const options = {
|
|
409
|
+
engine: 'playwright',
|
|
410
|
+
idleTimeoutMs: 30 * 60 * 1000,
|
|
411
|
+
...settings,
|
|
412
|
+
};
|
|
413
|
+
validate(options);
|
|
414
|
+
await fs.mkdir(options.userDataDir, { recursive: true, mode: 0o700 });
|
|
415
|
+
const file = path.join(path.resolve(options.userDataDir), SESSION_METADATA);
|
|
416
|
+
const release = await acquireLease(`${file}.lock`);
|
|
417
|
+
const endpoint = `http://127.0.0.1:${options.remoteDebuggingPort}`;
|
|
418
|
+
let owned;
|
|
419
|
+
try {
|
|
420
|
+
owned = await open(options, file, endpoint);
|
|
421
|
+
return await lifecycle(owned, options, file, endpoint);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (owned) {
|
|
424
|
+
await (
|
|
425
|
+
owned.reused ? owned.session.detach() : owned.session.close()
|
|
426
|
+
).catch(() => {});
|
|
427
|
+
}
|
|
428
|
+
throw error;
|
|
429
|
+
} finally {
|
|
430
|
+
await release();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -106,6 +106,26 @@ export async function configureUserDataDir(
|
|
|
106
106
|
) {
|
|
107
107
|
assertObject(preferences, 'preferences');
|
|
108
108
|
assertObject(localState, 'localState');
|
|
109
|
+
for (const key of [
|
|
110
|
+
'session.restore_on_startup',
|
|
111
|
+
'session.startup_urls',
|
|
112
|
+
'homepage',
|
|
113
|
+
'homepage_is_newtabpage',
|
|
114
|
+
'browser.show_home_button',
|
|
115
|
+
'extensions.settings',
|
|
116
|
+
'default_search_provider_data',
|
|
117
|
+
]) {
|
|
118
|
+
let value = preferences;
|
|
119
|
+
for (const part of key.split('.')) {
|
|
120
|
+
value = value?.[part];
|
|
121
|
+
}
|
|
122
|
+
if (value !== undefined || Object.hasOwn(preferences, key)) {
|
|
123
|
+
process.emitWarning(
|
|
124
|
+
`Chrome protects ${key} in Secure Preferences and may reset this value. Use browser settings or managed policy to set it.`,
|
|
125
|
+
{ code: 'PROTECTED_CHROME_PREFERENCE' }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
109
129
|
if (
|
|
110
130
|
defaultBrowserCheck !== undefined &&
|
|
111
131
|
typeof defaultBrowserCheck !== 'boolean'
|
|
@@ -103,6 +103,7 @@ export function buildRealBrowserArgs({
|
|
|
103
103
|
remoteDebuggingPort,
|
|
104
104
|
headless = false,
|
|
105
105
|
restrictions = [],
|
|
106
|
+
disableFeatures = [],
|
|
106
107
|
args = [],
|
|
107
108
|
extraArgs = [],
|
|
108
109
|
automationParity = true,
|
|
@@ -127,7 +128,7 @@ export function buildRealBrowserArgs({
|
|
|
127
128
|
`--user-data-dir=${userDataDir}`,
|
|
128
129
|
`--remote-debugging-port=${remoteDebuggingPort}`,
|
|
129
130
|
...(headless ? ['--headless=new'] : []),
|
|
130
|
-
...resolveRestrictions(restrictions).args,
|
|
131
|
+
...resolveRestrictions(restrictions, { disableFeatures }).args,
|
|
131
132
|
...customArgs,
|
|
132
133
|
]);
|
|
133
134
|
if (
|
|
@@ -642,6 +643,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
642
643
|
const argOptions = {
|
|
643
644
|
headless,
|
|
644
645
|
restrictions,
|
|
646
|
+
disableFeatures: options.disableFeatures,
|
|
645
647
|
args,
|
|
646
648
|
extraArgs,
|
|
647
649
|
automationParity,
|
|
@@ -700,7 +702,10 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
700
702
|
await removingTemporaryProfileOnError(profile, () =>
|
|
701
703
|
configureUserDataDir(userDataDir, {
|
|
702
704
|
defaultBrowserCheck,
|
|
703
|
-
preferences
|
|
705
|
+
preferences: {
|
|
706
|
+
...resolveRestrictions(restrictions).preferences,
|
|
707
|
+
...preferences,
|
|
708
|
+
},
|
|
704
709
|
localState,
|
|
705
710
|
profileDirectory: attach?.profile ?? 'Default',
|
|
706
711
|
})
|
|
@@ -73,11 +73,14 @@ function expand(names) {
|
|
|
73
73
|
* @param {string[]} [names]
|
|
74
74
|
* @returns {{ids: string[], args: string[], env: Object<string,string>}}
|
|
75
75
|
*/
|
|
76
|
-
export function resolveRestrictions(names = []) {
|
|
76
|
+
export function resolveRestrictions(names = [], options = {}) {
|
|
77
77
|
const ids = expand(names);
|
|
78
78
|
const args = [];
|
|
79
|
-
const disableFeatures = [
|
|
79
|
+
const disableFeatures = [
|
|
80
|
+
...assertStringArray(options.disableFeatures ?? [], 'disableFeatures'),
|
|
81
|
+
];
|
|
80
82
|
const env = {};
|
|
83
|
+
const preferences = {};
|
|
81
84
|
for (const id of ids) {
|
|
82
85
|
const restriction = LAUNCH_RESTRICTIONS.find((entry) => entry.id === id);
|
|
83
86
|
if (!restriction) {
|
|
@@ -91,11 +94,17 @@ export function resolveRestrictions(names = []) {
|
|
|
91
94
|
args.push(...(restriction.args ?? []));
|
|
92
95
|
disableFeatures.push(...(restriction.disableFeatures ?? []));
|
|
93
96
|
Object.assign(env, restriction.env ?? {});
|
|
97
|
+
for (const [key, value] of Object.entries(restriction.preferences ?? {})) {
|
|
98
|
+
preferences[key] =
|
|
99
|
+
value && typeof value === 'object'
|
|
100
|
+
? { ...preferences[key], ...value }
|
|
101
|
+
: value;
|
|
102
|
+
}
|
|
94
103
|
}
|
|
95
104
|
if (disableFeatures.length > 0) {
|
|
96
|
-
args.push(`--disable-features=${disableFeatures.join(',')}`);
|
|
105
|
+
args.push(`--disable-features=${[...new Set(disableFeatures)].join(',')}`);
|
|
97
106
|
}
|
|
98
|
-
return { ids, args, env };
|
|
107
|
+
return { ids, args, env, preferences };
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
const LIST_SWITCHES = [
|