Browser Commander
August 2, 2026 · View on GitHub
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 either Playwright or Puppeteer:
# With Playwright
npm install playwright
# Or with Puppeteer
npm install puppeteer
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();
To reuse an installed Chrome-family browser instead of downloading the engine's bundled Chromium, select a browser channel or provide its executable path. Both options are passed directly to Playwright or Puppeteer:
const { browser, page } = await launchBrowser({
engine: 'playwright',
channel: 'chrome',
// Or: executablePath: '/usr/bin/google-chrome',
headless: true,
extraArgs: ['--lang=en-US'],
// Per-flag escape hatch; `args` remains a compatible append-only alias.
ignoreDefaultArgs: ['--disable-infobars'],
});
Browser Commander applies the documented
automation-friendly launch defaults,
including --password-store=basic to avoid an additional OS credential
dialog. ignoreDefaultArgs: true omits every Browser Commander default.
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 loopback remote-debugging port, a dedicated --user-data-dir, and the
automation-friendly defaults above, or use launchRealBrowser().
launchRealBrowser() can find and start a genuine installed Chrome,
Edge, Brave, or Chromium with a loopback CDP endpoint and then attach to it. 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'],
ignoreDefaultArgs: ['--disable-infobars'],
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.
Remote-debugging, loopback, and profile arguments remain managed even when all
optional defaults are ignored; 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*')); // Negation
Page 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 } = await launchBrowser({
engine: 'playwright', // 'playwright' or 'puppeteer'
headless: false, // Run in headless mode
userDataDir: '~/.hh-apply/playwright-data', // Browser profile directory
slowMo: 150, // Slow down operations (ms)
verbose: false, // Enable debug logging
args: ['--no-sandbox', '--disable-setuid-sandbox'], // Custom Chrome args to append
storageState: './session-state.json', // Saved cookies and localStorage, as a path or object
});
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. The helper defaults to
a managed directory under ~/.browser-commander/real-browser/, rejects known
default browser-profile paths, protects its remote-debugging arguments, and
returns the spawned browserProcess, resolved cdpEndpoint, executable path,
and profile path alongside { browser, page }.
launchAndConnectRealBrowser() is an alias with identical behavior.
extraArgs appends custom switches, while ignoreDefaultArgs accepts a list
of defaults to omit (or true to omit all optional defaults). The older
args append option remains supported.
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.
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' });
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.
Debugging
Enable verbose mode for detailed logs:
const commander = makeBrowserCommander({ page, verbose: true });
Architecture
See src/ARCHITECTURE.md for detailed architecture documentation.