browser-commander 0.11.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,15 @@
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
+
3
13
  ## 0.11.0
4
14
 
5
15
  ### 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
@@ -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.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,
@@ -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,
@@ -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
+ }
@@ -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
@@ -43,10 +43,12 @@ export {
43
43
 
44
44
  // Re-export browser management
45
45
  export { launchBrowser } from './browser/launcher.js';
46
+ export { saveStorageState } from './browser/storage-state.js';
46
47
  export { emulateMedia } from './browser/media.js';
47
48
  export {
48
49
  waitForUrlStabilization,
49
50
  goto,
51
+ setContent,
50
52
  waitForNavigation,
51
53
  waitForPageReady,
52
54
  waitAfterAction,
@@ -522,5 +522,24 @@ describe(
522
522
  // Test passes if no error is thrown
523
523
  });
524
524
  });
525
+
526
+ describe('In-memory Content', () => {
527
+ it('should load HTML through commander.setContent', async () => {
528
+ if (!commander) {
529
+ return;
530
+ }
531
+
532
+ const result = await commander.setContent({
533
+ html: '<main><h1 data-testid="content-title">In memory</h1></main>',
534
+ waitUntil: 'load',
535
+ });
536
+ const title = await commander.textContent({
537
+ selector: '[data-testid="content-title"]',
538
+ });
539
+
540
+ assert.strictEqual(result.loaded, true);
541
+ assert.strictEqual(title, 'In memory');
542
+ });
543
+ });
525
544
  }
526
545
  );
@@ -476,4 +476,23 @@ describe('E2E Tests - Puppeteer Engine', { skip: !process.env.RUN_E2E }, () => {
476
476
  assert.strictEqual(modalClosed, false);
477
477
  });
478
478
  });
479
+
480
+ describe('In-memory Content', () => {
481
+ it('should load HTML through commander.setContent', async () => {
482
+ if (!commander) {
483
+ return;
484
+ }
485
+
486
+ const result = await commander.setContent({
487
+ html: '<main><h1 data-testid="content-title">In memory</h1></main>',
488
+ waitUntil: 'load',
489
+ });
490
+ const title = await commander.textContent({
491
+ selector: '[data-testid="content-title"]',
492
+ });
493
+
494
+ assert.strictEqual(result.loaded, true);
495
+ assert.strictEqual(title, 'In memory');
496
+ });
497
+ });
479
498
  });
