browser-commander 0.13.0

Universal browser automation library that supports multiple browser engines with a unified API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
# Browser Commander

A Rust library for universal browser automation that provides a unified API for different browser automation engines. The key focus is on **stoppable page triggers** - ensuring automation logic is properly mounted/unmounted during page navigation.

## Installation

Add this to your `Cargo.toml`:

```toml
[dependencies]
browser-commander = "0.9"
tokio = { version = "1.0", features = ["full"] }
```

### TLS backend

WebDriver connections use `rustls` by default, so a default build pulls no
OpenSSL and needs neither `pkg-config` nor system TLS headers. If you need the
platform's native TLS stack instead, opt in explicitly:

```toml
[dependencies]
browser-commander = { version = "0.9", features = ["native-tls"] }
```

## 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

```rust
use browser_commander::prelude::*;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Launch a browser with chromiumoxide engine
    let options = LaunchOptions::chromiumoxide().headless(true);
    let result = launch_browser(options).await?;
    println!("Browser launched: {:?}", result.browser.engine);

    // `result.page` is an `Arc<dyn EngineAdapter>` you can pass to any
    // of the navigation / interaction helpers.
    let page = result.page.as_ref();

    // Navigate to a URL
    page.goto("https://example.com").await?;

    // Click a button
    page.click("button.submit").await?;

    // Fill a text field
    page.fill("input[name='email']", "test@example.com").await?;

    Ok(())
}
```

## Features

- **Unified API** across multiple browser engines
- **Native Rust Chromiumoxide support**
- **Playwright and Puppeteer support through a Node.js bridge**
- **Built-in navigation safety handling**
- **Element visibility and scroll management**
- **Click, fill, and other interaction support with verification**
- **Managed downloads whose files outlive the browser**
- **Portable trace bundles, readable from every supported language**
- **Async/await support with Tokio**

## API Reference

### Browser Launch

```rust
use browser_commander::prelude::*;

// Launch with chromiumoxide (CDP-based)
let options = LaunchOptions::chromiumoxide()
    .headless(true)
    .user_data_dir("~/.browser-data")
    .with_extra_args(vec!["--lang=en-US".to_string()])
    .ignore_default_args(vec!["--disable-infobars".to_string()]);

let result = launch_browser(options).await?;
```

