browser-commander 0.10.0 → 0.12.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.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add `commander.setContent()` for safely loading in-memory HTML through the managed navigation lifecycle.
8
+
9
+ Add portable cookie and localStorage restoration through `launchBrowser({ storageState })` and the `saveStorageState()` helper for Playwright and Puppeteer.
10
+
11
+ Avoid intermittent Node 24 test-runner transport failures on macOS by running test files without child-process isolation.
12
+
13
+ ## 0.11.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 8ef86eb: Add page HTML, rendered text, and positional page evaluation APIs to commander instances.
18
+
3
19
  ## 0.10.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,22 @@ const { browser, page } = await launchBrowser({
100
106
  });
101
107
  ```
102
108
 
109
+ Reuse a saved authenticated session by passing Playwright-compatible storage
110
+ state as a JSON file path or object. Cookies and localStorage are restored for
111
+ both engines:
112
+
113
+ ```javascript
114
+ import { launchBrowser, saveStorageState } from 'browser-commander';
115
+
116
+ const { browser, page } = await launchBrowser({
117
+ engine: 'playwright',
118
+ storageState: './gmail-state.json',
119
+ });
120
+
121
+ // Save the current session for a later launch.
122
+ await saveStorageState(page, './gmail-state.json');
123
+ ```
124
+
103
125
  ## Browser Commander Tests
104
126
 
105
127
  `browser-commander/tests` adds browser fixtures and scheduling helpers on top of
@@ -271,11 +293,26 @@ const { browser, page } = await launchBrowser({
271
293
  slowMo: 150, // Slow down operations (ms)
272
294
  verbose: false, // Enable debug logging
273
295
  args: ['--no-sandbox', '--disable-setuid-sandbox'], // Custom Chrome args to append
296
+ storageState: './session-state.json', // Saved cookies and localStorage, as a path or object
274
297
  });
275
298
  ```
276
299
 
277
300
  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
301
 
302
+ The `storageState` option accepts a Playwright-compatible JSON path or object.
303
+ Each engine restores its cookies and origin-specific localStorage before
304
+ navigation, including when Playwright uses a persistent context.
305
+
306
+ ### saveStorageState(page, filePath)
307
+
308
+ Save the current cookies and localStorage in Playwright's portable storage
309
+ state format. The helper supports pages from either engine and also returns the
310
+ saved object:
311
+
312
+ ```javascript
313
+ const state = await saveStorageState(page, './session-state.json');
314
+ ```
315
+
279
316
  The `colorScheme` option allows setting the initial color scheme (`'light'`, `'dark'`, or `'no-preference'`) at launch time for screenshot services and testing tools:
280
317
 
281
318
  ```javascript
@@ -360,6 +397,27 @@ await commander.fillTextArea({
360
397
  });
361
398
  ```
362
399
 
400
+ ### Page Content Extraction
401
+
402
+ Read the current page without reaching through to the engine-specific page API:
403
+
404
+ ```javascript
405
+ const html = await commander.content();
406
+ const pageText = await commander.innerText(); // document.body
407
+ const mainText = await commander.innerText('main');
408
+ const title = await commander.evaluate(() => document.title);
409
+ const total = await commander.evaluate((a, b) => a + b, 2, 3);
410
+ ```
411
+
412
+ The existing options-object form of `evaluate` remains supported:
413
+
414
+ ```javascript
415
+ const total = await commander.evaluate({
416
+ fn: (a, b) => a + b,
417
+ args: [2, 3],
418
+ });
419
+ ```
420
+
363
421
  ### Keyboard Interactions
364
422
 
