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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 46d0049: Add CDP attachment through `connectBrowser()` for Playwright and Puppeteer, plus `launchAndConnectRealBrowser()` for starting an installed Chrome-family browser with a dedicated automation profile.
8
+
9
+ ## 0.12.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Add `commander.setContent()` for safely loading in-memory HTML through the managed navigation lifecycle.
14
+
15
+ Add portable cookie and localStorage restoration through `launchBrowser({ storageState })` and the `saveStorageState()` helper for Playwright and Puppeteer.
16
+
17
+ Avoid intermittent Node 24 test-runner transport failures on macOS by running test files without child-process isolation.
18
+
3
19
  ## 0.11.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -82,6 +82,12 @@ commander.pageTrigger({
82
82
  // 4. Navigate - action auto-starts when page is ready
83
83
  await commander.goto({ url: 'https://example.com' });
84
84
 
85
+ // Or safely replace the document with in-memory HTML
86
+ await commander.setContent({
87
+ html: '<h1>Hi</h1>',
88
+ waitUntil: 'networkidle',
89
+ });
90
+
85
91
  // 5. Cleanup
86
92
  await commander.destroy();
87
93
  await browser.close();
@@ -100,6 +106,57 @@ const { browser, page } = await launchBrowser({
100
106
  });
101
107
  ```
102
108
 
109
+ Attach to a Chrome-family browser that is already listening for CDP connections:
110
+
111
+ ```javascript
112
+ import { connectBrowser, makeBrowserCommander } from 'browser-commander';
113
+
114
+ const { browser, page } = await connectBrowser({
115
+ engine: 'playwright', // or 'puppeteer'
116
+ cdpEndpoint: 'http://127.0.0.1:9222',
117
+ });
118
+ const commander = makeBrowserCommander({ page });
119
+ ```
120
+
121
+ `launchAndConnectRealBrowser()` can find and start a genuine installed Chrome,
122
+ Edge, Brave, or Chromium with a loopback CDP endpoint and then attach to it. It
123
+ always uses a dedicated profile; Chrome 136 and newer do not honor remote
124
+ debugging switches for the default profile. See the
125
+ [Chrome remote-debugging security change](https://developer.chrome.com/blog/remote-debugging-port).
126
+
127
+ ```javascript
128
+ import { launchAndConnectRealBrowser } from 'browser-commander';
129
+
130
+ const connection = await launchAndConnectRealBrowser({
131
+ engine: 'puppeteer',
132
+ channel: 'chrome',
133
+ userDataDir: '/tmp/my-automation-profile',
134
+ seedCookies: [
135
+ { name: 'session', value: 'saved', url: 'https://example.com' },
136
+ ],
137
+ });
138
+ await connection.browser.close();
139
+ ```
140
+
141
+ Cookie seeding copies only cookies you explicitly provide; the helper does not
142
+ read, decrypt, or expose cookies from a browser's default profile.
143
+
144
+ Reuse a saved authenticated session by passing Playwright-compatible storage
145
+ state as a JSON file path or object. Cookies and localStorage are restored for
146
+ both engines:
147
+
148
+ ```javascript
149
+ import { launchBrowser, saveStorageState } from 'browser-commander';
150
+
151
+ const { browser, page } = await launchBrowser({
152
+ engine: 'playwright',
153
+ storageState: './gmail-state.json',
154
+ });
155
+
156
+ // Save the current session for a later launch.
157
+ await saveStorageState(page, './gmail-state.json');
158
+ ```
159
+
103
160
  ## Browser Commander Tests
104
161
 
105
162
  `browser-commander/tests` adds browser fixtures and scheduling helpers on top of
@@ -271,11 +328,58 @@ const { browser, page } = await launchBrowser({
271
328
  slowMo: 150, // Slow down operations (ms)
272
329
  verbose: false, // Enable debug logging
273
330
  args: ['--no-sandbox', '--disable-setuid-sandbox'], // Custom Chrome args to append
331
+ storageState: './session-state.json', // Saved cookies and localStorage, as a path or object
274
332
  });
275
333
  ```
276
334
 
277
335
  The `args` option allows passing custom Chrome arguments, which is useful for headless server environments (Docker, CI/CD) that require flags like `--no-sandbox`.
278
336
 
