npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

browser-commander

v0.21.2

Published

Universal browser automation library that supports both Playwright and Puppeteer with a unified API

Readme

Browser Commander

A universal browser automation library for JavaScript/TypeScript that supports both Playwright and Puppeteer with a unified API. The key focus is on stoppable page triggers - ensuring automation logic is properly mounted/unmounted during page navigation.

Installation

npm install browser-commander

You'll also need Playwright, Puppeteer or Selenium WebDriver:

# With Playwright
npm install playwright

# Or with Puppeteer
npm install puppeteer

# Or with Selenium WebDriver (W3C WebDriver + WebDriver BiDi)
npm install selenium-webdriver

Documentation

Generate the JavaScript API reference locally with:

npm run docs:api

The repository Documentation workflow also combines this JSDoc output with Rust cargo doc output for GitHub Pages publishing from main.

Core Concept: Page State Machine

Browser Commander manages the browser as a state machine with two states:

+------------------+                      +------------------+
|                  |   navigation start   |                  |
|  WORKING STATE   | -------------------> |  LOADING STATE   |
|  (action runs)   |                      |  (wait only)     |
|                  |   <-----------------  |                  |
+------------------+     page ready       +------------------+

LOADING STATE: Page is loading. Only waiting/tracking operations are allowed. No automation logic runs.

WORKING STATE: Page is fully loaded (30 seconds of network idle). Page triggers can safely interact with DOM.

Quick Start

import {
  launchBrowser,
  makeBrowserCommander,
  makeUrlCondition,
} from 'browser-commander';

// 1. Launch browser
const { browser, page } = await launchBrowser({ engine: 'playwright' });

// 2. Create commander
const commander = makeBrowserCommander({ page, verbose: true });

// 3. Register page trigger with condition and action
commander.pageTrigger({
  name: 'example-trigger',
  condition: makeUrlCondition('*example.com*'), // matches URLs containing 'example.com'
  action: async (ctx) => {
    // ctx.commander has all methods, but they throw ActionStoppedError if navigation happens
    // ctx.checkStopped() - call in loops to check if should stop
    // ctx.abortSignal - use with fetch() for cancellation
    // ctx.onCleanup(fn) - register cleanup when action stops

    console.log(`Processing: ${ctx.url}`);

    // Safe iteration - stops if navigation detected
    await ctx.forEach(['item1', 'item2'], async (item) => {
      await ctx.commander.clickButton({ selector: `[data-id="${item}"]` });
    });
  },
});

// 4. Navigate - action auto-starts when page is ready
await commander.goto({ url: 'https://example.com' });

// Or safely replace the document with in-memory HTML
await commander.setContent({
  html: '<h1>Hi</h1>',
  waitUntil: 'networkidle',
});

// 5. Cleanup
await commander.destroy();
await browser.close();

launchBrowser() starts the installed Chrome the way a person would and then attaches to it. The whole command line is --user-data-dir=<fresh temporary profile> --remote-debugging-port=<reserved port> about:blank: no automation switches, so navigator.webdriver is false, there is no "controlled by automated test software" or unsupported-flag infobar, and extensions, sync, translation and every other browser feature behave as in a hand-started Chrome. The temporary profile is deleted by browser.close().

const { browser, page } = await launchBrowser({
  engine: 'playwright',
  channel: 'msedge', // or executablePath: '/usr/bin/google-chrome'
  userDataDir: '/tmp/my-profile', // keep the profile instead of a temporary one
  extraArgs: ['--lang=en-US'],
  // Opt-in restrictions, by name (see the table linked below).
  restrictions: ['no-sync', 'no-translate'],
});

Every switch Browser Commander used to add on its own is available as a named launch restriction; restrictions: ['legacy-defaults'] restores the old CHROME_ARGS. Pass launch: 'engine' to use Playwright's launchPersistentContext() or puppeteer.launch() instead; the engine then adds its own switches, and ignoreDefaultArgs removes them.

Attach to a Chrome-family browser that is already listening for CDP connections:

import { connectBrowser, makeBrowserCommander } from 'browser-commander';

const { browser, page } = await connectBrowser({
  engine: 'playwright', // or 'puppeteer'
  cdpEndpoint: 'http://127.0.0.1:9222',
});
const commander = makeBrowserCommander({ page });