@@ -90,6 +90,7 @@ export function createMockPlaywrightPage(options = {}) {
90
90
  _isPlaywrightPage: true,
91
91
  url: () => url,
92
92
  goto: async (targetUrl, opts = {}) => {},
93
+ setContent: async (html, opts = {}) => {},
93
94
  waitForNavigation: async (opts = {}) => {},
94
95
  waitForSelector: async (sel, opts = {}) => locatorMock(sel),
95
96
  $: async (sel) => locatorMock(sel),
@@ -196,6 +197,7 @@ export function createMockPuppeteerPage(options = {}) {
196
197
  _isPuppeteerPage: true,
197
198
  url: () => url,
198
199
  goto: async (targetUrl, opts = {}) => {},
200
+ setContent: async (html, opts = {}) => {},
199
201
  waitForNavigation: async (opts = {}) => {},
200
202
  waitForSelector: async (sel, opts = {}) => elementMock(sel),
201
203
  $: async (sel) => {
@@ -441,6 +443,7 @@ export function createMockNavigationManager(options = {}) {
441
443
  url = opts.url || url;
442
444
  return true;
443
445
  },
446
+ setContent: async (opts = {}) => true,
444
447
  waitForNavigation: async (opts = {}) => true,
445
448
  waitForPageReady: async (opts = {}) => true,
446
449
  isNavigating: () => navigating,
@@ -61,6 +61,10 @@ describe('bindings', () => {
61
61
  typeof bindings.goto === 'function',
62
62
  'goto should be a function'
63
63
  );
64
+ assert.ok(
65
+ typeof bindings.setContent === 'function',
66
+ 'setContent should be a function'
67
+ );
64
68
  assert.ok(
65
69
  typeof bindings.getUrl === 'function',
66
70
  'getUrl should be a function'
@@ -5,6 +5,7 @@ import {
5
5
  verifyNavigation,
6
6
  waitForUrlStabilization,
7
7
  goto,
8
+ setContent,
8
9
  waitForNavigation,
9
10
  waitForPageReady,
10
11
  waitAfterAction,
@@ -203,6 +204,66 @@ describe('navigation', () => {
203
204
  });
204
205
  });
205
206
 
207
+ describe('setContent', () => {
208
+ it('should throw when html is not provided', async () => {
209
+ const page = createMockPlaywrightPage();
210
+
211
+ await assert.rejects(() => setContent({ page }), /html is required/);
212
+ });
213
+
214
+ it('should load content using navigationManager', async () => {
215
+ const page = createMockPlaywrightPage();
216
+ const navigationManager = createMockNavigationManager();
217
+ let receivedOptions;
218
+ navigationManager.setContent = async (options) => {
219
+ receivedOptions = options;
220
+ return true;
221
+ };
222
+
223
+ const result = await setContent({
224
+ page,
225
+ navigationManager,
226
+ html: '<h1>Hi</h1>',
227
+ waitUntil: 'networkidle',
228
+ timeout: 5000,
229
+ });
230
+
231
+ assert.deepStrictEqual(receivedOptions, {
232
+ html: '<h1>Hi</h1>',
233
+ waitUntil: 'networkidle',
234
+ timeout: 5000,
235
+ });
236
+ assert.deepStrictEqual(result, {
237
+ loaded: true,
238
+ actualUrl: 'https://example.com',
239
+ });
240
+ });
241
+
242
+ it('should load content directly without navigationManager', async () => {
243
+ const page = createMockPlaywrightPage();
244
+ let receivedArguments;
245
+ page.setContent = async (...args) => {
246
+ receivedArguments = args;
247
+ };
248
+
249
+ const result = await setContent({
250
+ page,
251
+ html: '',
252
+ waitUntil: 'load',
253
+ timeout: 5000,
254
+ });
255
+
256
+ assert.deepStrictEqual(receivedArguments, [
257
+ '',
258
+ { waitUntil: 'load', timeout: 5000 },
259
+ ]);
260
+ assert.deepStrictEqual(result, {
261
+ loaded: true,
262
+ actualUrl: 'https://example.com',
263
+ });
264
+ });
265
+ });
266
+
206
267
  describe('waitForNavigation', () => {
207
268
  it('should delegate to navigationManager if available', async () => {
208
269
  const page = createMockPlaywrightPage();
@@ -0,0 +1,148 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, it } from 'node:test';
5
+ import assert from 'node:assert';
6
+
7
+ import {
8
+ loadStorageState,
9
+ restorePlaywrightStorageState,
10
+ restorePuppeteerStorageState,
11
+ saveStorageState,
12
+ } from '../../../src/browser/storage-state.js';
13
+ import { saveStorageState as publicSaveStorageState } from '../../../src/index.js';
14
+
15
+ describe('browser storage state', () => {
16
+ let temporaryDirectory;
17
+
18
+ afterEach(async () => {
19
+ if (temporaryDirectory) {
20
+ await rm(temporaryDirectory, { recursive: true, force: true });
21
+ temporaryDirectory = undefined;
22
+ }
23
+ });
24
+
25
+ it('loads storage state from a path and accepts an object unchanged', async () => {
26
+ temporaryDirectory = await mkdtemp(
27
+ path.join(os.tmpdir(), 'browser-commander-storage-state-')
28
+ );
29
+ const filePath = path.join(temporaryDirectory, 'state.json');
30
+ const state = {
31
+ cookies: [{ name: 'session', value: 'saved', domain: '.example.com' }],
32
+ origins: [],
33
+ };
34
+ await writeFile(filePath, JSON.stringify(state));
35
+
36
+ assert.deepEqual(await loadStorageState(filePath), state);
37
+ assert.equal(await loadStorageState(state), state);
38
+ });
39
+
40
+ it('rejects invalid storage state input', async () => {
41
+ await assert.rejects(
42
+ () => loadStorageState(null),
43
+ /storageState must be a file path or an object/
44
+ );
45
+ });
46
+
47
+ it('exports saveStorageState from the package API', () => {
48
+ assert.equal(publicSaveStorageState, saveStorageState);
49
+ });
50
+
51
+ it('restores Puppeteer cookies and localStorage before navigation', async () => {
52
+ const calls = [];
53
+ const state = {
54
+ cookies: [{ name: 'session', value: 'saved', domain: '.example.com' }],
55
+ origins: [
56
+ {
57
+ origin: 'https://example.com',
58
+ localStorage: [{ name: 'theme', value: 'dark' }],
59
+ },
60
+ ],
61
+ };
62
+ const page = {
63
+ setCookie: async (...cookies) => calls.push(['cookies', cookies]),
64
+ evaluateOnNewDocument: async (fn, value) =>
65
+ calls.push(['new-document', fn, value]),
66
+ evaluate: async (fn, value) => calls.push(['current-page', fn, value]),
67
+ };
68
+
69
+ await restorePuppeteerStorageState({ page, storageState: state });
70
+
71
+ assert.deepEqual(calls[0], ['cookies', state.cookies]);
72
+ assert.equal(calls[1][0], 'new-document');
73
+ assert.equal(calls[1][2], state.origins);
74
+ assert.equal(calls[2][0], 'current-page');
75
+ assert.equal(calls[2][2], state.origins);
76
+ });
77
+
78
+ it('restores Playwright cookies and localStorage before navigation', async () => {
79
+ const calls = [];
80
+ const state = {
81
+ cookies: [{ name: 'session', value: 'saved', domain: '.example.com' }],
82
+ origins: [
83
+ {
84
+ origin: 'https://example.com',
85
+ localStorage: [{ name: 'theme', value: 'dark' }],
86
+ },
87
+ ],
88
+ };
89
+ const page = {
90
+ evaluate: async (fn, value) => calls.push(['current-page', fn, value]),
91
+ };
92
+ const context = {
93
+ addCookies: async (cookies) => calls.push(['cookies', cookies]),
94
+ addInitScript: async (fn, value) =>
95
+ calls.push(['new-document', fn, value]),
96
+ pages: () => [page],
97
+ };
98
+
99
+ await restorePlaywrightStorageState({ context, storageState: state });
100
+
101
+ assert.deepEqual(calls[0], ['cookies', state.cookies]);
102
+ assert.equal(calls[1][0], 'new-document');
103
+ assert.equal(calls[1][2], state.origins);
104
+ assert.equal(calls[2][0], 'current-page');
105
+ assert.equal(calls[2][2], state.origins);
106
+ });
107
+
108
+ it('delegates Playwright saves to the page context', async () => {
109
+ const savedState = { cookies: [], origins: [] };
110
+ const calls = [];
111
+ const page = {
112
+ context: () => ({
113
+ storageState: async (options) => {
114
+ calls.push(options);
115
+ return savedState;
116
+ },
117
+ }),
118
+ };
119
+
120
+ assert.equal(await saveStorageState(page, '/tmp/state.json'), savedState);
121
+ assert.deepEqual(calls, [{ path: '/tmp/state.json' }]);
122
+ });
123
+
124
+ it('saves Puppeteer cookies and current-origin localStorage', async () => {
125
+ temporaryDirectory = await mkdtemp(
126
+ path.join(os.tmpdir(), 'browser-commander-storage-state-')
127
+ );
128
+ const filePath = path.join(temporaryDirectory, 'state.json');
129
+ const cookies = [
130
+ { name: 'session', value: 'saved', domain: '.example.com' },
131
+ ];
132
+ const localStorage = [{ name: 'theme', value: 'dark' }];
133
+ const page = {
134
+ browserContext: () => ({}),
135
+ cookies: async () => cookies,
136
+ url: () => 'https://example.com/inbox',
137
+ evaluate: async () => localStorage,
138
+ };
139
+
140
+ const state = await saveStorageState(page, filePath);
141
+
142
+ assert.deepEqual(state, {
143
+ cookies,
144
+ origins: [{ origin: 'https://example.com', localStorage }],
145
+ });
146
+ assert.deepEqual(JSON.parse(await readFile(filePath, 'utf8')), state);
147
+ });
148
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { createNavigationManager } from '../../../src/core/navigation-manager.js';
4
+ import {
5
+ createMockPlaywrightPage,
6
+ createMockLogger,
7
+ } from '../../helpers/mocks.js';
8
+
9
+ describe('navigation manager', () => {
10
+ describe('setContent', () => {
11
+ it('should apply the full managed navigation lifecycle', async () => {
12
+ const page = createMockPlaywrightPage();
13
+ const log = createMockLogger();
14
+ const calls = [];
15
+ page.setContent = async (...args) => calls.push(['setContent', ...args]);
16
+
17
+ const manager = createNavigationManager({
18
+ page,
19
+ engine: 'playwright',
20
+ log,
21
+ });
22
+ manager.configure({ redirectStabilizationTime: 0 });
23
+ manager.onSessionCleanup(() => calls.push(['cleanup']));
24
+ manager.on('onNavigationStart', () => calls.push(['navigationStart']));
25
+ manager.on('onPageReady', () => calls.push(['pageReady']));
26
+
27
+ const loaded = await manager.setContent({
28
+ html: '<h1>Hi</h1>',
29
+ waitUntil: 'networkidle',
30
+ timeout: 5000,
31
+ });
32
+
33
+ assert.strictEqual(loaded, true);
34
+ assert.strictEqual(manager.getSessionId(), 1);
35
+ assert.strictEqual(manager.isNavigating(), false);
36
+ assert.deepStrictEqual(calls, [
37
+ ['cleanup'],
38
+ ['navigationStart'],
39
+ [
40
+ 'setContent',
41
+ '<h1>Hi</h1>',
42
+ { waitUntil: 'networkidle', timeout: 5000 },
43
+ ],
44
+ ['pageReady'],
45
+ ]);
46
+ });
47
+
48
+ it('should require html while accepting an empty string', async () => {
49
+ const page = createMockPlaywrightPage();
50
+ const log = createMockLogger();
51
+ const manager = createNavigationManager({
52
+ page,
53
+ engine: 'playwright',
54
+ log,
55
+ });
56
+ manager.configure({ redirectStabilizationTime: 0 });
57
+
58
+ await assert.rejects(() => manager.setContent(), /html is required/);
59
+ await assert.doesNotReject(() => manager.setContent({ html: '' }));
60
+ });
61
+ });
62
+ });
@@ -0,0 +1,73 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import assert from 'node:assert/strict';
5
+ import { describe, it } from 'node:test';
6
+
7
+ import { checkWorkflow } from '../../../../scripts/check-ci-workflows.mjs';
8
+
9
+ function withWorkflow(content, callback) {
10
+ const directory = mkdtempSync(join(tmpdir(), 'browser-commander-ci-'));
11
+ const filePath = join(directory, 'workflow.yml');
12
+ writeFileSync(filePath, content);
13
+
14
+ try {
15
+ return callback(filePath);
16
+ } finally {
17
+ rmSync(directory, { force: true, recursive: true });
18
+ }
19
+ }
20
+
21
+ describe('CI workflow policy', () => {
22
+ it('accepts bounded job-scoped checks', () => {
23
+ const workflow = `name: Test
24
+ on: push
25
+ permissions:
26
+ contents: read
27
+ env:
28
+ GIT_CONFIG_KEY_0: init.defaultBranch
29
+ jobs:
30
+ test:
31
+ runs-on: ubuntu-latest
32
+ timeout-minutes: 5
33
+ concurrency:
34
+ group: \${{ github.workflow }}-\${{ github.ref }}-test
35
+ cancel-in-progress: true
36
+ steps:
37
+ - uses: actions/checkout@v6
38
+ `;
39
+
40
+ withWorkflow(workflow, (filePath) => {
41
+ assert.equal(checkWorkflow(filePath), 0);
42
+ });
43
+ });
44
+
45
+ it('rejects workflow-wide cancellation and unbounded jobs', () => {
46
+ const workflow = `name: Test
47
+ on: push
48
+ concurrency:
49
+ group: test
50
+ cancel-in-progress: true
51
+ jobs:
52
+ test:
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - uses: actions/checkout@v6
56
+ - run: npm install
57
+ - run: echo "\${{ github.head_ref }}"
58
+ - uses: codecov/codecov-action@v6
59
+ with:
60
+ file: coverage.xml
61
+ `;
62
+ const originalError = console.error;
63
+ console.error = () => {};
64
+
65
+ try {
66
+ withWorkflow(workflow, (filePath) => {
67
+ assert.equal(checkWorkflow(filePath), 7);
68
+ });
69
+ } finally {
70
+ console.error = originalError;
71
+ }
72
+ });
73
+ });
@@ -127,12 +127,25 @@ describe('test runner helper', () => {
127
127
  reporter: 'spec',
128
128
  targets: ['tests/unit'],
129
129
  });
130
- assert.deepEqual(buildNodeTestArgs(options, ['tests/unit/a.test.js']), [
131
- '--test',
132
- '--experimental-test-coverage',
133
- '--test-reporter',
134
- 'spec',
135
- 'tests/unit/a.test.js',
136
- ]);
130
+ assert.deepEqual(
131
+ buildNodeTestArgs(options, ['tests/unit/a.test.js'], '24.18.0'),
132
+ [
133
+ '--test',
134
+ '--test-isolation=none',
135
+ '--experimental-test-coverage',
136
+ '--test-reporter',
137
+ 'spec',
138
+ 'tests/unit/a.test.js',
139
+ ]
140
+ );
141
+ });
142
+
143
+ it('keeps process isolation on Node versions without isolation controls', () => {
144
+ const options = parseTestRunnerArgs(['tests/unit']);
145
+
146
+ assert.deepEqual(
147
+ buildNodeTestArgs(options, ['tests/unit/a.test.js'], '20.20.2'),
148
+ ['--test', 'tests/unit/a.test.js']
149
+ );
137
150
  });
138
151
  });