337
+ The `storageState` option accepts a Playwright-compatible JSON path or object.
338
+ Each engine restores its cookies and origin-specific localStorage before
339
+ navigation, including when Playwright uses a persistent context.
340
+
341
+ ### connectBrowser(options)
342
+
343
+ Connect to an existing Chrome-family browser over an HTTP or WebSocket CDP
344
+ endpoint. Exactly one of `cdpEndpoint` and `wsEndpoint` is required. The raw
345
+ `browser` and `page` work with both the underlying engine API and
346
+ `makeBrowserCommander({ page })`.
347
+
348
+ ```javascript
349
+ const { browser, page } = await connectBrowser({
350
+ engine: 'playwright',
351
+ wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/<id>',
352
+ timeout: 30_000,
353
+ seedCookies: [
354
+ { name: 'session', value: 'saved', url: 'https://example.com' },
355
+ ],
356
+ });
357
+ ```
358
+
359
+ Playwright accepts `timeout` and Puppeteer accepts `protocolTimeout`.
360
+ `storageState` can also seed Playwright-compatible cookies and localStorage.
361
+
362
+ ### launchAndConnectRealBrowser(options)
363
+
364
+ Start an installed browser and connect through `connectBrowser()`. Use
365
+ `channel` (`chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`,
366
+ `msedge-beta`, `msedge-dev`, `msedge-canary`, `brave`, or `chromium`) or an
367
+ explicit `executablePath`. The helper defaults to
368
+ a managed directory under `~/.browser-commander/real-browser/`, rejects known
369
+ default browser-profile paths, protects its remote-debugging arguments, and
370
+ returns the spawned `browserProcess`, resolved `cdpEndpoint`, executable path,
371
+ and profile path alongside `{ browser, page }`.
372
+
373
+ ### saveStorageState(page, filePath)
374
+
375
+ Save the current cookies and localStorage in Playwright's portable storage
376
+ state format. The helper supports pages from either engine and also returns the
377
+ saved object:
378
+
379
+ ```javascript
380
+ const state = await saveStorageState(page, './session-state.json');
381
+ ```
382
+
279
383
  The `colorScheme` option allows setting the initial color scheme (`'light'`, `'dark'`, or `'no-preference'`) at launch time for screenshot services and testing tools:
280
384
 
281
385
  ```javascript
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Example: PDF generation via browser-commander
3
3
  *
4
- * This example shows how to use the unified page.pdf() method
5
- * instead of the old workaround: `const rawPage = page._page || page`.
4
+ * This example shows how to load in-memory HTML and generate a PDF through the
5
+ * unified commander API.
6
6
  *
7
7
  * Supports both Playwright and Puppeteer engines.
8
8
  *
@@ -45,8 +45,11 @@ async function generatePdf() {
45
45
  try {
46
46
  const commander = makeBrowserCommander({ page, verbose: false });
47
47
 
48
- // Navigate to a page
49
- await page.goto('https://example.com');
48
+ // Load HTML assembled in memory using the managed navigation lifecycle.
49
+ await commander.setContent({
50
+ html: '<main><h1>Browser Commander</h1><p>Generated in memory.</p></main>',
51
+ waitUntil: 'load',
52
+ });
50
53
 
51
54
  // Generate PDF using the unified API (no workarounds needed!)
52
55
  const pdfBuffer = await commander.pdf({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -137,9 +137,27 @@ export function resolveTestFiles(targets, cwd = process.cwd()) {
137
137
  return uniqueFiles.map((file) => relative(cwd, file));
138
138
  }
139
139
 
140
- export function buildNodeTestArgs(options, testFiles) {
140
+ function supportsTestIsolation(nodeVersion) {
141
+ const majorVersion = Number.parseInt(nodeVersion.split('.')[0], 10);
142
+ return Number.isInteger(majorVersion) && majorVersion >= 24;
143
+ }
144
+
145
+ export function buildNodeTestArgs(
146
+ options,
147
+ testFiles,
148
+ nodeVersion = process.versions.node
149
+ ) {
141
150
  const args = ['--test'];
142
151
 
152
+ // Node's default process isolation serializes each test file's results over
153
+ // a child-process channel. Node 24 on macOS can intermittently corrupt that
154
+ // stream (ERR_TEST_FAILURE: "Unable to deserialize cloned data"). Running
155
+ // supported runtimes in-process removes that transport while keeping the
156
+ // Node 20-compatible fallback for consumers on the minimum engine version.
157
+ if (supportsTestIsolation(nodeVersion)) {
158
+ args.push('--test-isolation=none');
159
+ }
160
+
143
161
  if (options.coverage) {
144
162
  args.push('--experimental-test-coverage');
145
163
  }
package/src/README.md CHANGED
@@ -417,6 +417,19 @@ await commander.goto({
417
417
  });
418
418
  ```
