browser-commander 0.11.0 → 0.13.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/CHANGELOG.md +16 -0
- package/README.md +104 -0
- package/examples/pdf-generation.js +7 -4
- package/package.json +1 -1
- package/scripts/run-tests.mjs +19 -1
- package/src/README.md +13 -0
- package/src/bindings.js +8 -0
- package/src/browser/connector.js +163 -0
- package/src/browser/launcher.js +17 -0
- package/src/browser/navigation.js +58 -0
- package/src/browser/real-browser.js +439 -0
- package/src/browser/storage-state.js +133 -0
- package/src/core/navigation-manager.js +57 -24
- package/src/exports.js +4 -0
- package/tests/e2e/playwright.e2e.test.js +19 -0
- package/tests/e2e/puppeteer.e2e.test.js +19 -0
- package/tests/helpers/mocks.js +3 -0
- package/tests/unit/bindings.test.js +4 -0
- package/tests/unit/browser/connector.test.js +129 -0
- package/tests/unit/browser/navigation.test.js +61 -0
- package/tests/unit/browser/real-browser.test.js +165 -0
- package/tests/unit/browser/storage-state.test.js +148 -0
- package/tests/unit/core/navigation-manager.test.js +62 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +73 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +20 -7
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access, mkdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { connectBrowser } from './connector.js';
|
|
8
|
+
|
|
9
|
+
const MANAGED_ARGUMENTS = [
|
|
10
|
+
'--remote-debugging-address',
|
|
11
|
+
'--remote-debugging-port',
|
|
12
|
+
'--user-data-dir',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const CHANNEL_EXECUTABLE_NAMES = {
|
|
16
|
+
brave: ['brave-browser', 'brave-browser-stable', 'brave'],
|
|
17
|
+
chrome: ['google-chrome', 'google-chrome-stable', 'chrome'],
|
|
18
|
+
'chrome-beta': ['google-chrome-beta'],
|
|
19
|
+
'chrome-canary': ['google-chrome-canary'],
|
|
20
|
+
'chrome-dev': ['google-chrome-unstable'],
|
|
21
|
+
chromium: ['chromium', 'chromium-browser'],
|
|
22
|
+
msedge: ['microsoft-edge', 'microsoft-edge-stable', 'msedge'],
|
|
23
|
+
'msedge-beta': ['microsoft-edge-beta'],
|
|
24
|
+
'msedge-canary': ['microsoft-edge-canary'],
|
|
25
|
+
'msedge-dev': ['microsoft-edge-dev'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function platformPath(platform) {
|
|
29
|
+
return platform === 'win32' ? path.win32 : path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultUserDataDir(channel, homeDir = os.homedir()) {
|
|
33
|
+
const directoryName = channel.replace(/[^a-z0-9_.-]/gi, '-');
|
|
34
|
+
return path.join(
|
|
35
|
+
homeDir,
|
|
36
|
+
'.browser-commander',
|
|
37
|
+
'real-browser',
|
|
38
|
+
directoryName
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function knownDefaultUserDataDirs({
|
|
43
|
+
platform = process.platform,
|
|
44
|
+
homeDir = os.homedir(),
|
|
45
|
+
environment = process.env,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const pathApi = platformPath(platform);
|
|
48
|
+
if (platform === 'darwin') {
|
|
49
|
+
const applicationSupport = pathApi.join(
|
|
50
|
+
homeDir,
|
|
51
|
+
'Library',
|
|
52
|
+
'Application Support'
|
|
53
|
+
);
|
|
54
|
+
return [
|
|
55
|
+
pathApi.join(applicationSupport, 'Google', 'Chrome'),
|
|
56
|
+
pathApi.join(applicationSupport, 'Google', 'Chrome Beta'),
|
|
57
|
+
pathApi.join(applicationSupport, 'Google', 'Chrome Canary'),
|
|
58
|
+
pathApi.join(applicationSupport, 'Google', 'Chrome Dev'),
|
|
59
|
+
pathApi.join(applicationSupport, 'Chromium'),
|
|
60
|
+
pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser'),
|
|
61
|
+
pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser-Beta'),
|
|
62
|
+
pathApi.join(
|
|
63
|
+
applicationSupport,
|
|
64
|
+
'BraveSoftware',
|
|
65
|
+
'Brave-Browser-Nightly'
|
|
66
|
+
),
|
|
67
|
+
pathApi.join(applicationSupport, 'Microsoft Edge'),
|
|
68
|
+
pathApi.join(applicationSupport, 'Microsoft Edge Beta'),
|
|
69
|
+
pathApi.join(applicationSupport, 'Microsoft Edge Canary'),
|
|
70
|
+
pathApi.join(applicationSupport, 'Microsoft Edge Dev'),
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
if (platform === 'win32') {
|
|
74
|
+
const localAppData =
|
|
75
|
+
environment.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local');
|
|
76
|
+
return [
|
|
77
|
+
pathApi.join(localAppData, 'Google', 'Chrome', 'User Data'),
|
|
78
|
+
pathApi.join(localAppData, 'Google', 'Chrome Beta', 'User Data'),
|
|
79
|
+
pathApi.join(localAppData, 'Google', 'Chrome Dev', 'User Data'),
|
|
80
|
+
pathApi.join(localAppData, 'Google', 'Chrome SxS', 'User Data'),
|
|
81
|
+
pathApi.join(localAppData, 'Chromium', 'User Data'),
|
|
82
|
+
pathApi.join(localAppData, 'BraveSoftware', 'Brave-Browser', 'User Data'),
|
|
83
|
+
pathApi.join(
|
|
84
|
+
localAppData,
|
|
85
|
+
'BraveSoftware',
|
|
86
|
+
'Brave-Browser-Beta',
|
|
87
|
+
'User Data'
|
|
88
|
+
),
|
|
89
|
+
pathApi.join(
|
|
90
|
+
localAppData,
|
|
91
|
+
'BraveSoftware',
|
|
92
|
+
'Brave-Browser-Nightly',
|
|
93
|
+
'User Data'
|
|
94
|
+
),
|
|
95
|
+
pathApi.join(localAppData, 'Microsoft', 'Edge', 'User Data'),
|
|
96
|
+
pathApi.join(localAppData, 'Microsoft', 'Edge Beta', 'User Data'),
|
|
97
|
+
pathApi.join(localAppData, 'Microsoft', 'Edge Dev', 'User Data'),
|
|
98
|
+
pathApi.join(localAppData, 'Microsoft', 'Edge SxS', 'User Data'),
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
return [
|
|
102
|
+
pathApi.join(homeDir, '.config', 'google-chrome'),
|
|
103
|
+
pathApi.join(homeDir, '.config', 'google-chrome-beta'),
|
|
104
|
+
pathApi.join(homeDir, '.config', 'google-chrome-unstable'),
|
|
105
|
+
pathApi.join(homeDir, '.config', 'chromium'),
|
|
106
|
+
pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser'),
|
|
107
|
+
pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Beta'),
|
|
108
|
+
pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Nightly'),
|
|
109
|
+
pathApi.join(homeDir, '.config', 'microsoft-edge'),
|
|
110
|
+
pathApi.join(homeDir, '.config', 'microsoft-edge-beta'),
|
|
111
|
+
pathApi.join(homeDir, '.config', 'microsoft-edge-dev'),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Ensure Chrome is not asked to expose the user's default profile over CDP. */
|
|
116
|
+
export function assertDedicatedUserDataDir(userDataDir, platformOptions = {}) {
|
|
117
|
+
if (!userDataDir) {
|
|
118
|
+
throw new Error('launchAndConnectRealBrowser requires a userDataDir');
|
|
119
|
+
}
|
|
120
|
+
const platform = platformOptions.platform ?? process.platform;
|
|
121
|
+
const pathApi = platformPath(platform);
|
|
122
|
+
const normalize = (value) => {
|
|
123
|
+
const resolved = pathApi.resolve(value).replace(/[\\/]+$/, '');
|
|
124
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
125
|
+
};
|
|
126
|
+
const requested = normalize(userDataDir);
|
|
127
|
+
const isDefault = knownDefaultUserDataDirs(platformOptions).some(
|
|
128
|
+
(directory) => normalize(directory) === requested
|
|
129
|
+
);
|
|
130
|
+
if (isDefault) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'launchAndConnectRealBrowser requires a dedicated userDataDir, not a browser default profile'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function browserInstallCandidates({
|
|
138
|
+
channel,
|
|
139
|
+
platform = process.platform,
|
|
140
|
+
environment = process.env,
|
|
141
|
+
}) {
|
|
142
|
+
const candidates = [];
|
|
143
|
+
const names = CHANNEL_EXECUTABLE_NAMES[channel];
|
|
144
|
+
if (!names) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Unknown browser channel: ${channel}. Expected one of ${Object.keys(CHANNEL_EXECUTABLE_NAMES).join(', ')}`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (platform === 'darwin') {
|
|
151
|
+
const applications = {
|
|
152
|
+
brave: 'Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
153
|
+
chrome: 'Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
154
|
+
'chrome-beta': 'Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
|
155
|
+
'chrome-canary':
|
|
156
|
+
'Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
157
|
+
'chrome-dev': 'Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev',
|
|
158
|
+
chromium: 'Chromium.app/Contents/MacOS/Chromium',
|
|
159
|
+
msedge: 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
160
|
+
'msedge-beta':
|
|
161
|
+
'Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta',
|
|
162
|
+
'msedge-canary':
|
|
163
|
+
'Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary',
|
|
164
|
+
'msedge-dev': 'Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev',
|
|
165
|
+
};
|
|
166
|
+
candidates.push(path.join('/Applications', applications[channel]));
|
|
167
|
+
} else if (platform === 'win32') {
|
|
168
|
+
const roots = [
|
|
169
|
+
environment.PROGRAMFILES,
|
|
170
|
+
environment['PROGRAMFILES(X86)'],
|
|
171
|
+
environment.LOCALAPPDATA,
|
|
172
|
+
].filter(Boolean);
|
|
173
|
+
const relativePaths = {
|
|
174
|
+
brave: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'],
|
|
175
|
+
chrome: ['Google', 'Chrome', 'Application', 'chrome.exe'],
|
|
176
|
+
'chrome-beta': ['Google', 'Chrome Beta', 'Application', 'chrome.exe'],
|
|
177
|
+
'chrome-canary': ['Google', 'Chrome SxS', 'Application', 'chrome.exe'],
|
|
178
|
+
'chrome-dev': ['Google', 'Chrome Dev', 'Application', 'chrome.exe'],
|
|
179
|
+
chromium: ['Chromium', 'Application', 'chrome.exe'],
|
|
180
|
+
msedge: ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
|
|
181
|
+
'msedge-beta': ['Microsoft', 'Edge Beta', 'Application', 'msedge.exe'],
|
|
182
|
+
'msedge-canary': ['Microsoft', 'Edge SxS', 'Application', 'msedge.exe'],
|
|
183
|
+
'msedge-dev': ['Microsoft', 'Edge Dev', 'Application', 'msedge.exe'],
|
|
184
|
+
};
|
|
185
|
+
for (const root of roots) {
|
|
186
|
+
candidates.push(path.win32.join(root, ...relativePaths[channel]));
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
for (const name of names) {
|
|
190
|
+
candidates.push(`/usr/bin/${name}`, `/usr/local/bin/${name}`);
|
|
191
|
+
}
|
|
192
|
+
if (channel === 'chrome') {
|
|
193
|
+
candidates.push('/opt/google/chrome/google-chrome');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const pathApi = platformPath(platform);
|
|
198
|
+
const delimiter = platform === 'win32' ? ';' : path.delimiter;
|
|
199
|
+
for (const directory of (environment.PATH ?? '').split(delimiter)) {
|
|
200
|
+
if (!directory) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
for (const name of names) {
|
|
204
|
+
candidates.push(
|
|
205
|
+
pathApi.join(directory, platform === 'win32' ? `${name}.exe` : name)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return [...new Set(candidates)];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Resolve a genuine installed Chrome-family browser executable. */
|
|
213
|
+
export async function resolveSystemBrowserExecutable({
|
|
214
|
+
channel = 'chrome',
|
|
215
|
+
executablePath,
|
|
216
|
+
platform = process.platform,
|
|
217
|
+
environment = process.env,
|
|
218
|
+
} = {}) {
|
|
219
|
+
const candidates = executablePath
|
|
220
|
+
? [path.resolve(executablePath)]
|
|
221
|
+
: browserInstallCandidates({ channel, platform, environment });
|
|
222
|
+
for (const candidate of candidates) {
|
|
223
|
+
try {
|
|
224
|
+
await access(candidate, constants.X_OK);
|
|
225
|
+
return candidate;
|
|
226
|
+
} catch {
|
|
227
|
+
// Continue through known locations and PATH entries.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
throw new Error(
|
|
231
|
+
executablePath
|
|
232
|
+
? `Browser executable is not accessible: ${executablePath}`
|
|
233
|
+
: `Could not find an installed ${channel} browser; provide executablePath`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Build the protected command line used for a real browser CDP process. */
|
|
238
|
+
export function buildRealBrowserArgs({
|
|
239
|
+
userDataDir,
|
|
240
|
+
remoteDebuggingPort = 0,
|
|
241
|
+
headless = false,
|
|
242
|
+
args = [],
|
|
243
|
+
}) {
|
|
244
|
+
if (
|
|
245
|
+
!Number.isInteger(remoteDebuggingPort) ||
|
|
246
|
+
remoteDebuggingPort < 0 ||
|
|
247
|
+
remoteDebuggingPort > 65_535
|
|
248
|
+
) {
|
|
249
|
+
throw new RangeError(
|
|
250
|
+
'remoteDebuggingPort must be an integer from 0 to 65535'
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const conflictingArgument = args.find((argument) =>
|
|
254
|
+
MANAGED_ARGUMENTS.some(
|
|
255
|
+
(managed) => argument === managed || argument.startsWith(`${managed}=`)
|
|
256
|
+
)
|
|
257
|
+
);
|
|
258
|
+
if (conflictingArgument) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`${conflictingArgument} is managed by launchAndConnectRealBrowser`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return [
|
|
265
|
+
'--remote-debugging-address=127.0.0.1',
|
|
266
|
+
`--remote-debugging-port=${remoteDebuggingPort}`,
|
|
267
|
+
`--user-data-dir=${userDataDir}`,
|
|
268
|
+
'--no-first-run',
|
|
269
|
+
'--no-default-browser-check',
|
|
270
|
+
...(headless ? ['--headless=new'] : []),
|
|
271
|
+
...args,
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function fetchCdpVersion(endpoint, fetchImplementation, timeout) {
|
|
276
|
+
const controller = new AbortController();
|
|
277
|
+
const timer = setTimeout(() => controller.abort(), Math.min(timeout, 500));
|
|
278
|
+
try {
|
|
279
|
+
const response = await fetchImplementation(`${endpoint}/json/version`, {
|
|
280
|
+
signal: controller.signal,
|
|
281
|
+
});
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
const version = await response.json();
|
|
286
|
+
return Boolean(version.webSocketDebuggerUrl);
|
|
287
|
+
} finally {
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Wait until Chrome publishes a usable DevTools endpoint. */
|
|
293
|
+
export async function waitForCdpEndpoint({
|
|
294
|
+
remoteDebuggingPort,
|
|
295
|
+
userDataDir,
|
|
296
|
+
browserProcess,
|
|
297
|
+
timeout = 30_000,
|
|
298
|
+
fetchImplementation = globalThis.fetch,
|
|
299
|
+
}) {
|
|
300
|
+
const deadline = Date.now() + timeout;
|
|
301
|
+
while (Date.now() < deadline) {
|
|
302
|
+
if (browserProcess.exitCode !== null) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Browser exited before its DevTools endpoint was ready (exit ${browserProcess.exitCode})`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let port = remoteDebuggingPort;
|
|
309
|
+
if (port === 0) {
|
|
310
|
+
try {
|
|
311
|
+
const activePort = await readFile(
|
|
312
|
+
path.join(userDataDir, 'DevToolsActivePort'),
|
|
313
|
+
'utf8'
|
|
314
|
+
);
|
|
315
|
+
port = Number.parseInt(activePort.split(/\r?\n/, 1)[0], 10);
|
|
316
|
+
} catch {
|
|
317
|
+
port = 0;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (port > 0) {
|
|
322
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
323
|
+
try {
|
|
324
|
+
if (
|
|
325
|
+
await fetchCdpVersion(
|
|
326
|
+
endpoint,
|
|
327
|
+
fetchImplementation,
|
|
328
|
+
Math.max(1, deadline - Date.now())
|
|
329
|
+
)
|
|
330
|
+
) {
|
|
331
|
+
return endpoint;
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// Chrome may have allocated the port before /json/version is ready.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
338
|
+
}
|
|
339
|
+
throw new Error(
|
|
340
|
+
`Timed out after ${timeout}ms waiting for the DevTools endpoint`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Spawn a genuine installed Chrome-family browser with an isolated profile,
|
|
346
|
+
* wait for its loopback CDP endpoint, and attach through {@link connectBrowser}.
|
|
347
|
+
*
|
|
348
|
+
* @param {Object} options - Launch and connection options
|
|
349
|
+
* @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine
|
|
350
|
+
* @param {string} [options.channel='chrome'] - Installed Chrome-family channel
|
|
351
|
+
* @param {string} [options.executablePath] - Explicit installed browser executable
|
|
352
|
+
* @param {string} [options.userDataDir] - Dedicated non-default browser profile
|
|
353
|
+
* @param {number} [options.remoteDebuggingPort=0] - Loopback CDP port; zero lets Chrome choose
|
|
354
|
+
* @param {boolean} [options.headless=false] - Run the installed browser headlessly
|
|
355
|
+
* @param {string[]} [options.args] - Additional browser arguments
|
|
356
|
+
* @param {number} [options.startupTimeout=30000] - CDP readiness timeout in milliseconds
|
|
357
|
+
* @param {Object[]} [options.seedCookies] - Cookies to seed after connecting
|
|
358
|
+
* @param {boolean} [options.verbose=false] - Show browser and connection logs
|
|
359
|
+
* @returns {Promise<{browser: Object, page: Object, browserProcess: Object, cdpEndpoint: string, executablePath: string, userDataDir: string}>} Connected handles and process metadata
|
|
360
|
+
*/
|
|
361
|
+
export async function launchAndConnectRealBrowser(options = {}) {
|
|
362
|
+
return await launchAndConnectRealBrowserWithDependencies(options);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Dependency-injected implementation used by the public helper and tests. */
|
|
366
|
+
export async function launchAndConnectRealBrowserWithDependencies(
|
|
367
|
+
options = {},
|
|
368
|
+
dependencies = {}
|
|
369
|
+
) {
|
|
370
|
+
const {
|
|
371
|
+
engine = 'playwright',
|
|
372
|
+
channel = 'chrome',
|
|
373
|
+
executablePath: requestedExecutablePath,
|
|
374
|
+
userDataDir = defaultUserDataDir(channel),
|
|
375
|
+
remoteDebuggingPort = 0,
|
|
376
|
+
headless = false,
|
|
377
|
+
args = [],
|
|
378
|
+
startupTimeout = 30_000,
|
|
379
|
+
verbose = false,
|
|
380
|
+
cdpEndpoint,
|
|
381
|
+
wsEndpoint,
|
|
382
|
+
...connectionOptions
|
|
383
|
+
} = options;
|
|
384
|
+
if (cdpEndpoint || wsEndpoint) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
'launchAndConnectRealBrowser creates its own endpoint; use connectBrowser to attach to an existing endpoint'
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
assertDedicatedUserDataDir(userDataDir);
|
|
391
|
+
await mkdir(userDataDir, { recursive: true });
|
|
392
|
+
const resolveExecutable =
|
|
393
|
+
dependencies.resolveExecutable ?? resolveSystemBrowserExecutable;
|
|
394
|
+
const resolvedExecutablePath = await resolveExecutable({
|
|
395
|
+
channel,
|
|
396
|
+
executablePath: requestedExecutablePath,
|
|
397
|
+
});
|
|
398
|
+
const browserArgs = buildRealBrowserArgs({
|
|
399
|
+
userDataDir,
|
|
400
|
+
remoteDebuggingPort,
|
|
401
|
+
headless,
|
|
402
|
+
args,
|
|
403
|
+
});
|
|
404
|
+
const spawnBrowser = dependencies.spawnBrowser ?? spawn;
|
|
405
|
+
const browserProcess = spawnBrowser(resolvedExecutablePath, browserArgs, {
|
|
406
|
+
stdio: verbose ? 'inherit' : 'ignore',
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const waitForEndpoint = dependencies.waitForEndpoint ?? waitForCdpEndpoint;
|
|
411
|
+
const resolvedCdpEndpoint = await waitForEndpoint({
|
|
412
|
+
remoteDebuggingPort,
|
|
413
|
+
userDataDir,
|
|
414
|
+
browserProcess,
|
|
415
|
+
timeout: startupTimeout,
|
|
416
|
+
});
|
|
417
|
+
const connect = dependencies.connect ?? connectBrowser;
|
|
418
|
+
const connection = await connect({
|
|
419
|
+
engine,
|
|
420
|
+
cdpEndpoint: resolvedCdpEndpoint,
|
|
421
|
+
...connectionOptions,
|
|
422
|
+
verbose,
|
|
423
|
+
});
|
|
424
|
+
return {
|
|
425
|
+
...connection,
|
|
426
|
+
browserProcess,
|
|
427
|
+
cdpEndpoint: resolvedCdpEndpoint,
|
|
428
|
+
executablePath: resolvedExecutablePath,
|
|
429
|
+
userDataDir,
|
|
430
|
+
};
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (browserProcess.exitCode === null) {
|
|
433
|
+
browserProcess.kill();
|
|
434
|
+
}
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export { defaultUserDataDir, knownDefaultUserDataDirs };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Load a Playwright-compatible storage state from a JSON path or object.
|
|
5
|
+
* @param {string|Object|undefined} storageState - State path or parsed state
|
|
6
|
+
* @returns {Promise<Object|undefined>} Parsed state
|
|
7
|
+
*/
|
|
8
|
+
export async function loadStorageState(storageState) {
|
|
9
|
+
if (storageState === undefined) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
if (typeof storageState === 'string') {
|
|
13
|
+
return JSON.parse(await readFile(storageState, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
if (storageState && typeof storageState === 'object') {
|
|
16
|
+
return storageState;
|
|
17
|
+
}
|
|
18
|
+
throw new TypeError('storageState must be a file path or an object');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function restoreOriginLocalStorage(origins) {
|
|
22
|
+
const originState = origins.find(
|
|
23
|
+
({ origin }) => origin === globalThis.location.origin
|
|
24
|
+
);
|
|
25
|
+
if (!originState) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const { name, value } of originState.localStorage ?? []) {
|
|
29
|
+
globalThis.localStorage.setItem(name, value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply Playwright-compatible storage state to a Puppeteer page.
|
|
35
|
+
* @param {Object} options - Restore options
|
|
36
|
+
* @param {Object} options.page - Puppeteer page
|
|
37
|
+
* @param {Object|undefined} options.storageState - Parsed storage state
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
export async function restorePuppeteerStorageState({ page, storageState }) {
|
|
41
|
+
if (!storageState) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const cookies = storageState.cookies ?? [];
|
|
46
|
+
if (cookies.length > 0) {
|
|
47
|
+
await page.setCookie(...cookies);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const origins = storageState.origins ?? [];
|
|
51
|
+
if (origins.length > 0) {
|
|
52
|
+
await page.evaluateOnNewDocument(restoreOriginLocalStorage, origins);
|
|
53
|
+
await page.evaluate(restoreOriginLocalStorage, origins);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Apply storage state to a Playwright persistent browser context.
|
|
59
|
+
* @param {Object} options - Restore options
|
|
60
|
+
* @param {Object} options.context - Playwright browser context
|
|
61
|
+
* @param {Object|undefined} options.storageState - Parsed storage state
|
|
62
|
+
* @returns {Promise<void>}
|
|
63
|
+
*/
|
|
64
|
+
export async function restorePlaywrightStorageState({ context, storageState }) {
|
|
65
|
+
if (!storageState) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const cookies = storageState.cookies ?? [];
|
|
70
|
+
if (cookies.length > 0) {
|
|
71
|
+
await context.addCookies(cookies);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const origins = storageState.origins ?? [];
|
|
75
|
+
if (origins.length > 0) {
|
|
76
|
+
await context.addInitScript(restoreOriginLocalStorage, origins);
|
|
77
|
+
await Promise.all(
|
|
78
|
+
context
|
|
79
|
+
.pages()
|
|
80
|
+
.map((page) => page.evaluate(restoreOriginLocalStorage, origins))
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readCurrentLocalStorage() {
|
|
86
|
+
return Array.from({ length: globalThis.localStorage.length }, (_, index) => {
|
|
87
|
+
const name = globalThis.localStorage.key(index);
|
|
88
|
+
return { name, value: globalThis.localStorage.getItem(name) };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function collectPuppeteerStorageState(page) {
|
|
93
|
+
const cookies = await page.cookies();
|
|
94
|
+
const pageUrl = page.url();
|
|
95
|
+
const origin = new URL(pageUrl).origin;
|
|
96
|
+
const origins = [];
|
|
97
|
+
|
|
98
|
+
if (origin !== 'null') {
|
|
99
|
+
origins.push({
|
|
100
|
+
origin,
|
|
101
|
+
localStorage: await page.evaluate(readCurrentLocalStorage),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { cookies, origins };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Save a page's cookies and localStorage in Playwright's storage-state format.
|
|
110
|
+
* @param {Object} page - Playwright or Puppeteer page
|
|
111
|
+
* @param {string} [filePath] - Optional JSON output path
|
|
112
|
+
* @returns {Promise<Object>} Saved storage state
|
|
113
|
+
*/
|
|
114
|
+
export async function saveStorageState(page, filePath) {
|
|
115
|
+
const context = typeof page.context === 'function' ? page.context() : null;
|
|
116
|
+
if (typeof context?.storageState === 'function') {
|
|
117
|
+
return context.storageState(filePath ? { path: filePath } : undefined);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
typeof page.cookies !== 'function' ||
|
|
122
|
+
typeof page.evaluate !== 'function' ||
|
|
123
|
+
typeof page.url !== 'function'
|
|
124
|
+
) {
|
|
125
|
+
throw new TypeError('page must be a Playwright or Puppeteer page');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const storageState = await collectPuppeteerStorageState(page);
|
|
129
|
+
if (filePath) {
|
|
130
|
+
await writeFile(filePath, `${JSON.stringify(storageState, null, 2)}\n`);
|
|
131
|
+
}
|
|
132
|
+
return storageState;
|
|
133
|
+
}
|
|
@@ -11,6 +11,47 @@
|
|
|
11
11
|
import { isNavigationError } from './navigation-safety.js';
|
|
12
12
|
import { TIMING } from './constants.js';
|
|
13
13
|
|
|
14
|
+
async function setManagedContent(options = {}) {
|
|
15
|
+
const {
|
|
16
|
+
page,
|
|
17
|
+
log,
|
|
18
|
+
html,
|
|
19
|
+
waitUntil = 'load',
|
|
20
|
+
timeout = 60000,
|
|
21
|
+
currentUrl,
|
|
22
|
+
triggerNavigationStart,
|
|
23
|
+
updateCurrentUrl,
|
|
24
|
+
waitForPageReady,
|
|
25
|
+
completeNavigation,
|
|
26
|
+
} = options;
|
|
27
|
+
|
|
28
|
+
if (typeof html !== 'string') {
|
|
29
|
+
throw new Error('html is required in options');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
log.debug(() => '🚀 Loading in-memory HTML content');
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Treat document replacement as a controlled navigation so active page
|
|
36
|
+
// triggers stop before the old document is discarded.
|
|
37
|
+
await triggerNavigationStart({ url: currentUrl, isExternal: false });
|
|
38
|
+
await page.setContent(html, { waitUntil, timeout });
|
|
39
|
+
|
|
40
|
+
// setContent normally preserves the URL, but inline scripts can navigate.
|
|
41
|
+
updateCurrentUrl();
|
|
42
|
+
await waitForPageReady({ timeout, reason: 'after setContent' });
|
|
43
|
+
return true;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// Do not leave the manager in its loading state when setContent fails.
|
|
46
|
+
completeNavigation();
|
|
47
|
+
if (isNavigationError(error)) {
|
|
48
|
+
log.debug(() => '⚠️ Content loading was interrupted, recovering...');
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
14
55
|
/**
|
|
15
56
|
* Create a NavigationManager instance for a page
|
|
16
57
|
* @param {Object} options - Configuration options
|
|
@@ -341,6 +382,18 @@ export function createNavigationManager(options = {}) {
|
|
|
341
382
|
}
|
|
342
383
|
}
|
|
343
384
|
|
|
385
|
+
const setContent = (opts = {}) =>
|
|
386
|
+
setManagedContent({
|
|
387
|
+
page,
|
|
388
|
+
log,
|
|
389
|
+
...opts,
|
|
390
|
+
currentUrl,
|
|
391
|
+
triggerNavigationStart,
|
|
392
|
+
updateCurrentUrl: () => (currentUrl = page.url()),
|
|
393
|
+
waitForPageReady,
|
|
394
|
+
completeNavigation,
|
|
395
|
+
});
|
|
396
|
+
|
|
344
397
|
/**
|
|
345
398
|
* Wait for any pending navigation to complete
|
|
346
399
|
* @param {Object} options - Configuration options
|
|
@@ -373,27 +426,6 @@ export function createNavigationManager(options = {}) {
|
|
|
373
426
|
return await navigationPromise;
|
|
374
427
|
}
|
|
375
428
|
|
|
376
|
-
/**
|
|
377
|
-
* Check if we're currently navigating
|
|
378
|
-
*/
|
|
379
|
-
function isCurrentlyNavigating() {
|
|
380
|
-
return isNavigating;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Get current URL
|
|
385
|
-
*/
|
|
386
|
-
function getCurrentUrl() {
|
|
387
|
-
return currentUrl;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Get current session ID
|
|
392
|
-
*/
|
|
393
|
-
function getSessionId() {
|
|
394
|
-
return sessionId;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
429
|
/**
|
|
398
430
|
* Get the current abort signal
|
|
399
431
|
* Use this to check if operations should be aborted due to navigation
|
|
@@ -476,13 +508,14 @@ export function createNavigationManager(options = {}) {
|
|
|
476
508
|
return {
|
|
477
509
|
// Navigation
|
|
478
510
|
navigate,
|
|
511
|
+
setContent,
|
|
479
512
|
waitForNavigation,
|
|
480
513
|
waitForPageReady,
|
|
481
514
|
|
|
482
515
|
// State
|
|
483
|
-
isNavigating:
|
|
484
|
-
getCurrentUrl,
|
|
485
|
-
getSessionId,
|
|
516
|
+
isNavigating: () => isNavigating,
|
|
517
|
+
getCurrentUrl: () => currentUrl,
|
|
518
|
+
getSessionId: () => sessionId,
|
|
486
519
|
|
|
487
520
|
// Abort handling - use these to stop operations when navigation occurs
|
|
488
521
|
getAbortSignal,
|
package/src/exports.js
CHANGED
|
@@ -42,11 +42,15 @@ export {
|
|
|
42
42
|
} from './core/page-trigger-manager.js';
|
|
43
43
|
|
|
44
44
|
// Re-export browser management
|
|
45
|
+
export { connectBrowser } from './browser/connector.js';
|
|
46
|
+
export { launchAndConnectRealBrowser } from './browser/real-browser.js';
|
|
45
47
|
export { launchBrowser } from './browser/launcher.js';
|
|
48
|
+
export { saveStorageState } from './browser/storage-state.js';
|
|
46
49
|
export { emulateMedia } from './browser/media.js';
|
|
47
50
|
export {
|
|
48
51
|
waitForUrlStabilization,
|
|
49
52
|
goto,
|
|
53
|
+
setContent,
|
|
50
54
|
waitForNavigation,
|
|
51
55
|
waitForPageReady,
|
|
52
56
|
waitAfterAction,
|