browser-commander
v0.21.2
Published
Universal browser automation library that supports both Playwright and Puppeteer with a unified API
Maintainers
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-commanderYou'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-webdriverDocumentation
Generate the JavaScript API reference locally with:
npm run docs:apiThe 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()anddefineBrowserTests()for Playwright/Puppeteer matrices.- Automatic fixture cleanup with
commander.destroy()andbrowser.close(). retries, per-testtimeoutMs, and failure artifacts undertest-results/browser-commanderby default.- Historical duration tracking in
tests/.browser-commander-test-timings.jsonwhen atestsdirectory exists. - Longest-first ordering and balanced shard planning. Set
BROWSER_COMMANDER_TEST_SHARD=1/3to select a shard. - Re-exported
test-anywhereAPIs such astest,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*')); // NegationPage Trigger Lifecycle
The Guarantee
When navigation is detected:
- Action is signaled to stop (AbortController.abort())
- Wait for action to finish (up to 10 seconds for graceful cleanup)
- 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 reporteddbsc-bound: their Device Bound Session Credentials key cannot leave the source profile, so those sessions expire quickly and are not copied. - Windows app-bound
v20passwords/cookies are reportedapp-bound-v20because they require the browser's privileged elevation service. - Migrated extensions are reported
mac-will-not-validate: Chromium'sSecure PreferencesMAC 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, cleanupBest 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 --stdioThe 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.