365
423
  ```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.10.0",
3
+ "version": "0.12.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,
@@ -30,6 +31,8 @@ import {
30
31
  } from './elements/selectors.js';
31
32
  import { isVisible, isEnabled, count } from './elements/visibility.js';
32
33
  import {
34
+ content,
35
+ innerText,
33
36
  textContent,
34
37
  inputValue,
35
38
  getAttribute,
@@ -85,7 +88,13 @@ export function createBoundFunctions(options = {}) {
85
88
  : null;
86
89
  return wait({ ...opts, log, abortSignal: opts.abortSignal || abortSignal });
87
90
  };
88
- const evaluateBound = (opts) => evaluate({ ...opts, page, engine });
91
+ const evaluateBound = (fnOrOptions, ...args) => {
92
+ const opts =
93
+ typeof fnOrOptions === 'function'
94
+ ? { fn: fnOrOptions, args }
95
+ : fnOrOptions;
96
+ return evaluate({ ...opts, page, engine });
97
+ };
89
98
  const safeEvaluateBound = (opts) => safeEvaluate({ ...opts, page, engine });
90
99
  const getUrlBound = () => getUrl({ page });
91
100
  const unfocusAddressBarBound = (opts = {}) =>
@@ -107,6 +116,12 @@ export function createBoundFunctions(options = {}) {
107
116
  waitForUrlStabilization: waitForUrlStabilizationBound,
108
117
  navigationManager,
109
118
  });
119
+ const setContentBound = (opts) =>
120
+ setContent({
121
+ ...opts,
122
+ page,
123
+ navigationManager,
124
+ });
110
125
  const waitForNavigationBound = (opts) =>
111
126
  waitForNavigation({
112
127
  ...opts,
@@ -158,6 +173,16 @@ export function createBoundFunctions(options = {}) {
158
173
  const countBound = (opts) => count({ ...opts, page, engine });
159
174
 
160
175
  // Bound content
176
+ const contentBound = (opts = {}) => content({ ...opts, page, engine });
177
+ const innerTextBound = (selectorOrOptions = 'body') => {
178
+ const opts =
179
+ typeof selectorOrOptions === 'object' &&
180
+ selectorOrOptions !== null &&
181
+ Object.hasOwn(selectorOrOptions, 'selector')
182
+ ? selectorOrOptions
183
+ : { selector: selectorOrOptions };
184
+ return innerText({ ...opts, page, engine });
185
+ };
161
186
  const textContentBound = (opts) => textContent({ ...opts, page, engine });
162
187
  const inputValueBound = (opts) => inputValue({ ...opts, page, engine });
163
188
  const getAttributeBound = (opts) => getAttribute({ ...opts, page, engine });
@@ -294,6 +319,7 @@ export function createBoundFunctions(options = {}) {
294
319
  querySelectorAll: querySelectorAllBound,
295
320
  waitForUrlStabilization: waitForUrlStabilizationBound,
296
321
  goto: gotoBound,
322
+ setContent: setContentBound,
297
323
  getUrl: getUrlBound,
298
324
  waitForNavigation: waitForNavigationBound,
299
325
  waitForPageReady: waitForPageReadyBound,
@@ -302,6 +328,8 @@ export function createBoundFunctions(options = {}) {
302
328
  isVisible: isVisibleWrapped,
303
329
  isEnabled: isEnabledWrapped,
304
330
  count: countBound,
331
+ content: contentBound,
332
+ innerText: innerTextBound,
305
333
  textContent: textContentWrapped,
306
334
  inputValue: inputValueWrapped,
307
335
  locator: locatorBound,
@@ -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
@@ -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
+ }
@@ -120,6 +120,15 @@ export class EngineAdapter {
120
120
  return notImplemented('getTextContent');
121
121
  }
122
122
 
123
+ /**
124
+ * Get rendered text from an element
125
+ * @param {Object} locatorOrElement - Locator or element
126
+ * @returns {Promise<string>} - Rendered text
127
+ */
128
+ getInnerText(_locatorOrElement) {
129
+ return notImplemented('getInnerText');
130
+ }
131
+
123
132
  /**
124
133
  * Get input value
125
134
  * @param {Object} locatorOrElement - Locator or element
@@ -236,6 +245,14 @@ export class EngineAdapter {
236
245
  return notImplemented('evaluateOnPage');
237
246
  }
238
247
 
248
+ /**
249
+ * Get the full HTML of the current page
250
+ * @returns {Promise<string>} - Serialized page HTML
251
+ */
252
+ getPageContent() {
253
+ return notImplemented('getPageContent');
254
+ }
255
+
239
256
  /**
240
257
  * Get the main frame
241
258
  * @returns {Object} - Main frame
@@ -325,6 +342,10 @@ export class PlaywrightAdapter extends EngineAdapter {
325
342
  return await locatorOrElement.textContent();
326
343
  }
327
344
 
345
+ async getInnerText(locatorOrElement) {
346
+ return await locatorOrElement.innerText();
347
+ }
348
+
328
349
  async getInputValue(locatorOrElement) {
329
350
  return await locatorOrElement.inputValue();
330
351
  }
@@ -401,6 +422,10 @@ export class PlaywrightAdapter extends EngineAdapter {
401
422
  }
402
423
  }
403
424
 
425
+ async getPageContent() {
426
+ return await this.page.content();
427
+ }
428
+
404
429
  getMainFrame() {
405
430
  return this.page.mainFrame();
406
431
  }
@@ -469,6 +494,10 @@ export class PuppeteerAdapter extends EngineAdapter {
469
494
  return await this.page.evaluate((el) => el.textContent, locatorOrElement);
470
495
  }
471
496
 
497
+ async getInnerText(locatorOrElement) {
498
+ return await this.page.evaluate((el) => el.innerText, locatorOrElement);
499
+ }
500
+
472
501
  async getInputValue(locatorOrElement) {
473
502
  return await this.page.evaluate((el) => el.value, locatorOrElement);
474
503
  }
@@ -541,6 +570,10 @@ export class PuppeteerAdapter extends EngineAdapter {
541
570
  return await this.page.evaluate(fn, ...args);
542
571
  }
543
572
 
573
+ async getPageContent() {
574
+ return await this.page.content();
575
+ }
576
+
544
577
  getMainFrame() {
545
578
  return this.page.mainFrame();
546
579
  }