Attachment cannot change the existing process's launch arguments. Start it with a fixed --remote-debugging-port and a dedicated --user-data-dir, or let launchBrowser()/launchRealBrowser() start it.

launchRealBrowser() is the lower-level helper behind the default launch: it finds and starts an installed Chrome, Edge, Brave, or Chromium on a reserved loopback port, confirms from the browser's own DevTools listening on line that the port is really its own (retrying on a port race), and attaches. It always uses a dedicated profile; Chrome 136 and newer do not honor remote debugging switches for the default profile. See the Chrome remote-debugging security change.

import { launchRealBrowser } from 'browser-commander';

const connection = await launchRealBrowser({
  engine: 'puppeteer',
  channel: 'chrome',
  userDataDir: '/tmp/my-automation-profile',
  extraArgs: ['--lang=en-US'],
  restrictions: ['no-default-browser-check'],
  seedCookies: [
    { name: 'session', value: 'saved', url: 'https://example.com' },
  ],
});
await connection.browser.close();

Cookie seeding copies only cookies you explicitly provide. To seed a dedicated profile from one of your installed browser profiles, use the explicit local cookie-import helper:

import {
  launchAndConnectRealBrowser,
  listBrowserProfiles,
  readBrowserCookies,
} from 'browser-commander';

console.log(await listBrowserProfiles({ browser: 'chrome' }));

const cookies = await readBrowserCookies({
  browser: 'chrome', // chrome, edge, brave, chromium, or firefox
  profile: 'Default',
  domainFilter: 'example.com',
  cache: { ttlMinutes: 60 },
});

const connection = await launchAndConnectRealBrowser({
  engine: 'playwright',
  channel: 'chrome',
  userDataDir: '/tmp/my-dedicated-profile',
  seedCookies: cookies,
});

The import stays on the local machine and runs only when called. It never sends cookie data anywhere. Decrypted result and derived-key cache files are stored under ~/.browser-commander/cookie-cache/ with owner-only permissions. The default 60-minute TTL and a cross-process lock ensure that concurrent or repeated scripts touch Keychain, libsecret/KWallet, or DPAPI at most once per TTL window. Set refresh: true to force a new read, customize cache.dir/ttlMinutes, or set cache: false to opt out of disk caching. launchAndConnectRealBrowser() remains available as a descriptive alias. The remote-debugging and profile switches are always managed; headless: true adds --headless=new.

Reuse a saved authenticated session by passing Playwright-compatible storage state as a JSON file path or object. Cookies and localStorage are restored for both engines:

import { launchBrowser, saveStorageState } from 'browser-commander';

const { browser, page } = await launchBrowser({
  engine: 'playwright',
  storageState: './gmail-state.json',
});

// Save the current session for a later launch.
await saveStorageState(page, './gmail-state.json');

Browser Commander Tests

browser-commander/tests adds browser fixtures and scheduling helpers on top of test-anywhere. It keeps tests portable across Node.js, Bun, and Deno while running the same browser scenario against Playwright and Puppeteer.

import { assert, browserTest } from 'browser-commander/tests';

browserTest(
  'loads example.com',
  async ({ commander, engine }) => {
    await commander.goto({
      url: 'https://example.com',
      waitForNetworkIdle: false,
    });

    const heading = await commander.textContent({ selector: 'h1' });
    assert.ok(heading.includes('Example Domain'), `${engine} should work`);
  },
  {
    engines: ['playwright', 'puppeteer'],
    launchOptions: { headless: true },
    retries: 1,
    timeoutMs: 60000,
  }
);

The test helpers provide:

  • browserTest() and defineBrowserTests() for Playwright/Puppeteer matrices.
  • Automatic fixture cleanup with commander.destroy() and browser.close().
  • retries, per-test timeoutMs, and failure artifacts under test-results/browser-commander by default.
  • Historical duration tracking in tests/.browser-commander-test-timings.json when a tests directory exists.
  • Longest-first ordering and balanced shard planning. Set BROWSER_COMMANDER_TEST_SHARD=1/3 to select a shard.
  • Re-exported test-anywhere APIs such as test, describe, it, assert, expect, and lifecycle hooks.

See examples/browser-commander-tests.example.js for a runnable example.

URL Condition Helpers

The makeUrlCondition helper makes it easy to create URL matching conditions:

import {
  makeUrlCondition,
  allConditions,
  anyCondition,
  notCondition,
} from 'browser-commander';

// Exact URL match
makeUrlCondition('https://example.com/page');

// Contains substring (use * wildcards)
makeUrlCondition('*checkout*'); // URL contains 'checkout'
makeUrlCondition('*example.com*'); // URL contains 'example.com'

// Starts with / ends with
makeUrlCondition('/api/*'); // starts with '/api/'
makeUrlCondition('*.json'); // ends with '.json'

// Express-style route patterns
makeUrlCondition('/vacancy/:id'); // matches /vacancy/123
makeUrlCondition('https://hh.ru/vacancy/:vacancyId'); // matches specific domain + path
makeUrlCondition('/user/:userId/profile'); // multiple segments

// RegExp
makeUrlCondition(/\/product\/\d+/);

// Custom function (receives full context)
makeUrlCondition((url, ctx) => {
  const parsed = new URL(url);
  return (
    parsed.pathname.startsWith('/admin') && parsed.searchParams.has('edit')
  );
});

// Combine conditions
allConditions(
  makeUrlCondition('*example.com*'),
  makeUrlCondition('*/checkout*')
); // Both must match

anyCondition(makeUrlCondition('*/cart*'), makeUrlCondition('*/checkout*')); // Either matches

notCondition(makeUrlCondition('*/admin*')); // Negation

Page Trigger Lifecycle

The Guarantee

When navigation is detected:

  1. Action is signaled to stop (AbortController.abort())
  2. Wait for action to finish (up to 10 seconds for graceful cleanup)
  3. Only then start waiting for page load

This ensures:

  • No DOM operations on stale/loading pages
  • Actions can do proper cleanup (clear intervals, save state)
  • No race conditions between action and navigation

Action Context API

When your action is called, it receives a context object with these properties:

commander.pageTrigger({
  name: 'my-trigger',
  condition: makeUrlCondition('*/checkout*'),
  action: async (ctx) => {
    // Current URL
    ctx.url; // 'https://example.com/checkout'

    // Trigger name (for debugging)
    ctx.triggerName; // 'my-trigger'

    // Check if action should stop
    ctx.isStopped(); // Returns true if navigation detected

    // Throw ActionStoppedError if stopped (use in manual loops)
    ctx.checkStopped();

    // AbortSignal - use with fetch() or other cancellable APIs
    ctx.abortSignal;

    // Safe wait (throws if stopped during wait)
    await ctx.wait(1000);

    // Safe iteration (checks stopped between items)
    await ctx.forEach(items, async (item) => {
      await ctx.commander.clickButton({ selector: item.selector });
    });

    // Register cleanup (runs when action stops)
    ctx.onCleanup(() => {
      console.log('Cleaning up...');
    });

    // Commander with all methods wrapped to throw on stop
    await ctx.commander.fillTextArea({ selector: 'input', text: 'hello' });

    // Raw commander (use carefully - does not auto-throw)
    ctx.rawCommander;
  },
});

API Reference

launchBrowser(options)

const { browser, page, close } = await launchBrowser({
  engine: 'playwright', // 'playwright' or 'puppeteer'
  launch: 'real', // 'real' (start the installed browser, attach) or 'engine'
  channel: 'chrome', // or executablePath; defaults to the installed Chrome
  headless: false, // Run in headless mode
  userDataDir: undefined, // Keep a profile; default is a fresh temporary one
  restrictions: [], // Opt-in restrictions such as 'no-extensions', 'no-sync'
  slowMo: 0, // Slow down operations (ms)
  verbose: false, // Enable debug logging
  args: ['--no-sandbox'], // Custom Chrome args to append
  storageState: './session-state.json', // Saved cookies and localStorage, as a path or object
});

With Playwright, browser is the attached browser's default context, so browser.pages(), browser.newPage() and browser.close() work as they did with the persistent context. close() (and browser.close()) closes the browser and deletes a temporary profile. The result also carries launch, userDataDir, temporaryProfile and the exact args the browser was started with.

The args option allows passing custom Chrome arguments, which is useful for headless server environments (Docker, CI/CD) that require flags like --no-sandbox.

The storageState option accepts a Playwright-compatible JSON path or object. Each engine restores its cookies and origin-specific localStorage before navigation, including when Playwright uses a persistent context.

connectBrowser(options)

