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.
@@ -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: isCurrentlyNavigating,
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,