By default Browser Commander starts the installed browser the way a person
would: `--user-data-dir=<fresh temporary profile>
--remote-debugging-port=<reserved port> about:blank` and nothing else (see
[Launch Command Line and Opt-In Restrictions](../docs/feature-parity.md#launch-command-line-and-opt-in-restrictions)).
Switches the library used to add, such as `--password-store=basic`, are opt-in
`restrictions` (the `legacy-defaults` preset restores the old set).
`LaunchMode::Engine` keeps the engine launcher, `ignore_all_default_args()`
omits that launcher's own defaults, and `with_args()` remains a compatible
append-only builder.

### Playwright and Puppeteer

Rust does not have official Playwright or Puppeteer bindings. To keep the same engine names available from Rust, Browser Commander starts a local Node.js bridge and delegates operations to the official Node packages.

Install the package you want Node to resolve:

```bash
npm install playwright
npm install puppeteer
```

Then configure the bridge working directory if the packages are not installed from the process current directory:

```rust
use browser_commander::prelude::*;

let playwright = LaunchOptions::playwright()
    .headless(true)
    .node_working_dir("./js");

let puppeteer = LaunchOptions::puppeteer()
    .headless(true)
    .node_working_dir("./js");
```

Reuse a system-installed Chrome-family browser by selecting its channel or
providing an explicit executable path. `channel` applies to the Playwright and
Puppeteer bridge engines; `executable_path` also applies to Chromiumoxide:

```rust
let playwright = LaunchOptions::playwright()
    .channel("chrome")
    .headless(true)
    .node_working_dir("./js");

let chromiumoxide = LaunchOptions::chromiumoxide()
    .executable_path("/usr/bin/google-chrome")
    .headless(true);
```

You can also set a custom Node executable:

```rust
let options = LaunchOptions::playwright()
    .node_executable("/usr/local/bin/node")
    .node_working_dir("./js");
```

`LaunchOptions::fantoccini()` is still accepted for source compatibility, but `launch_browser()` does not yet start a managed WebDriver process.

### Connect to a Running Browser over CDP

`connect_browser()` attaches to an externally managed Chrome-family browser
and returns the same `LaunchResult` page adapter as `launch_browser()`. Use
Chromiumoxide natively, or the Playwright/Puppeteer Node.js bridges:

```rust
use browser_commander::prelude::*;

let native = connect_browser(
    ConnectOptions::chromiumoxide()
        .cdp_endpoint("http://127.0.0.1:9222"),
).await?;

let playwright = connect_browser(
    ConnectOptions::playwright()
        .ws_endpoint("ws://127.0.0.1:9222/devtools/browser/<id>")
        .node_working_dir("./js"),
).await?;

native.page.goto("https://example.com").await?;
```

Exactly one endpoint is required. When starting Chrome 136 or newer yourself,
pass a non-default `--user-data-dir` together with the remote-debugging flag;
Chrome intentionally disables remote debugging for its default data directory.
Cookies can be supplied explicitly with `ConnectOptions::seed_cookies()`.
Because connection is attach-only, it cannot retrofit launch flags. Start the
external process with the documented defaults or use `launch_real_browser()`.

### Installed browser cookies

Discover profiles and read cookies in the same shape accepted by browser
contexts:

```rust
use browser_commander::{
    list_browser_profiles, read_browser_cookies, BrowserCookieReadOptions,
    BrowserProfileOptions,
};

let profiles = list_browser_profiles(
    BrowserProfileOptions::default().browser("chrome"),
)?;
println!("{profiles:#?}");

let cookies = read_browser_cookies(
    BrowserCookieReadOptions::new("chrome")
        .profile("Default")
        .domain_filter("example.com")
        .ttl_minutes(60.0),
)?;
# Ok::<(), anyhow::Error>(())
```

Each `BrowserCookie` contains `name`, `value`, `domain`, `path`, `expires`,
`http_only`, `secure`, and `same_site` (serialized as the Playwright-compatible
`httpOnly` and `sameSite` names). The explicit helper supports Chrome, Edge,
Brave, Chromium, and Firefox and never sends imported data anywhere.

Decrypted results and derived keys default to
`~/.browser-commander/cookie-cache/` with owner-only permissions. A process
lock and the default 60-minute TTL keep Keychain, libsecret/KWallet, or DPAPI
access to at most one read across concurrent and repeated processes. Use
`.refresh(true)` to coordinate a new read, `.cache_dir(...)` and
`.ttl_minutes(...)` to customize storage, or `.cache(false)` to opt out.

| 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`                              |

Chromium database-version-24 domain hashes and 1601-based timestamps are
handled automatically. Current Windows Chromium may use app-bound `v20`
encryption, which requires the browser's privileged service and cannot be
decrypted by an ordinary external process. The helper reports this boundary;
use a browser-supported export or saved Browser Commander storage state for
those cookies. `.ignore_decryption_errors(true)` returns the remaining
decryptable cookies. Treat imported cookies like passwords: use a short TTL,
never commit cache files, and seed only a dedicated automation profile.

### Launch and Connect to an Installed Browser

`launch_real_browser()` discovers and starts genuine installed Chrome, Edge,
Brave, or Chromium with a dedicated profile, waits for its loopback CDP
endpoint, and attaches with Chromiumoxide or the Playwright/Puppeteer bridges:

```rust
use browser_commander::prelude::*;
use serde_json::json;

let result = launch_real_browser(
    RealBrowserOptions::playwright()
        .channel("chrome")
        .user_data_dir("/tmp/browser-commander-profile")
        .with_extra_args(vec!["--lang=en-US".to_string()])
        .ignore_default_args(vec!["--disable-infobars".to_string()])
        .seed_cookies(vec![json!({
            "name": "session",
            "value": "saved",
            "url": "https://example.com"
        })])
        .node_working_dir("./js"),
).await?;

result.page.goto("https://example.com").await?;
println!("CDP endpoint: {}", result.cdp_endpoint);
```

An explicit `executable_path` can replace channel discovery. Known default
profiles and custom arguments that override the loopback address, debugging
port, or profile are rejected. `RealBrowserLaunchResult` owns a
`browser_process` handle and terminates the spawned browser when dropped.
`launch_and_connect_real_browser()` is an alias.
The remote-debugging address, port, and profile remain managed; headless mode
is opt-in and uses `--headless=new`.

### Navigation

```rust
// Navigate to URL
goto(&page, "https://example.com", None).await?;

// Navigate with options
let nav_options = NavigationOptions {
    wait_until: WaitUntil::NetworkIdle,
    timeout: Some(30000),
};
goto(&page, "https://example.com", Some(nav_options)).await?;

// Wait for URL to match condition
wait_for_url_condition(&page, |url| url.contains("success")).await?;
```

### Element Interactions

```rust
// Click a button
click_button(&page, "button.submit", None).await?;

// Click with options
let click_options = ClickOptions {
    scroll_into_view: true,
    wait_for_navigation: true,
    ..Default::default()
};
click_button(&page, "button.submit", Some(click_options)).await?;

// Fill text area
fill_text_area(&page, "textarea.message", "Hello world", None).await?;

// Scroll element into view
scroll_into_view(&page, ".target-element", None).await?;

// Keyboard interactions
press_key(&engine, "Escape").await?;
press_key(&engine, "Enter").await?;
type_text(&engine, "Hello World").await?;
key_down(&engine, "Control").await?;
key_up(&engine, "Control").await?;
```

### Element Queries

```rust
// Check visibility
let visible = is_visible(&page, ".element").await?;

// Check if enabled
let enabled = is_enabled(&page, "button.submit").await?;

// Get text content
let text = text_content(&page, ".message").await?;

// Get attribute value
let href = get_attribute(&page, "a.link", "href").await?;

// Count matching elements
let count = count(&page, ".item").await?;
```

### Managed Downloads

A download that only exists while the browser is open is not a download. Ask for
`downloads` at any entry point - `launch_browser()`, `connect_browser()` or the
real-browser helpers - and the manager owns the file from then on:

```rust
use browser_commander::browser::{launch_browser, LaunchOptions};
use browser_commander::downloads::{CaptureOptions, DownloadOptions};
use browser_commander::interactions::click_element;

let launched = launch_browser(
    LaunchOptions::chromiumoxide()
        .downloads(DownloadOptions::default().directory("/tmp/reports")),
)
.await?;
let manager = launched.downloads.clone().expect("a manager was requested");
let page = launched.page.clone();

// capture() starts listening before the action runs, so a download that
// finishes in 5ms cannot slip past the registration.
let artifact = manager
    .capture(
        CaptureOptions::named("q3-report.pdf").within(Duration::from_secs(20)),
        async {
            click_element(page.as_ref(), "#export", &Default::default()).await?;
            Ok(())
        },
    )
    .await?;

manager.dispose().await;
drop(launched);
// The file at `artifact.path` is still there: it outlives the browser.
```

Chromiumoxide redirects downloads with `Browser.setDownloadBehavior`, which also
covers a download a person started by hand in a visible browser. The Node bridge
and Fantoccini have no such mechanism, so asking them for downloads fails with
that reason rather than quietly doing nothing.
`examples/managed_download.rs` runs the whole lifecycle against a real Chromium.

### Portable Traces

A trace is one versioned directory - manifest, ordered NDJSON timeline,
per-checkpoint DOM snapshots and the mutation batches between them - so a bundle
recorded by a JavaScript run reads back here:

```rust
use browser_commander::traces::{diff_control_state, read_trace};

let trace = read_trace("/tmp/traces/checkout")?;
println!("{} {}", trace.manifest.schema_version, trace.manifest.outcome);

for event in &trace.events {
    println!("{} {}", event["at"], event["kind"]);
}

let before = trace.state(1)?;
let after = trace.state(2)?;
for change in diff_control_state(before.as_ref(), after.as_ref()) {
    println!("{:?} {:?}", change.path, change.change);
}
```

Recording is JavaScript-only today; `docs/feature-parity.md` lists that gap along
with the rest.

### Truthful Click Results

`click_element()` reports what was observed, not what was attempted:

```rust
let result = click_element(adapter, "#submit", &ClickOptions::default()).await?;

result.status;   // Succeeded | Failed | TimedOut | Interrupted | Unverified
result.effect;   // Confirmed | NotObserved | 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
```

`ClickOptions::activation` carries three independent axes. `ClickScroll`
(`Auto`, `Preserve`, `None`) replaces the deprecated `no_auto_scroll` flag:
`ClickScroll::None` never scrolls, and on an engine that cannot deliver a click
without scrolling it returns `ClickDispatchError::ScrollConstraint` naming the
alternatives rather than scrolling the page and reporting success.

### Utilities

```rust
// Wait for a duration
wait(1000).await;

// Get current URL
let url = get_url(&page).await?;

// Parse URL
let parsed = parse_url("https://example.com/path?query=value")?;

// Evaluate JavaScript
let result: String = evaluate(&page, "document.title").await?;
```

## Modules

- `core` - Core types and traits (constants, engine adapter, logger)
- `elements` - Element operations (selectors, visibility, content)
- `interactions` - User interactions (click, scroll, fill, keyboard)
- `browser` - Browser management (launcher, navigation)
- `utilities` - General utilities (URL handling, wait operations)
- `high_level` - High-level DRY utilities
- `downloads` - Managed downloads that outlive the browser
- `traces` - Reading portable trace bundles

## Prelude

For convenience, import everything commonly needed with:

```rust
use browser_commander::prelude::*;
```

## 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 `LaunchResult` raw fields

`launch_browser()` returns a `LaunchResult` with a `browser` field that contains the engine type and configuration. When using the underlying engine crate (e.g. `chromiumoxide`) directly, the raw browser and page objects are accessible from the engine crate:

```rust
use browser_commander::prelude::*;

let options = LaunchOptions::chromiumoxide().headless(true);
let result = launch_browser(options).await?;

// Access engine metadata
println!("Engine: {:?}", result.browser.engine);
println!("User data dir: {:?}", result.browser.user_data_dir);

// For engine-specific APIs not yet in browser-commander,
// use the underlying engine crate directly alongside browser-commander.
// For example, with chromiumoxide:
//   let (browser, mut handler) = Browser::launch(BrowserConfig::builder()...).await?;
//   let page = browser.new_page("about:blank").await?;
//   // Use page.pdf(), page.emulate_media(), page.keyboard() etc.
```

### Why This Matters

- Users can adopt browser-commander incrementally while retaining access to full engine APIs
- No need for fragile `_page` private-field hacks
- Missing APIs can be [reported as issues]https://github.com/link-foundation/browser-commander/issues while users remain unblocked

## License

[UNLICENSE](../LICENSE)