Connect to an existing Chrome-family browser over an HTTP or WebSocket CDP endpoint. Exactly one of cdpEndpoint and wsEndpoint is required. The raw browser and page work with both the underlying engine API and makeBrowserCommander({ page }).

const { browser, page } = await connectBrowser({
  engine: 'playwright',
  wsEndpoint: 'ws://127.0.0.1:9222/devtools/browser/<id>',
  timeout: 30_000,
  seedCookies: [
    { name: 'session', value: 'saved', url: 'https://example.com' },
  ],
});

Playwright accepts timeout and Puppeteer accepts protocolTimeout. storageState can also seed Playwright-compatible cookies and localStorage.

launchRealBrowser(options)

Start an installed browser and connect through connectBrowser(). Use channel (chrome, chrome-beta, chrome-dev, chrome-canary, msedge, msedge-beta, msedge-dev, msedge-canary, brave, or chromium) or an explicit executablePath. Without userDataDir it creates a fresh temporary profile that close() deletes; it rejects known default browser-profile paths, protects its remote-debugging switches, reserves a loopback port (or uses remoteDebuggingPort, never 0), and returns the spawned browserProcess, cdpEndpoint, remoteDebuggingPort, executablePath, userDataDir, temporaryProfile and args alongside { browser, page, close }. launchAndConnectRealBrowser() is an alias with identical behavior. restrictions opts into named restrictions, args/extraArgs append custom switches, and env adds environment variables for the browser process only.

The launch is clean by default, so signing in with a Google account works (a nonexistent address shows "Couldn't find your Google Account" rather than "This browser or app may not be secure"). Pass migrateFrom to opt into a read-only migration from an installed browser's main profile before the launch; the migrated cookies are seeded automatically and the returned session carries a migration report (see migrateProfile() below):

const session = await launchRealBrowser({
  channel: 'chrome',
  userDataDir, // dedicated; clean by default
  migrateFrom: {
    browser: 'chrome', // chrome | edge | brave | chromium | firefox
    profile: 'Default',
    include: ['cookies', 'bookmarks', 'history', 'passwords', 'preferences'],
    domains: ['npmjs.com', 'github.com'], // optional cookie filter
  },
});
console.log(session.migration.migrated, session.migration.skipped);

launchWebDriver(options) / connectWebDriver(options)

The selenium engine drives Chrome or Firefox over W3C WebDriver through selenium-webdriver (an optional peer dependency). launchWebDriver() finds a driver (driverPath, then PATH, then the Selenium Manager bundled with selenium-webdriver, which downloads a chromedriver/geckodriver matching the browser), starts it on a reserved loopback port, waits for /status, and opens a session with a fresh temporary profile that close() deletes:

import { launchWebDriver, makeBrowserCommander } from 'browser-commander';

const { driver, page, close } = await launchWebDriver({
  browser: 'chrome', // or 'firefox'
  headless: false,
  bidi: true, // WebDriver BiDi: console/dialog/network events, preload scripts
  // executablePath, driverPath, userDataDir, args, restrictions, env
});
const commander = makeBrowserCommander({ page }); // commander.engine === 'selenium'

page.on('console', (message) => console.log(message.text())); // needs bidi
await commander.goto({ url: 'https://example.com' });
await commander.fill({ selector: 'input[name="q"]', text: 'hello' });
await commander.click({ selector: 'button[type="submit"]' });
const pdf = await page.pdf({ format: 'A4' }); // W3C Print Page
await close();

page is a WebDriverPage: a Puppeteer-shaped facade over the driver (evaluate, $, $$, keyboard, mouse, screenshot, pdf, cookies, on), so the rest of Browser Commander runs unchanged. makeBrowserCommander() also accepts a bare selenium WebDriver and wraps it. On the adapter (createEngineAdapter(page, 'selenium')), onConsoleMessage(handler), onNavigation(handler), onBidiEvent(method, handler) and navigate(url, {wait}) expose BiDi log.entryAdded and browsingContext directly; they throw when the session has no BiDi.