419
419
 
420
+ ### commander.setContent(options)
421
+
422
+ Load an in-memory HTML document while preserving the same page-trigger cleanup
423
+ and readiness guarantees as `goto()`:
424
+
425
+ ```javascript
426
+ await commander.setContent({
427
+ html: '<h1>Hi</h1>',
428
+ waitUntil: 'networkidle', // Playwright/Puppeteer option
429
+ timeout: 60000,
430
+ });
431
+ ```
432
+
420
433
  ### commander.clickButton(options)
421
434
 
422
435
  ```javascript
package/src/bindings.js CHANGED
@@ -8,6 +8,7 @@ import { getUrl, unfocusAddressBar } from './utilities/url.js';
8
8
  import {
9
9
  waitForUrlStabilization,
10
10
  goto,
11
+ setContent,
11
12
  waitForNavigation,
12
13
  waitForPageReady,
13
14
  waitAfterAction,
@@ -115,6 +116,12 @@ export function createBoundFunctions(options = {}) {
115
116
  waitForUrlStabilization: waitForUrlStabilizationBound,
116
117
  navigationManager,
117
118
  });
119
+ const setContentBound = (opts) =>
120
+ setContent({
121
+ ...opts,
122
+ page,
123
+ navigationManager,
124
+ });
118
125
  const waitForNavigationBound = (opts) =>
119
126
  waitForNavigation({
120
127
  ...opts,
@@ -312,6 +319,7 @@ export function createBoundFunctions(options = {}) {
312
319
  querySelectorAll: querySelectorAllBound,
313
320
  waitForUrlStabilization: waitForUrlStabilizationBound,
314
321
  goto: gotoBound,
322
+ setContent: setContentBound,
315
323
  getUrl: getUrlBound,
316
324
  waitForNavigation: waitForNavigationBound,
317
325
  waitForPageReady: waitForPageReadyBound,
@@ -0,0 +1,163 @@
1
+ import {
2
+ loadStorageState,
3
+ restorePlaywrightStorageState,
4
+ restorePuppeteerStorageState,
5
+ } from './storage-state.js';
6
+
7
+ function validateConnectionOptions({ engine, cdpEndpoint, wsEndpoint }) {
8
+ if (!['playwright', 'puppeteer'].includes(engine)) {
9
+ throw new Error(
10
+ `Invalid engine: ${engine}. Expected 'playwright' or 'puppeteer'`
11
+ );
12
+ }
13
+
14
+ if (Boolean(cdpEndpoint) === Boolean(wsEndpoint)) {
15
+ throw new Error(
16
+ 'connectBrowser requires exactly one of cdpEndpoint or wsEndpoint'
17
+ );
18
+ }
19
+ }
20
+
21
+ function addDefinedOptions(options, values) {
22
+ for (const [key, value] of Object.entries(values)) {
23
+ if (value !== undefined) {
24
+ options[key] = value;
25
+ }
26
+ }
27
+ return options;
28
+ }
29
+
30
+ function buildPlaywrightConnectOptions({ slowMo, timeout, headers }) {
31
+ return addDefinedOptions({}, { slowMo, timeout, headers });
32
+ }
33
+
34
+ function buildPuppeteerConnectOptions({
35
+ cdpEndpoint,
36
+ wsEndpoint,
37
+ slowMo,
38
+ protocolTimeout,
39
+ headers,
40
+ }) {
41
+ return addDefinedOptions(
42
+ {
43
+ ...(cdpEndpoint
44
+ ? { browserURL: cdpEndpoint }
45
+ : { browserWSEndpoint: wsEndpoint }),
46
+ defaultViewport: null,
47
+ },
48
+ { slowMo, protocolTimeout, headers }
49
+ );
50
+ }
51
+
52
+ async function prepareStorageState({ storageState, seedCookies }) {
53
+ if (seedCookies !== undefined && !Array.isArray(seedCookies)) {
54
+ throw new TypeError('seedCookies must be an array');
55
+ }
56
+
57
+ const resolvedStorageState = await loadStorageState(storageState);
58
+ if (!resolvedStorageState && seedCookies === undefined) {
59
+ return undefined;
60
+ }
61
+
62
+ return {
63
+ ...(resolvedStorageState ?? {}),
64
+ cookies: [...(resolvedStorageState?.cookies ?? []), ...(seedCookies ?? [])],
65
+ };
66
+ }
67
+
68
+ async function connectPlaywright({ options, loadPlaywright, storageState }) {
69
+ const { chromium } = await loadPlaywright();
70
+ const endpoint = options.cdpEndpoint ?? options.wsEndpoint;
71
+ const browser = await chromium.connectOverCDP(
72
+ endpoint,
73
+ buildPlaywrightConnectOptions(options)
74
+ );
75
+ const context = browser.contexts()[0];
76
+ if (!context) {
77
+ throw new Error('Connected Playwright browser has no default context');
78
+ }
79
+ const page = context.pages()[0] ?? (await context.newPage());
80
+
81
+ await restorePlaywrightStorageState({ context, storageState });
82
+ return { browser, page };
83
+ }
84
+
85
+ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
86
+ const puppeteerModule = await loadPuppeteer();
87
+ const puppeteer = puppeteerModule.default ?? puppeteerModule;
88
+ const browser = await puppeteer.connect(
89
+ buildPuppeteerConnectOptions(options)
90
+ );
91
+ const pages = await browser.pages();
92
+ const page = pages[0] ?? (await browser.newPage());
93
+
94
+ await restorePuppeteerStorageState({ page, storageState });
95
+ return { browser, page };
96
+ }
97
+
98
+ /**
99
+ * Attach to an already-running Chromium-family browser over CDP.
100
+ *
101
+ * @param {Object} options - Connection options
102
+ * @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine
103
+ * @param {string} [options.cdpEndpoint] - HTTP DevTools endpoint, such as http://127.0.0.1:9222
104
+ * @param {string} [options.wsEndpoint] - DevTools browser WebSocket endpoint
105
+ * @param {number} [options.slowMo] - Delay engine operations by this many milliseconds
106
+ * @param {number} [options.timeout] - Playwright connection timeout in milliseconds
107
+ * @param {number} [options.protocolTimeout] - Puppeteer CDP call timeout in milliseconds
108
+ * @param {Object<string,string>} [options.headers] - Additional connection headers
109
+ * @param {Object[]|string|Object} [options.storageState] - Playwright-compatible state path or object
110
+ * @param {Object[]} [options.seedCookies] - Cookies to seed after connecting
111
+ * @param {boolean} [options.verbose=false] - Enable connection logging
112
+ * @returns {Promise<{browser: Object, page: Object}>} Raw browser and page handles
113
+ */
114
+ export async function connectBrowser(options = {}) {
115
+ return await connectBrowserWithDependencies(options);
116
+ }
117
+
118
+ /**
119
+ * Dependency-injected implementation used by the public connector and tests.
120
+ *
121
+ * @param {Object} options - See {@link connectBrowser}
122
+ * @param {Object} dependencies - Optional engine module loaders
123
+ * @returns {Promise<{browser: Object, page: Object}>} Raw browser and page handles
124
+ */
125
+ export async function connectBrowserWithDependencies(
126
+ options = {},
127
+ dependencies = {}
128
+ ) {
129
+ const normalizedOptions = {
130
+ engine: 'playwright',
131
+ verbose: false,
132
+ ...options,
133
+ };
134
+ validateConnectionOptions(normalizedOptions);
135
+
136
+ const storageState = await prepareStorageState(normalizedOptions);
137
+ const { engine, verbose } = normalizedOptions;
138
+ if (verbose) {
139
+ console.log(`Connecting to browser with ${engine} engine...`);
140
+ }
141
+
142
+ const result =
143
+ engine === 'playwright'
144
+ ? await connectPlaywright({
145
+ options: normalizedOptions,
146
+ loadPlaywright:
147
+ dependencies.loadPlaywright ?? (() => import('playwright')),
148
+ storageState,
149
+ })
150
+ : await connectPuppeteer({
151
+ options: normalizedOptions,
152
+ loadPuppeteer:
153
+ dependencies.loadPuppeteer ?? (() => import('puppeteer')),
154
+ storageState,
155
+ });
156
+
157
+ if (verbose) {
158
+ console.log(`Connected to browser with ${engine} engine`);
159
+ }
160
+ return result;
161
+ }
162
+
163
+ export { buildPlaywrightConnectOptions, buildPuppeteerConnectOptions };
@@ -7,6 +7,11 @@ import {
7
7
  buildPlaywrightLaunchOptions,
8
8
  buildPuppeteerLaunchOptions,
9
9
  } from './launch-options.js';
10
+ import {
11
+ loadStorageState,
12
+ restorePlaywrightStorageState,
13
+ restorePuppeteerStorageState,
14
+ } from './storage-state.js';
10
15
 
11
16
  /**
12
17
  * Launch browser with default configuration
@@ -20,6 +25,7 @@ import {
20
25
  * @param {string|null} [options.colorScheme] - Emulate color scheme: 'light', 'dark', 'no-preference', or null to reset
21
26
  * @param {string} [options.channel] - Installed browser channel, such as 'chrome', 'chrome-beta', or 'msedge'
22
27
  * @param {string} [options.executablePath] - Explicit path to a Chrome or Chromium executable
28
+ * @param {string|Object} [options.storageState] - Playwright-compatible storage state path or object
23
29
  * @returns {Promise<Object>} - Object with browser and page
24
30
  */
25
31
  export async function launchBrowser(options = {}) {
@@ -33,6 +39,7 @@ export async function launchBrowser(options = {}) {
33
39
  colorScheme,
34
40
  channel,
35
41
  executablePath,
42
+ storageState,
36
43
  } = options;
37
44
 
38
45
  // Combine default CHROME_ARGS with custom args
@@ -44,6 +51,8 @@ export async function launchBrowser(options = {}) {
44
51
  );
45
52
  }
46
53
 
54
+ const resolvedStorageState = await loadStorageState(storageState);
55
+
47
56
  // Set environment variables to suppress warnings
48
57
  process.env.GOOGLE_API_KEY = 'no';
49
58
  process.env.GOOGLE_DEFAULT_CLIENT_ID = 'no';
@@ -74,6 +83,10 @@ export async function launchBrowser(options = {}) {
74
83
  contextOptions
75
84
  );
76
85
  page = browser.pages()[0];
86
+ await restorePlaywrightStorageState({
87
+ context: browser,
88
+ storageState: resolvedStorageState,
89
+ });
77
90
  } else {
78
91
  const puppeteer = await import('puppeteer');
79
92
  browser = await puppeteer.default.launch(
@@ -87,6 +100,10 @@ export async function launchBrowser(options = {}) {
87
100
  );
88
101
  const pages = await browser.pages();
89
102
  page = pages[0];
103
+ await restorePuppeteerStorageState({
104
+ page,
105
+ storageState: resolvedStorageState,
106
+ });
90
107
  }
91
108
 
92
109
  if (verbose) {
@@ -348,6 +348,64 @@ export async function goto(options = {}) {
348
348
  }
349
349
  }
350
350
 
351
+ /**
352
+ * Replace the current document with an in-memory HTML string.
353
+ * When NavigationManager is available, this uses the same managed lifecycle as
354
+ * goto so active page triggers are stopped before the document is replaced.
355
+ *
356
+ * @param {Object} options - Configuration options
357
+ * @param {Object} options.page - Browser page object
358
+ * @param {Object} options.navigationManager - NavigationManager instance (preferred)
359
+ * @param {string} options.html - HTML to load
360
+ * @param {string} options.waitUntil - Wait until condition (default: 'load')
361
+ * @param {number} options.timeout - Content loading timeout in ms (default: 60000)
362
+ * @returns {Promise<Object>} Content loading result
363
+ */
364
+ export async function setContent(options = {}) {
365
+ const {
366
+ page,
367
+ navigationManager,
368
+ html,
369
+ waitUntil = 'load',
370
+ timeout = 60000,
371
+ } = options;
372
+
373
+ if (typeof html !== 'string') {
374
+ throw new Error('html is required in options');
375
+ }
376
+
377
+ try {
378
+ let loaded;
379
+ if (navigationManager) {
380
+ loaded = await navigationManager.setContent({
381
+ html,
382
+ waitUntil,
383
+ timeout,
384
+ });
385
+ } else {
386
+ await page.setContent(html, { waitUntil, timeout });
387
+ loaded = true;
388
+ }
389
+
390
+ return loaded
391
+ ? { loaded: true, actualUrl: page.url() }
392
+ : {
393
+ loaded: false,
394
+ actualUrl: page.url(),
395
+ reason: 'content loading stopped/interrupted',
396
+ };
397
+ } catch (error) {
398
+ if (isNavigationError(error) || isActionStoppedError(error)) {
399
+ return {
400
+ loaded: false,
401
+ actualUrl: page.url(),
402
+ reason: 'content loading stopped/interrupted',
403
+ };
404
+ }
405
+ throw error;
406
+ }
407
+ }
408
+
351
409
  /**
352
410
  * Wait for navigation
353
411
  * @param {Object} options - Configuration options