Chrome gets the same command line as launchRealBrowser() (--user-data-dir and a fixed --remote-debugging-port), and every switch chromedriver would add on its own - --enable-automation, --remote-debugging-port=0, --password-store=basic, --test-type=webdriver and 16 more - is excluded, so navigator.webdriver is false headful and headless. connectWebDriver({ serverUrl, capabilities }) opens a session on a server that is already running (a Selenium Grid, a cloud provider); pass capabilities: { webSocketUrl: true } for BiDi. Media emulation, fingerprint overrides and managed downloads need CDP and throw on this engine; see docs/feature-parity.md.

listBrowserProfiles(options)

Discover cookie-bearing profiles for Chrome, Edge, Brave, Chromium, and Firefox. Pass an optional browser to narrow discovery. Each result contains { browser, name, displayName, path, isDefault }.

readBrowserCookies(options)

Read cookies from an installed browser profile and return the exact { name, value, domain, path, expires, httpOnly, secure, sameSite } shape used by Playwright and Puppeteer:

const cookies = await readBrowserCookies({
  browser: 'firefox',
  profile: 'default-release', // optional; the default profile is selected first
  domainFilter: 'example.com', // optional substring match
  cache: { dir: './private-cookie-cache', ttlMinutes: 30 },
  refresh: false,
});

Platform support:

| Browser family | macOS | Linux | Windows | | ----------------------------- | ------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------- | | Chrome, Edge, Brave, Chromium | Keychain + AES-128-CBC (v10/v11) | libsecret/KWallet + AES-128-CBC (v11), or the Chromium v10 fallback key | DPAPI-protected AES-256-GCM key (v10/v11) | | Firefox | cookies.sqlite | cookies.sqlite | cookies.sqlite |

Firefox cookie values are stored directly in its local cookie database. Chromium database version 24 domain hashes and Chrome's 1601-based timestamps are handled automatically. Current Windows Chromium can use app-bound v20 encryption, which intentionally requires the browser's privileged elevation service and cannot be decrypted by an ordinary external process. The helper reports that boundary instead of bypassing it; use a browser-supported export or an existing Browser Commander storage-state file for those cookies. Set ignoreDecryptionErrors: true only when returning the remaining decryptable cookies is acceptable.

Treat imported cookies like passwords: keep cache directories private, use a short TTL, never commit them, and seed only a dedicated automation profile.

migrateProfile(options)

Copy data from an installed browser's main profile into a dedicated target profile directory (usually the Default folder of a launchRealBrowser() userDataDir). This is the same migration launchRealBrowser({ migrateFrom }) runs, exposed directly. It is opt-in and strictly read-only on the source: SQLite databases (History, Top Sites, cookies, Firefox places.sqlite) are copied through the SQLite Online Backup API so a running browser is never disturbed, and JSON files (Bookmarks, Preferences) are copied as is.

import { migrateProfile } from 'browser-commander';

const report = await migrateProfile({
  from: { browser: 'chrome', profile: 'Default' },
  to: '/path/to/dedicated/User Data/Default',
  targetBrowser: 'chrome', // launching channel, for key derivation
  include: [
    'cookies',
    'bookmarks',
    'history',
    'passwords',
    'preferences',
    'extensions',
  ],
  domains: ['github.com'], // optional cookie filter
});

The report is { source, target, migrated, skipped, warnings, cookies }: migrated counts each data class, cookies holds the seedable Playwright-shape cookies, and skipped/warnings explain everything that could not be migrated verbatim, for example:

  • DBSC-bound Google cookies (__Secure-1PSIDTS/3PSIDTS/SIDTS) are reported dbsc-bound: their Device Bound Session Credentials key cannot leave the source profile, so those sessions expire quickly and are not copied.
  • Windows app-bound v20 passwords/cookies are reported app-bound-v20 because they require the browser's privileged elevation service.
  • Migrated extensions are reported mac-will-not-validate: Chromium's Secure Preferences MAC cannot be forged externally, so migrated extensions are likely disabled until re-enabled in the browser.
  • Firefox with a primary password is reported primary-password-set.

Data classes: cookies, bookmarks, history, passwords, preferences, extensions (exported as ALL_DATA_CLASSES). Chromium sources (chrome/edge/brave/chromium) re-encrypt passwords with the target profile's OS-keystore key; Firefox uses its own NSS (key4.db) path for cookies.sqlite, places.sqlite and logins.json.

openInUserBrowser(url)

Open a URL in the user's own default browser with no automation - no CDP, no dedicated profile, no navigator.webdriver. Use it when a consumer only has to show a page where the user is already signed in (an OAuth consent screen or a CLI web-login page). It shells out to the platform opener (macOS open, Linux xdg-open, Windows explorer.exe) after validating the URL:

import { openInUserBrowser } from 'browser-commander';

await openInUserBrowser('https://github.com/login/device');

Only a browser-safe scheme set is accepted (http:, https:, file:, ftp:, about:, chrome:, edge:, view-source:); validateOpenUrl() and buildOpenCommand() are exported for callers that want to validate or build the command separately.

saveStorageState(page, filePath)

Save the current cookies and localStorage in Playwright's portable storage state format. The helper supports pages from either engine and also returns the saved object:

const state = await saveStorageState(page, './session-state.json');

The colorScheme option allows setting the initial color scheme ('light', 'dark', or 'no-preference') at launch time for screenshot services and testing tools:

const { browser, page } = await launchBrowser({
  engine: 'playwright',
  colorScheme: 'dark', // 'light', 'dark', or 'no-preference'
});

commander.emulateMedia(options)

Emulate media features (e.g. prefers-color-scheme) for the current page:

// Set dark mode
await commander.emulateMedia({ colorScheme: 'dark' });

// Set light mode
await commander.emulateMedia({ colorScheme: 'light' });

// Reset to system default
await commander.emulateMedia({ colorScheme: null });

Works with both Playwright (page.emulateMedia) and Puppeteer (page.emulateMediaFeatures). Can also be used as a standalone function:

import { emulateMedia } from 'browser-commander';

await emulateMedia({ page, engine: 'playwright', colorScheme: 'dark' });

makeBrowserCommander(options)

const commander = makeBrowserCommander({
  page, // Required: Playwright/Puppeteer page
  verbose: false, // Enable debug logging
  enableNetworkTracking: true, // Track HTTP requests
  enableNavigationManager: true, // Enable navigation events
});

commander.pageTrigger(config)

const unregister = commander.pageTrigger({
  name: 'trigger-name',                    // For debugging
  condition: (ctx) => boolean,             // When to run (receives {url, commander})
  action: async (ctx) => void,             // What to do
  priority: 0,                             // Higher runs first
});

commander.goto(options)

await commander.goto({
  url: 'https://example.com',
  waitUntil: 'domcontentloaded', // Playwright/Puppeteer option
  timeout: 60000,
});

commander.clickButton(options)

await commander.clickButton({
  selector: 'button.submit',
  scrollIntoView: true,
  waitForNavigation: true,
});

commander.fillTextArea(options)

await commander.fillTextArea({
  selector: 'textarea.message',
  text: 'Hello world',
  checkEmpty: true,
});

Page Content Extraction

Read the current page without reaching through to the engine-specific page API:

const html = await commander.content();
const pageText = await commander.innerText(); // document.body
const mainText = await commander.innerText('main');
const title = await commander.evaluate(() => document.title);
const total = await commander.evaluate((a, b) => a + b, 2, 3);

The existing options-object form of evaluate remains supported:

const total = await commander.evaluate({
  fn: (a, b) => a + b,
  args: [2, 3],
});

Keyboard Interactions

import { pressKey, typeText, keyDown, keyUp } from 'browser-commander';

// Press a single key
await pressKey({ page, engine: 'playwright', key: 'Escape' });
await pressKey({ page, engine: 'playwright', key: 'Enter' });
await pressKey({ page, engine: 'playwright', key: 'Tab' });

// Type text
await typeText({ page, engine: 'playwright', text: 'Hello World' });

// Hold and release modifier keys
await keyDown({ page, engine: 'playwright', key: 'Control' });
await keyUp({ page, engine: 'playwright', key: 'Control' });

Managed Downloads

A download that only exists while the browser is open is not a download. Ask for downloads at any entry point - launchBrowser(), connectBrowser(), launchRealBrowser() or commander.configureDownloads() - and the manager owns the file from then on:

const { commander, downloads } = await launchBrowser({
  downloads: { directory: '/tmp/reports', conflict: 'rename' },
});

// capture() starts listening before the action runs, so a download that
// finishes in 5ms cannot slip past the registration.
const artifact = await downloads.capture({
  action: () => commander.clickButton({ selector: '#export' }),
  filename: 'q3-report.pdf', // the page's UUID name gets the caller's name
  timeout: 30000,
});

console.log(artifact.path, artifact.bytes, artifact.checksum);

await commander.destroy();
// The file is still there: it outlives the page, the context and the browser.

downloads.on(DOWNLOAD_EVENT.COMPLETED, ...) observes every download in the session, including one a person started by hand in a visible browser, and each download is reported exactly once whether it was captured or merely observed. A failed or cancelled download raises the failure rather than returning a path.

Portable Traces

startTrace() records a session into a schema-versioned bundle any of the three languages can read - the manifest, an ordered NDJSON timeline, per-checkpoint DOM snapshots and the mutation batches between them:

import { startTrace, readTrace, writeTraceViewer } from 'browser-commander';

const trace = await startTrace({
  commander,
  output: '/tmp/traces/checkout',
  mode: 'continuous', // keeps DOM mutations between checkpoints
  privacy: { redact: [/password/i, /token/i] },
});

await commander.goto({ url: 'https://example.com/checkout' });
await trace.checkpoint('cart');
await commander.clickButton({ selector: '#pay' });
await trace.checkpoint('paid');

const { path } = await trace.stop();
const bundle = await readTrace(path);
await writeTraceViewer(bundle, '/tmp/traces/checkout/viewer.html');

Redaction runs before anything reaches disk, and a run cut short by a closed page or a size limit still leaves a readable partial trace. In continuous mode the observers are reinstalled from an init script before the page's own code runs, so a trace keeps recording across navigations, SPA route changes and same-document updates, and it records what a person cannot see in the DOM - typing, checking, selecting, focus and scroll - as semantic live-state records (issue #93). initialCheckpoint (on by default) captures the page as it was before the first action, so the first interval has a base to replay onto. The viewer seeks, diffs and replays the captured DOM with scripts and network disabled; it is a diagnostic replay of what was recorded, not a pixel-accurate re-execution of the session.

Links Notation Export

The bundle stays authoritative, and writeTraceLinks() writes a second, portable view of it for tools that keep semantic, actor-aware histories - audit graphs, agent memory, cross-run diffing (issue #94):

import { readTrace, writeTraceLinks } from 'browser-commander';

const trace = await readTrace('./run.bc-trace');
await writeTraceLinks(trace, './run.lino', {
  include: ['timeline', 'checkpoints', 'control-diffs'],
});

A long-lived agent can have the same export written as the run happens, so a process that is killed still leaves everything it had recorded:

const trace = await commander.startTrace({
  output: './run.bc-trace',
  links: { output: './run.lino' },
});
// trace.links === './run.lino'

One link per line: one (timeline: ...) link per ordered event carrying its sequence, time, kind, owner ids, actor, action, target and outcome; one (checkpoint: ...) link naming its HTML, state and screenshot members by their path inside the bundle; one (control-diff: ...) link per control that changed between two checkpoints, with path, before, after and who changed it. Dropped and partial records are written as such rather than left out, no binary content is ever copied into the export, and redaction is whatever the bundle already decided - the export only reads what was written there. The representation is pinned by a golden test against links-notation 0.20, whose Python and Rust implementations read the same file; experiments/trace-links-export.mjs records a real run and parses its export from Python to prove it.

Truthful Click Results

clickElement() reports what was observed, not what was attempted:

const result = await clickElement({
  page,
  engine,
  locatorOrElement: '#submit',
});

result.status; // 'succeeded' | 'failed' | 'timed_out' | 'interrupted' | 'unverified'
result.effect; // 'confirmed' | 'not-observed' | 'contradicted'
result.evidence; // why status and effect say what they say
result.clicked; // still here: whether the click reached the element
result.verified; // still here, now derived from effect === 'confirmed'

The scroll axis (auto, preserve, none) replaces the deprecated noAutoScroll flag. scroll: 'none' never scrolls: on an engine that cannot deliver a click without scrolling it throws ScrollConstraintError naming the alternatives, rather than scrolling the page and reporting success.

commander.destroy()

await commander.destroy(); // Stop actions, cleanup

Best Practices

1. Use ctx.forEach for Loops

// BAD: Won't stop on navigation
for (const item of items) {
  await ctx.commander.click({ selector: item });
}

// GOOD: Stops immediately on navigation
await ctx.forEach(items, async (item) => {
  await ctx.commander.click({ selector: item });
});

2. Use ctx.checkStopped for Complex Logic

action: async (ctx) => {
  while (hasMorePages) {
    ctx.checkStopped(); // Throws if navigation detected

    await processPage(ctx);
    hasMorePages = await ctx.commander.isVisible({ selector: '.next' });
  }
};

3. Register Cleanup for Resources

action: async (ctx) => {
  const intervalId = setInterval(updateStatus, 1000);

  ctx.onCleanup(() => {
    clearInterval(intervalId);
    console.log('Interval cleared');
  });

  // ... rest of action
};

4. Use ctx.abortSignal with Fetch

action: async (ctx) => {
  const response = await fetch(url, {
    signal: ctx.abortSignal, // Cancels on navigation
  });
};

Extensibility / Escape Hatch

browser-commander cannot anticipate every browser API. When you need an API that is not yet supported, you can access the raw underlying engine objects directly as an official extensibility escape hatch.

Using commander.page for engine-specific APIs

makeBrowserCommander exposes commander.page — this is the raw Playwright or Puppeteer page object, not a wrapper. Use it directly for APIs browser-commander doesn't yet support:

const { browser, page } = await launchBrowser({ engine: 'playwright' });
const commander = makeBrowserCommander({ page });

// Access engine-specific API via commander.page
// Example: PDF generation (issue #35)
const pdfBuffer = await commander.page.pdf({
  format: 'A4',
  printBackground: true,
  margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' },
});

// Example: Color scheme emulation (issue #36)
await commander.page.emulateMedia({ colorScheme: 'dark' });

// Example: Keyboard interactions (issue #37)
await commander.page.keyboard.press('Escape');

// Example: Dialog handling (issue #38)
commander.page.on('dialog', async (dialog) => {
  await dialog.dismiss();
});

Using launchBrowser raw return values

launchBrowser() returns the raw { browser, page } objects from the underlying engine. You can use these directly:

const { browser, page } = await launchBrowser({ engine: 'playwright' });

// Use raw page directly for engine-specific APIs
await page.pdf({ format: 'A4' });

// Or create a commander for the unified API
const commander = makeBrowserCommander({ page });

No more _page hacks

If you previously used page._page || page to access the raw page, replace it with commander.page:

// BEFORE (fragile hack):
const rawPage = page._page || page;
await rawPage.pdf({ format: 'A4' });

// AFTER (official API):
await commander.page.pdf({ format: 'A4' });

This is the official extensibility mechanism while awaiting browser-commander to add first-class support for these APIs. Please report missing APIs so they can be added.

Command Line

The package installs a browser-commander command. The Rust and Python packages ship the same command, and the contract all three follow is docs/cli-and-bridge.md. Every command prints exactly one JSON document to stdout. The exit code is 0 on success and 1 on error. doctor exits 2 when it finds an unlisted difference, and a usage error exits 64.

npx browser-commander version
npx browser-commander goto https://example.com --headless

# Keep one browser running and drive it from later commands
npx browser-commander launch --keep-open &
npx browser-commander fill '#q' 'hello' --cdp-endpoint http://127.0.0.1:9222
npx browser-commander eval 'document.title' --cdp-endpoint http://127.0.0.1:9222

# Run a JSON command script (see tests/cli-contract/basic.json)
npx browser-commander run script.json --engine puppeteer

# JSON-RPC 2.0 over stdin/stdout, one message per line
echo '{"jsonrpc":"2.0","id":1,"method":"version"}' | npx browser-commander serve --stdio

The other commands are open, click, screenshot, pdf, trace start|stop|view, cookies import, profile migrate and doctor. Use the = form for values that start with --, for example --arg=--lang=de.

serve --stdio exposes the high-level methods (session.launch, page.goto, …). It also exposes generic handle methods (handle.root, handle.call, handle.get, handle.describe, events.subscribe, …), which reach every public Playwright and Puppeteer method. The Rust and Python ports use this bridge for the engines they do not implement natively.

For raw Chrome DevTools Protocol access from JavaScript, createCdpSession(page) (or commander.createCdpSession()) returns the same send/on/once/off/detach surface for Playwright and Puppeteer pages.

Debugging

Enable verbose mode for detailed logs:

const commander = makeBrowserCommander({ page, verbose: true });

Architecture

See src/ARCHITECTURE.md for detailed architecture documentation.

License

UNLICENSE