SwiftBrowse documentation (version 0.1.2)

SwiftBrowse is the AI browser for browser automation and end-to-end testing: a native engine (Rust, driving Chromium over the DevTools Protocol) with one contract across a JSON test runner (swiftbrowse test), TypeScript and Python SDKs, and an MCP server for AI agents.

The complete documentation in one file. Online: https://steady-ember-82q7.here.now/ · Source: https://github.com/swiftbrowse/SwiftBrowse

SwiftBrowse

The AI browser for browser automation and end-to-end testing. A native engine (Rust, driving Chromium over the DevTools Protocol) with one contract across a test runner, TypeScript and Python SDKs, and an MCP server for AI agents.

npm install -g swiftbrowse          # or: pip install swiftbrowse
npx playwright install chromium chromium-headless-shell
swiftbrowse doctor

What it is

SwiftBrowse does one job three ways, with the same operations and the same results everywhere:

A step in a test file, an MCP tool call and an SDK method call are the same request:

{ "op": "click", "target": "Sign in", "expect": { "type": "url_contains", "value": "/dashboard" } }
await page.click('Sign in', { expect: { type: 'url_contains', value: '/dashboard' } });

Why use it

Where to go next

Platforms

Prebuilt binaries are available for Linux (x64 and arm64, glibc 2.35+), macOS (Apple Silicon and Intel) and Windows x64. They ship inside the npm and PyPI packages and as archives on the GitHub releases page. SwiftBrowse drives Chromium; other browser engines are not supported yet (see Limitations).

SwiftBrowse is young (version 0.1.2). If something breaks or the docs leave you guessing, please open an issue. Include the output of swiftbrowse doctor.


Getting started

Install SwiftBrowse, give it a Chromium to drive, and run a first test as a JSON file, in TypeScript or in Python.

Install

Pick one. Both ship the native swiftbrowse binary for your platform.

npm install -g swiftbrowse      # global CLI; or `npm install -D swiftbrowse` in a project, then `npx swiftbrowse`
pip install swiftbrowse         # installs the `swiftbrowse` command and the Python SDK

On npm, the binary comes from a platform package (swiftbrowse-linux-x64, swiftbrowse-linux-arm64, swiftbrowse-darwin-arm64, swiftbrowse-darwin-x64 or swiftbrowse-windows-x64), which npm installs automatically. On PyPI, the wheel for your platform contains it. You can also download an archive from the releases page, or build from source with cargo build --release (Rust 1.89+).

Get a browser

SwiftBrowse drives Chromium. The easiest way to get the right build is Playwright's installer, which SwiftBrowse finds automatically:

npx playwright install chromium chromium-headless-shell

The headless shell is what headless sessions use; it starts about three times faster than full Chromium. Full Chromium is used for headed sessions (--headed). An installed Chrome or Chromium on your PATH or in a standard location also works. To pick a binary yourself, set SWIFTBROWSE_CHROMIUM=/path/to/chrome.

Check what SwiftBrowse found:

swiftbrowse doctor

doctor lists the operating system, the browsers it found and their versions, ffmpeg (needed only for video), and warnings, such as running as root (Chromium's sandbox is then off).

Your first test

Make a folder with a tiny page to test, site/index.html:

<!doctype html>
<title>Hello</title>
<h1>Hello</h1>
<label>Name <input id="name"></label>
<button onclick="document.querySelector('output').textContent = 'Hi, ' + document.querySelector('#name').value">Greet</button>
<output></output>

and a test, hello.test.json:

{
  "name": "hello page",
  "tests": [
    {
      "name": "greets by name",
      "steps": [
        { "op": "navigate", "url": "index.html" },
        { "op": "expect", "condition": { "type": "visible", "target": { "by": "role", "role": "heading", "name": "Hello" } } },
        { "op": "fill", "target": "Name", "value": "Ada" },
        { "op": "click", "target": "Greet", "expect": { "type": "text_visible", "text": "Hi, Ada" } }
      ]
    }
  ]
}

Run it:

swiftbrowse test --serve site

--serve site serves the folder on a free local port and resolves relative URLs such as index.html against it. For a real app, use --base-url http://localhost:3000/ or let the runner start your dev server with --web-server (see The test runner).

swiftbrowse test finds every *.test.json and *.spec.json under the current directory (or the paths you give it). It runs the tests in parallel, each in its own isolated browser context, and prints a list of results. Results, failure screenshots and page snapshots go to test-results/.

Now break it on purpose: change "Hi, Ada" to "Hi, Bob" and run again. The failure shows what was expected, what the page actually had, a screenshot, and the page as text. Then try the other outputs:

swiftbrowse test --serve site --reporter dot --html report.html     # compact output plus an HTML report
swiftbrowse test --serve site --trace on                            # a step-by-step trace.html per test

Read Test files next for everything a test file can contain.

The example suite

The repository has a complete example: a small demo app and 29 tests covering forms, tabs, popups, iframes, uploads, network mocking and a saved login.

git clone https://github.com/swiftbrowse/SwiftBrowse
cd SwiftBrowse/examples/e2e
swiftbrowse test tests --serve site       # serves the demo app and runs every test

Tests in code

If you'd rather write tests in a programming language, use an SDK. It runs the same engine. Serve the site folder from above (for example python3 -m http.server 3000 -d site) and then:

TypeScript or JavaScript (npm install -D swiftbrowse), run with node --test:

import { test } from 'node:test';
import { launch } from 'swiftbrowse';

test('greets by name', async () => {
  const browser = await launch();
  const page = await browser.newSession();
  await page.navigate('http://localhost:3000/');
  await page.fill('Name', 'Ada');
  await page.click('Greet', { expect: { type: 'text_visible', text: 'Hi, Ada' } });
  await page.expect({ type: 'title_equals', value: 'Hello' });
  await browser.close();
});

Python (pip install swiftbrowse), run with pytest:

from swiftbrowse import launch

def test_greets_by_name():
    with launch() as browser, browser.new_session() as page:
        page.navigate("http://localhost:3000/")
        page.fill("Name", "Ada")
        page.click("Greet", expect={"type": "text_visible", "text": "Hi, Ada"})
        page.expect({"type": "title_equals", "value": "Hello"})

See TypeScript and JavaScript and Python.

For AI agents

Point an MCP client at swiftbrowse mcp:

{ "mcpServers": { "swiftbrowse": { "command": "swiftbrowse", "args": ["mcp"] } } }

See MCP server.

Quick commands

swiftbrowse snapshot https://your-app.test             # the page as compact text, with element refs
swiftbrowse screenshot https://your-app.test --full-page
swiftbrowse run script.json                           # run a list of steps, print every result
swiftbrowse daemon status                             # the warm browsers new sessions attach to

Something not working?

  1. Run swiftbrowse doctor and check that a browser was found.
  2. Run the failing test with --trace on and open the trace.html it prints.
  3. Open an issue. Include your OS, how you installed SwiftBrowse, the command you ran, its output, and swiftbrowse doctor --json.

Test files

A test file is JSON. The runner picks up every *.test.json and *.spec.json, plus *.setup.json files that run before everything else. Each test is a list of steps, and each step is one operation.

A complete file

{
  "name": "sign in",
  "base_url": "http://localhost:3000/",
  "vars": { "email": "ada@example.com" },
  "tags": ["auth"],
  "before_each": [ { "op": "navigate", "url": "login" } ],
  "after_each": [ { "op": "expect", "condition": { "type": "no_console_errors" } } ],
  "tests": [
    {
      "name": "signs in",
      "tags": ["smoke"],
      "steps": [
        { "op": "fill", "target": "Email", "value": "${email}" },
        { "op": "fill", "target": "Password", "value": "${env.TEST_PASSWORD}" },
        { "op": "click", "target": { "by": "role", "role": "button", "name": "Sign in" },
          "expect": { "type": "url_contains", "value": "dashboard" } },
        { "op": "expect", "condition": { "type": "text_equals", "target": { "by": "test_id", "id": "hello" }, "text": "Welcome, ${email}" } }
      ]
    },
    {
      "name": "rejects a wrong password",
      "retries": 2,
      "timeout_ms": 10000,
      "steps": [
        { "op": "fill", "target": "Email", "value": "${email}" },
        { "op": "fill", "target": "Password", "value": "wrong" },
        { "op": "click", "target": "Sign in" },
        { "op": "expect", "condition": { "type": "text_visible", "text": "Wrong email or password" } }
      ]
    },
    { "name": "resets the password", "skip": "waiting for the mail server", "steps": [] }
  ]
}

File fields

Field Meaning
name Suite name shown in reports. Default: the file name without .test.json.
base_url Relative navigate and new_tab URLs are resolved against it. --base-url and --serve override it.
vars Values for ${name} placeholders anywhere in the file. --var name=value overrides them.
tags Tags added to every test in the file.
before_each Steps run before every test, in the test's session.
after_each Steps run after every test, even when the test failed.
storage_state Name of a saved state to start every test from: see Saved logins.
tests The tests.
steps Instead of tests: a file that is one test (the same format as a swiftbrowse run script).

Unknown fields are an error, so a typo such as befor_each is reported instead of being ignored.

Test fields

Field Meaning
name Required. Shown as suite › name, with tags appended as @tag.
steps Required. The steps, in order. The test stops at the first failing step.
tags Tags for filtering with --grep @smoke.
skip true or a reason string. The test is reported as skipped.
only Run only the tests marked only (across all files). --forbid-only makes this an error on CI.
retries Retries for this test, overriding --retries.
timeout_ms Time budget for one attempt of this test, including hooks. Default 30 s (--test-timeout-ms).

Steps

A step is an object with op plus the operation's arguments:

{ "op": "select_option", "target": "Country", "option": "Norway" }

Every operation can be a step: navigate, click, fill, type_text, press_key, select_option, check, hover, upload_file, go_back, go_forward, reload, evaluate, wait_for, route, switch_tab, switch_frame, save_storage_state, get_screenshot and the rest.

The test runner adds one more: expect, an auto-waiting assertion with either one condition or a list of conditions. It retries until every condition holds or the expect timeout (5 s, --expect-timeout-ms, or timeout_ms on the step) runs out. See Assertions.

{ "op": "expect", "conditions": [
  { "type": "url_contains", "value": "/cart" },
  { "type": "count", "target": { "by": "css", "selector": ".item" }, "count": 3 }
] }

Verifying an action's outcome

click, hover and press_key accept an expect condition of their own. The action counts as successful only if the condition holds afterwards:

{ "op": "click", "target": "Add to cart", "expect": { "type": "text_visible", "text": "1 item" } }

fill, select_option, check and upload_file always verify their result (the field's value, the selected option, the checked state, the chosen files).

Variables

An undefined variable is an error before any test runs (exit code 2). Variables work in any string, including URLs, targets and conditions.

Setup files

A file named *.setup.json runs before all other tests. If any setup test fails, the other tests are skipped. Use it to log in once and save the state; see Saved logins.

Scripts

A file with top-level steps instead of tests is a single test. That's also the format of swiftbrowse run, which runs one script and prints every result as JSON:

{
  "name": "check the homepage",
  "steps": [
    { "op": "navigate", "url": "http://localhost:3000/" },
    { "op": "assert_page_state", "conditions": [ { "type": "title_equals", "value": "Hello" } ] }
  ]
}
swiftbrowse run check.json

A script stops at its first failing step unless it sets "stop_on_failure": false (only run supports that).

Isolation

Every test runs in a fresh, isolated browser context with its own cookies, localStorage, cache and tabs. Tests don't share state unless you share it on purpose with a saved state. Tests in different files and tests in the same file run in parallel up to --workers.

Policy inside tests

Tests target test environments, so the runner is permissive where the MCP server is careful:

See Configuration and security for the full policy.


Targets and locators

Every operation that acts on an element takes a target: either a plain string or a locator object.

Strings

A string is the quickest way to point at something:

{ "op": "fill", "target": "Email", "value": "ada@example.com" }
{ "op": "click", "target": "Sign in" }

A string tries these strategies in order and uses the first that matches:

  1. accessible name (what a screen reader announces: button text, aria-label, alt, …)
  2. form label (<label for>, a wrapping <label>, or aria-label)
  3. test id (data-testid, data-test-id, data-test)
  4. exact text
  5. partial text

The result reports which strategy matched (target.strategy), so you can tell what was found.

Locator objects

When a string is ambiguous or you want to be exact, use a locator with by:

Locator Example Matches
role { "by": "role", "role": "button", "name": "Save" } Elements with this ARIA role (implicit or explicit) and, optionally, this accessible name. Add "exact": true for an exact name match. "role": "*" matches any non-generic role.
label { "by": "label", "text": "Email" } Form controls whose label or aria-label matches. exact is optional.
test_id { "by": "test_id", "id": "cart-count" } data-testid, data-test-id or data-test.
text { "by": "text", "text": "Welcome back" } The innermost elements whose text matches. exact is optional.
css { "by": "css", "selector": "form .error" } A CSS selector.
xpath { "by": "xpath", "expr": "//table//tr[3]" } An XPath expression.
ref { "by": "ref", "ref": "e12" } A handle from a page snapshot or an earlier lookup (mostly for agents).
auto { "by": "auto", "query": "Email" } The same as a plain string.

Name and text matching ignores case and collapses whitespace. Without exact it matches substrings ("Save" matches "Save draft"); with "exact": true the whole name must match.

Prefer role, label, test_id and text over CSS and XPath. They follow what the user sees, so they survive markup changes and they check that your page is accessible.

Several matches: nth and the selection rule

The rule for choosing among several matches is strict, and the same everywhere:

  1. If at least one match is visible, hidden matches are ignored.
  2. If more than one match remains and no nth is given, the step fails with ambiguous. The error lists the candidates.
  3. With nth, the zero-based match in document order is used.
{ "op": "click", "target": { "by": "role", "role": "button", "name": "Delete", "nth": 2 } }

SwiftBrowse never silently picks the first of several matches, because that's how tests end up clicking the wrong row.

What happens before an action

Before click, hover, check, fill, select_option and the other actions touch an element, SwiftBrowse waits (up to the action's timeout) until:

The point is checked again as the mouse goes down and up. If a layout shift would make the click land on a different element, the click is blocked and retried instead of hitting the wrong thing.

Failures that happen before any input reaches the page (not found yet, hidden, disabled, covered, moving, stale) are retried automatically until the timeout. A click that was actually delivered is never repeated, so an action can't be performed twice. See Results and errors.

Frames and shadow DOM


Assertions

Assertions in SwiftBrowse wait: they check the page repeatedly until every condition holds, and fail only when the timeout runs out (5 s by default). You don't need sleeps or manual waits.

Where conditions are used

Where Form
Test files { "op": "expect", "condition": {…} } or "conditions": [{…}, {…}]
Any interface assert_page_state with conditions (recorded in the test report), wait_for with one condition
After an action click, hover and press_key take "expect": {…} to verify their outcome
TypeScript SDK await page.expect({…}) or page.expect([{…}, {…}], { timeoutMs: 10000 })
Python SDK page.expect({…}) or page.expect([...], timeout_ms=10000)

Conditions

type Fields Passes when
url_equals value The URL is exactly value.
url_contains value The URL contains value.
url_matches pattern The full URL matches the regular expression.
title_equals value The title is exactly value.
title_contains value The title contains value.
visible target At least one match is visible (or the nth match is).
hidden target No match is visible (including when nothing matches).
count target, count Exactly count elements match, visible or not.
text_visible text The text is visible somewhere on the page.
text_absent text The text is not visible anywhere.
text_equals target, text The element's whole visible text, whitespace-normalized, equals text.
text_contains target, text The element's visible text contains text.
value_equals target, value A form field's value equals value.
enabled target The element is enabled.
checked target, checked A checkbox, radio or aria-checked element is (true) or isn't (false) checked.
no_console_errors No console errors or uncaught exceptions so far in this session.
no_network_failures No failed requests or HTTP responses ≥ 400 so far in this session.
request_seen url_contains, optional method The page has sent a request whose URL contains this (with this method).

target takes the same strings and locators as actions: see Targets and locators.

Examples

{ "op": "expect", "condition": { "type": "url_matches", "pattern": "/orders/\\d+$" } }

{ "op": "expect", "conditions": [
  { "type": "text_equals", "target": { "by": "role", "role": "status" }, "text": "3 items" },
  { "type": "hidden", "target": { "by": "css", "selector": ".spinner" } },
  { "type": "no_console_errors" }
] }

{ "op": "click", "target": "Save",
  "expect": { "type": "request_seen", "url_contains": "/api/profile", "method": "PUT" } }

What a failure shows

When an assertion fails, the result lists every condition with passed and what was observed: the actual URL, the actual text, how many elements matched. You see why it failed, not just that it did. The test runner prints this and keeps a screenshot and a text snapshot of the page. With --trace you can also step through the run (see Traces, video and reports).

Timeouts

Setting Default Applies to
--expect-timeout-ms 5000 expect steps in test files
timeout_ms on a step That step only
timeoutMs / timeout_ms on SDK expect 5000 That assertion
--test-timeout-ms / timeout_ms on a test 30000 One attempt of a whole test, including hooks

Network mocking

route intercepts the page's requests. A matching request can be answered with your own response, failed with a network error, delayed, or let through. get_requests and the request_seen condition check what the page sent.

Fulfill with JSON

{ "op": "route", "url": "**/api/todos", "json": [ { "title": "Mocked one" }, { "title": "Mocked two" } ] }

json sets the body and content-type: application/json. Add the route before the page makes the request, which usually means before navigate.

Status codes, bodies and headers

{ "op": "route", "url": "**/api/todos", "status": 500, "body": "boom" }
{ "op": "route", "url": "**/api/me", "status": 401, "json": { "error": "signed out" }, "headers": { "x-request-id": "test" } }

Network errors

{ "op": "route", "url": "**/api/todos", "action": "abort", "error": "ConnectionRefused" }

Errors: Failed, Aborted, TimedOut, AccessDenied, ConnectionClosed, ConnectionReset, ConnectionRefused, NameNotResolved, InternetDisconnected, BlockedByClient.

Slow responses

{ "op": "route", "url": "**/api/todos", "json": [], "delay_ms": 600 }

Use it to test loading states:

{ "op": "click", "target": "Retry", "expect": { "type": "text_visible", "text": "Loading…" } }

Matching

Field Meaning
url A glob: ** matches anything, * anything except /, ? one character. **/api/todos* matches https://x.test/api/todos?page=2.
url_regex A regular expression matched against the full URL, instead of url.
method Only this HTTP method, e.g. "POST".
times Only the first N matching requests; after that the route removes itself.
action fulfill (the default when you give a status, body or JSON), abort, or continue to let the request through unchanged.

With neither url nor url_regex, the route matches every request.

The newest route wins. Add a general route first and a more specific or temporary one after it:

{ "op": "route", "url": "**/api/todos", "json": [ { "title": "Back again" } ] },
{ "op": "route", "url": "**/api/todos", "status": 503, "times": 1 },
{ "op": "navigate", "url": "api.html" },
{ "op": "expect", "condition": { "type": "text_visible", "text": "HTTP 503" } },
{ "op": "click", "target": "Retry", "expect": { "type": "text_visible", "text": "Back again" } }

The first request gets the 503. The one-time route then removes itself, and the retry gets the data.

Different answers per method

{ "op": "route", "url": "**/api/todos", "method": "GET", "json": [] },
{ "op": "route", "url": "**/api/todos", "method": "POST", "status": 201, "json": { "id": 42 } }

Checking what the page sent

{ "op": "expect", "condition": { "type": "request_seen", "url_contains": "/api/todos", "method": "POST" } }

get_requests returns every request the page made (method, URL, status, and whether it was mocked), newest last. list_routes shows the active routes and how many requests each has matched.

Removing routes

{ "op": "unroute", "id": "r1" }
{ "op": "unroute" }

The first form removes one route by the id that route returned; the second removes them all. Routes belong to the session, so they also go away when the test ends.

Scope

Routes apply to every tab and frame in the session, including popups and cross-site iframes. Interception is switched on only while at least one route exists, so tests without routes pay nothing for it.

In code

await page.route({ url: '**/api/todos', json: [{ title: 'Mocked' }] });
await page.navigate(`${base}/api.html`);
await page.expect({ type: 'text_visible', text: 'Mocked' });
page.route(url="**/api/todos", status=500, body="boom")

Saved logins

Log in once, save the session's state, and start other tests from it. The state is cookies plus the localStorage of the open origins, saved in Playwright's storageState format.

With the test runner

1. A setup file logs in and saves the state. Files named *.setup.json run before all other tests; if one fails, every other test is skipped.

{
  "name": "member login",
  "tests": [
    {
      "name": "logs in and saves the state",
      "steps": [
        { "op": "navigate", "url": "login.html" },
        { "op": "fill", "target": "Email", "value": "ada@example.com" },
        { "op": "fill", "target": "Password", "value": "${env.TEST_PASSWORD}" },
        { "op": "click", "target": { "by": "role", "role": "button", "name": "Sign in" },
          "expect": { "type": "url_contains", "value": "dashboard" } },
        { "op": "save_storage_state", "name": "member" }
      ]
    }
  ]
}

2. Test files start from it with storage_state:

{
  "name": "members area",
  "storage_state": "member",
  "tests": [
    { "name": "starts logged in", "steps": [
      { "op": "navigate", "url": "members.html" },
      { "op": "expect", "condition": { "type": "text_visible", "text": "Members area for Ada" } }
    ] }
  ]
}

Every test gets its own copy of the state. A test that logs out or changes localStorage doesn't affect the others.

Files without storage_state start signed out, so a suite can test signed-in and guest behavior side by side.

Where the state is stored

The runner writes it to <output-dir>/.auth/<name>.json (default test-results/.auth/). The file is readable by your user only. It contains live session cookies, so don't commit it: add test-results/ to .gitignore.

Anywhere else

save_storage_state and load_storage_state are ordinary operations, so they work over MCP and in the SDKs as well. Outside the test runner, the state is saved in the evidence directory.

await page.saveStorageState('member');
// later, in another session:
const other = await browser.newSession();
await other.loadStorageState('member');
await other.navigate(`${base}/members.html`);
page.save_storage_state("member")
other = browser.new_session()
other.load_storage_state("member")

load_storage_state sets the cookies and seeds each origin's localStorage before your next navigation, so the page sees the state on its first load.


Tabs, frames and uploads

Tabs and popups

A session can have several tabs: links with target="_blank", window.open popups, and tabs you open yourself. Actions apply to the active tab.

{ "op": "click", "target": "Open about in a new tab" },
{ "op": "switch_tab", "url_contains": "about.html" },
{ "op": "expect", "condition": { "type": "title_equals", "value": "About" } },
{ "op": "close_tab" }
Operation What it does
switch_tab Makes a tab active by index, url_contains, title_contains, or last: true (the newest). It waits for a matching tab to appear, so you can call it right after the click that opens a popup.
new_tab Opens a tab, optionally at a url, and makes it active.
list_tabs Lists the tabs with their index, URL and title, and marks the active one.
close_tab Closes the active tab (or index). Its opener, or else the last tab, becomes active.

A popup sign-in:

{ "op": "click", "target": "Sign in with popup" },
{ "op": "switch_tab", "last": true },
{ "op": "fill", "target": "Email", "value": "ada@example.com" },
{ "op": "click", "target": "Continue" },
{ "op": "switch_tab", "index": 0 },
{ "op": "expect", "condition": { "type": "text_visible", "text": "Signed in" } }

A click that opens a new tab returns as soon as the tab exists; it doesn't wait for the original page to change.

Frames

Actions and locators work in one frame at a time. switch_frame moves into an iframe, same-site or cross-site, and nested iframes one level at a time:

{ "op": "switch_frame", "target": { "by": "css", "selector": "iframe[name=payment]" } },
{ "op": "fill", "target": "Card number", "value": "4242 4242 4242 4242" },
{ "op": "switch_frame", "main": true }
Argument Selects
target The <iframe> element, located in the current frame. Use this to go into nested frames step by step.
name A frame by its name attribute, anywhere in the tab.
url_contains The first frame whose URL contains this text, anywhere in the tab.
main: true Back to the tab's top-level page.

switch_frame waits for the frame to exist. You stay in the frame until the next switch_frame or a navigation of the tab. list_frames lists the tab's frames with their names and URLs.

Clicks inside frames use real mouse input at the right position on the page, including frames inside frames. Clicks inside an iframe scaled with CSS transform are refused rather than risk missing (see Limitations).

File uploads

upload_file sets files on an <input type="file"> without opening a file dialog, and verifies the selection. It works on inputs hidden behind a styled button:

{ "op": "upload_file", "target": "Documents", "files": ["fixtures/notes.txt", "fixtures/contacts.csv"] }

The target must resolve to the file input itself. Its label works ("Documents" above), as do a test id or CSS. Targeting the styled button instead is an error that says to target the input.

For safety, uploads only read from allowed directories:

Where Allowed directories Relative paths are resolved against
Test runner the working directory and the test file's directory the test file's directory
SDKs (default test settings) the working directory the working directory
MCP and run none, unless you pass --allow-upload-dir DIR the working directory

A file outside these directories is refused with permission_denied.

Dialogs

alert() is acknowledged. confirm() and prompt() are dismissed: SwiftBrowse never accepts on the user's behalf. Both are reported in the result's notes and in page snapshots.


The test runner

swiftbrowse test [OPTIONS] [PATHS]...

swiftbrowse test finds test files, runs the tests in parallel isolated sessions, retries failures if you ask, and reports the results. Paths can be files or directories; the default is the current directory. Directories are searched recursively for *.test.json, *.spec.json and *.setup.json, skipping hidden directories, node_modules and target.

Common runs

swiftbrowse test                                   # everything under the current directory
swiftbrowse test tests/checkout.test.json          # one file
swiftbrowse test -g @smoke                         # tests tagged smoke
swiftbrowse test -g "checkout › pays" -j 1         # one test, one worker
swiftbrowse test --retries 2 --reporter dot        # CI-style
swiftbrowse test --repeat-each 20 -g "flaky one"   # hunt a flaky test
swiftbrowse test --list                            # show what would run

Parallelism

-j/--workers N runs N tests at a time; the default is half the CPU cores. Every test has its own browser context, so tests in the same file run in parallel too. Setup files run first, then everything else.

Sessions attach to warm background browsers (the daemon), one per parallel worker, so a new test starts in milliseconds instead of launching Chromium. The first run after the daemons have exited starts them, which costs about a tenth of a second per browser.

Picking tests

Option Effect
-g, --grep REGEX Only tests whose title matches. A title is file › test @tag1 @tag2.
--grep-invert REGEX Skip tests whose title matches.
--shard i/n Run part i of n, to split a suite across CI machines.
only in a file Run only tests marked only. Use --forbid-only on CI so a forgotten only fails the run.
skip in a file Report the test as skipped.

Retries, flakiness and failures

Option Effect
--retries N Retry a failing test up to N times. A test that fails and then passes is reported as flaky, and the run still passes.
--repeat-each N Run every test N times.
-x, --max-failures N Stop starting new tests after N failures (-x is 1).
--test-timeout-ms Time budget for one attempt, including hooks. Default 30000.
--expect-timeout-ms How long expect steps and actions wait for the page. Default 5000.

Where the app comes from

Option Effect
--base-url URL Resolve relative navigate URLs against this URL.
--serve DIR Serve a static directory on a free local port and use it as the base URL. Good for static sites and demos.
--web-server CMD + --web-server-url URL Start your dev server (e.g. npm run dev) before the tests, wait until the URL accepts connections, and stop the server afterwards. If something already answers at the URL, it's reused.
--var name=value Set a ${name} variable (repeatable).
swiftbrowse test --web-server "npm run dev" --web-server-url http://localhost:3000 --base-url http://localhost:3000/

Output

Option Effect
--reporter list One line per test (the default).
--reporter dot One character per test.
--reporter json The full results as JSON on stdout.
--reporter quiet Only the summary.
--output-dir DIR Where results.json, screenshots, page snapshots, traces, videos and saved states go. Default test-results.
--junit FILE Also write a JUnit XML report, which most CI systems display.
--html FILE Also write a self-contained HTML report.
--trace off|on|retain-on-failure Record a trace per test.
--video off|on|retain-on-failure Record a WebM video per test.

See Traces, video and reports.

Exit codes

Code Meaning
0 Every test passed (flaky tests count as passed).
1 At least one test failed.
2 Bad configuration or an invalid test file. Nothing ran.

Test files are validated before any browser starts: unknown fields, unknown operations, invalid arguments and undefined variables are reported with the file and step number.

Headed runs

--headed shows the browser window, which is useful while writing a test. Headed sessions launch their own browser instead of using the daemon.

swiftbrowse test tests/login.test.json --headed -j 1

Traces, video and reports

What every failure keeps

Even with no options, a failing test leaves in test-results/:

The list reporter prints this. test-results/results.json has it all in machine-readable form.

Traces

swiftbrowse test --trace retain-on-failure     # keep traces of failed tests only
swiftbrowse test --trace on                    # keep every test's trace

A trace is one self-contained trace.html per test. Open it in any browser; it needs no viewer and no server. It contains:

A trace is the fastest way to see why a test failed on CI: download it and open it.

Video

swiftbrowse test --video retain-on-failure

Records a WebM video of each test and follows tab switches. Encoding needs ffmpeg. Install it with npx playwright install ffmpeg, put ffmpeg on your PATH, or set SWIFTBROWSE_FFMPEG. Without ffmpeg, the frames are kept as JPEG images instead.

Over MCP or in the SDKs, use start_video and stop_video.

HTML report

swiftbrowse test --html report.html

A single self-contained file with every test, its status, attempts, durations and errors, with failure screenshots embedded and links to traces and videos.

JSON and JUnit

Debugging locally

swiftbrowse test tests/cart.test.json -g "removes an item" --headed -j 1 --trace on

Continuous integration

On CI, SwiftBrowse needs the swiftbrowse binary and a Chromium. Optionally, install ffmpeg for video.

GitHub Actions

name: e2e
on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright install --with-deps chromium chromium-headless-shell
      - run: npx swiftbrowse test --retries 2 --forbid-only --reporter dot --junit junit.xml --html report.html --trace retain-on-failure
        env:
          TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-results
          path: |
            test-results/
            report.html

This assumes swiftbrowse is a dev dependency (npm install -D swiftbrowse). With Python, use pip install swiftbrowse and run swiftbrowse test.

--with-deps installs the system libraries Chromium needs on a fresh Ubuntu runner.

With your app's dev server

      - run: npx swiftbrowse test --web-server "npm run start" --web-server-url http://localhost:3000 --base-url http://localhost:3000/

Sharding

Split a large suite across machines:

jobs:
  e2e:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      # …
      - run: npx swiftbrowse test --shard ${{ matrix.shard }}/4 --junit junit-${{ matrix.shard }}.xml

Tips


TypeScript and JavaScript

npm install -D swiftbrowse

The swiftbrowse package is an ES module with TypeScript types and no dependencies. It runs the native swiftbrowse binary as a child process and talks to it over JSON lines. Use it with any test framework: node:test, Vitest, Jest, Mocha.

A test

import { test, before, after } from 'node:test';
import { launch } from 'swiftbrowse';

let browser;
before(async () => { browser = await launch(); });
after(() => browser.close());

test('adds a todo', async () => {
  const page = await browser.newSession();
  await page.navigate('http://localhost:3000/');
  await page.fill('New todo', 'Write docs');
  await page.pressKey('Enter', { expect: { type: 'text_visible', text: 'Write docs' } });
  await page.expect([
    { type: 'count', target: { by: 'role', role: 'listitem' }, count: 1 },
    { type: 'no_console_errors' },
  ]);
  await page.close();
});
node --test

Start one launch() per test file (or per worker) and one newSession() per test. A session is isolated: its own cookies, storage and tabs. Sessions run concurrently, and each attaches to the warm browser daemon, so creating one takes milliseconds.

API

launch(options?) → Promise<Browser>

Starts a swiftbrowse sdk process.

Option Default Meaning
testDefaults true No approval prompts for "Delete" or "Pay" clicks, evaluate allowed, uploads from the working directory. Set it to false for the agent-safe policy.
headed false Show browser windows.
noDaemon false Launch a browser for this process instead of attaching to the warm daemon.
chromium discovered Path to a Chromium binary.
evidenceDir temp directory Where screenshots, videos and saved states go.
timeoutMs 10000 How long actions wait for their target, in ms.
allowHosts / blockHosts Restrict navigation (*.example.com patterns allowed).
uploadDirs Extra directories uploadFile may read from.
allowFileUrls false Permit file:// navigation.
throwOnError true Throw SwiftBrowseError when an operation fails.
executable found Path to the swiftbrowse binary.

browser.newSession({ headless?, viewport?, throwOnError? }) → Promise<Session>

viewport is [width, height]; the default is [1280, 800].

browser.close()

Closes every session and stops the process. Call it in after/afterAll.

Session methods

Every operation is a camelCase method: required arguments in order, then an options object whose keys are the operation's other arguments in snake_case.

await page.navigate(url, { wait_until: 'network_idle' });
await page.click('Save', { expect: { type: 'text_visible', text: 'Saved' }, timeout_ms: 10000 });
await page.fill({ by: 'label', text: 'Email' }, 'ada@example.com');
await page.selectOption('Country', 'Norway');
await page.check('I agree');
await page.uploadFile('Documents', ['fixtures/notes.txt']);
await page.route({ url: '**/api/todos', json: [] });
await page.switchTab({ url_contains: 'checkout' });
await page.switchFrame({ name: 'payment' });
await page.saveStorageState('member');
const shot = await page.getScreenshot({ full_page: true });

Plus three helpers:

Results and errors

Every method resolves to an ActionResult. If the operation failed, it throws a SwiftBrowseError instead, unless throwOnError is false. The error carries kind (for example not_found, ambiguous or verification_failed) and the full result, and its message includes any notes. Test frameworks report it like any failed assertion.

import { SwiftBrowseError } from 'swiftbrowse';
try {
  await page.click('Missing', { timeout_ms: 500 });
} catch (e) {
  if (e instanceof SwiftBrowseError && e.kind === 'not_found') { /* … */ }
}

Types

Methods, options, Target, Condition and ActionResult are typed. The types are generated from the engine's schemas, so editors autocomplete every argument and condition, and CI fails if they drift from the engine.

The binary

The SDK uses, in order: the executable option, $SWIFTBROWSE_BIN, the binary from the platform package npm installed (swiftbrowse-<platform>), then swiftbrowse on PATH. The package also installs the CLI: npx swiftbrowse test.

Parallelism with node:test

node --test runs test files in parallel processes. Tests inside a file run in order unless you use { concurrency: true }. Each file's launch() gets its own sessions on the shared warm browsers.


Python

pip install swiftbrowse

The swiftbrowse package uses the standard library only and needs Python 3.8+. The platform wheels include the native binary. It works with pytest, unittest or plain scripts.

A test with pytest

import pytest
from swiftbrowse import launch

@pytest.fixture(scope="session")
def browser():
    with launch() as b:
        yield b

@pytest.fixture
def page(browser):
    with browser.new_session() as s:
        yield s

def test_adds_a_todo(page):
    page.navigate("http://localhost:3000/")
    page.fill("New todo", "Write docs")
    page.press_key("Enter", expect={"type": "text_visible", "text": "Write docs"})
    page.expect([
        {"type": "count", "target": {"by": "role", "role": "listitem"}, "count": 1},
        {"type": "no_console_errors"},
    ])

Each new_session() is isolated: its own cookies, storage and tabs. It attaches to the warm browser daemon, so creating one per test is cheap.

API

launch(executable=None, *, ...) → Browser

Keyword Default Meaning
test_defaults True No approval prompts for "Delete" or "Pay" clicks, evaluate allowed, uploads from the working directory. Set it to False for the agent-safe policy.
headed False Show browser windows.
no_daemon False Launch a browser for this process instead of attaching to the warm daemon.
chromium discovered Path to a Chromium binary.
evidence_dir temp directory Where screenshots, videos and saved states go.
timeout_ms 10000 How long actions wait for their target, in ms.
allow_hosts / block_hosts Restrict navigation.
upload_dirs Extra directories upload_file may read from.
allow_file_urls False Permit file:// navigation.
throw_on_error True Raise SwiftBrowseError when an operation fails.

Browser and Session are context managers, so with closes them.

browser.new_session(*, headless=None, viewport=None, throw_on_error=None) → Session

viewport is (width, height); the default is (1280, 800).

Session methods

Every operation is a method with its documented signature: required arguments positionally, the rest by keyword.

page.navigate(url, wait_until="network_idle")
page.click("Save", expect={"type": "text_visible", "text": "Saved"}, timeout_ms=10000)
page.fill({"by": "label", "text": "Email"}, "ada@example.com")
page.select_option("Country", "Norway")
page.check("I agree")
page.upload_file("Documents", ["fixtures/notes.txt"])
page.route(url="**/api/todos", json=[])
page.switch_tab(url_contains="checkout")
page.switch_frame(name="payment")
page.save_storage_state("member")
shot = page.get_screenshot(full_page=True)

Plus:

Results and errors

Methods return the ActionResult as a dict. If the operation failed, they raise SwiftBrowseError instead (unless throw_on_error=False), with .kind and the full .result.

from swiftbrowse import SwiftBrowseError

try:
    page.click("Missing", timeout_ms=500)
except SwiftBrowseError as e:
    assert e.kind == "not_found"

Threads

A Browser sends one request at a time; calls from several threads are serialized. For parallel tests, use pytest-xdist: each worker process gets its own launch().

The binary

The SDK uses, in order: the executable argument, $SWIFTBROWSE_BIN, the binary inside the installed wheel, then swiftbrowse on PATH. The package also installs the swiftbrowse command (swiftbrowse test, swiftbrowse mcp, …), and python -m swiftbrowse works too.


MCP server

swiftbrowse mcp serves the Model Context Protocol over stdio, so AI agents (Claude Code, Claude Desktop, Cursor and other MCP clients) can drive a real browser.

Setup

{
  "mcpServers": {
    "swiftbrowse": { "command": "swiftbrowse", "args": ["mcp"] }
  }
}

With npx: "command": "npx", "args": ["-y", "swiftbrowse", "mcp"]. In Claude Code:

claude mcp add swiftbrowse -- swiftbrowse mcp

The server starts launching the default session's browser right away, so the first tool call doesn't wait (turn this off with --no-prewarm). It supports MCP protocol versions 2024-11-05, 2025-03-26 and 2025-06-18.

Tools

Every operation is a tool with the same name and arguments, plus:

Tool Does
session_create Start another isolated session (its own cookies, storage and tabs).
session_close Close a session.
session_list List open sessions.

Tools use the default session unless given a session_id.

Built for agents

In a benchmark of typical agent tasks, this used about half the tool calls of Playwright MCP and far fewer bytes of context (see Benchmarks).

Safety defaults

The MCP server is careful by default, because an agent is acting on the user's behalf:

{ "command": "swiftbrowse", "args": ["mcp", "--allow-host", "staging.example.com", "--allow-upload-dir", "/home/me/fixtures"] }

From agent session to test

Every tool call is an operation, so a flow an agent worked out can be saved as a test file. Put the tool calls in steps as { "op": <tool>, ...arguments } and run them with swiftbrowse test or swiftbrowse run.


Operations

Every operation SwiftBrowse can perform. This page is generated from the engine's own schemas (swiftbrowse sdk --schema, version 0.1.2), so it matches the binary exactly.

The same operation has the same name and arguments everywhere:

A target is a string or a locator object: see Targets and locators. A condition is described in Assertions. Every operation returns an ActionResult.

Navigation

navigate

Open a URL, wait for the requested load state and report the HTTP status.

Argument Type Description
url (required) string
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).
wait_until "dom_content_loaded" | "load" | "network_idle" Default: "load".

TypeScript: page.navigate(url, { …options }) · Python: page.navigate(url, **options)

go_back

Go back one history entry and wait for the page to load.

Argument Type Description
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.goBack({ …options }) · Python: page.go_back(**options)

go_forward

Go forward one history entry and wait for the page to load.

Argument Type Description
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.goForward({ …options }) · Python: page.go_forward(**options)

reload

Reload the page and wait for it to load.

Argument Type Description
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.reload({ …options }) · Python: page.reload(**options)

Reading the page

get_page_snapshot

Compact structured view of the page: URL, title, headings, visible interactive elements with ref handles, dialogs, alerts and validation messages. Use since_last to get only changes.

Argument Type Description
format "text" | "json" Default: "text".
max_elements integer
mode "compact" | "full" Default: "compact".
since_last boolean Return only what changed since this session's previous snapshot. Default: false.

TypeScript: page.getPageSnapshot({ …options }) · Python: page.get_page_snapshot(**options)

get_accessibility_tree

Bounded accessibility tree (role, name, value) from the browser.

Argument Type Description
max_nodes integer

TypeScript: page.getAccessibilityTree({ …options }) · Python: page.get_accessibility_tree(**options)

find_element

Resolve a locator. Fails with ambiguous when several elements match and no nth is given.

Argument Type Description
target (required) target A string or a locator: see Targets and locators.
all boolean Return every match instead of requiring a unique one. Default: false.

TypeScript: page.findElement(target, { …options }) · Python: page.find_element(target, **options)

get_screenshot

Save a PNG screenshot to the evidence directory and return its path.

Argument Type Description
format ImageFormat Default: "png".
full_page boolean Default: false.
name string File name stem inside the evidence directory; path separators are rejected.

TypeScript: page.getScreenshot({ …options }) · Python: page.get_screenshot(**options)

get_dom_snapshot

A self-contained, script-free HTML copy of the page (form state, shadow roots, live CSS) for trace viewers. Large; prefer get_page_snapshot to read a page.

Argument Type Description
mark string Mark this element (a ref) with data-sb-target in the snapshot.

TypeScript: page.getDomSnapshot({ …options }) · Python: page.get_dom_snapshot(**options)

Actions

click

Click an element after checking it is visible, enabled and not covered. Pass expect to verify the outcome. Consequential actions (buy, pay, delete, ...) need approved=true.

Argument Type Description
target (required) target A string or a locator: see Targets and locators.
approved boolean Set after a human approved a consequential action. Default: false.
consequential boolean Override the policy's consequential-action detection.
expect condition See Assertions.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.click(target, { …options }) · Python: page.click(target, **options)

hover

Move the mouse over an element after checking it is visible and not covered. Pass expect to verify what appears.

Argument Type Description
target (required) target A string or a locator: see Targets and locators.
expect condition See Assertions.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.hover(target, { …options }) · Python: page.hover(target, **options)

check

Leave a checkbox, radio or aria-checked element checked (or unchecked with checked=false), clicking only if needed, and verify the state.

Argument Type Description
target (required) target A string or a locator: see Targets and locators.
checked boolean The state to leave it in. Default true; false unchecks. Default: true.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.check(target, { …options }) · Python: page.check(target, **options)

fill

Replace a field's value and verify the resulting value.

Argument Type Description
target (required) target A string or a locator: see Targets and locators.
value (required) string
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.fill(target, value, { …options }) · Python: page.fill(target, value, **options)

type_text

Type text key by key into the target or focused element.

Argument Type Description
text (required) string
target target A string or a locator: see Targets and locators.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.typeText(text, { …options }) · Python: page.type_text(text, **options)

press_key

Press a key or chord, optionally on a target and with an expected outcome.

Argument Type Description
key (required) string Key name such as Enter, Tab, Escape, ArrowDown, a, or a chord like Control+a.
expect condition See Assertions.
target target A string or a locator: see Targets and locators.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.pressKey(key, { …options }) · Python: page.press_key(key, **options)

select_option

Choose an option of a and verify the selection. Only files inside directories the policy allows.

Argument Type Description
target (required) target A string or a locator: see Targets and locators.
files (required) string[] Files to select; an empty list clears the selection. Must lie in a directory the policy allows for uploads.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.uploadFile(target, files, { …options }) · Python: page.upload_file(target, files, **options)

evaluate

Run a JavaScript expression in the page and return its JSON result. Only available when the session policy allows scripts (the test runner does; MCP needs --allow-evaluate).

Argument Type Description
expression (required) string JavaScript expression evaluated in the page; promises are awaited and the JSON-serializable result is returned. Disabled unless the policy allows it.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.evaluate(expression, { …options }) · Python: page.evaluate(expression, **options)

Waiting and assertions

wait_for

Wait until a condition holds or the timeout expires.

Argument Type Description
condition (required) condition See Assertions.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).

TypeScript: page.waitFor(condition, { …options }) · Python: page.wait_for(condition, **options)

assert_page_state

Check URL, title, element, text, value or diagnostics conditions. Recorded in the test report.

Argument Type Description
conditions (required) condition[] Every condition must hold: see Assertions.
name string Label recorded in the test report.
timeout_ms integer Wait up to this long for all conditions to hold. Default 0: check once.

TypeScript: page.assertPageState(conditions, { …options }) · Python: page.assert_page_state(conditions, **options)

Network

route

Mock the network: requests matching a URL glob or regex (and method) are fulfilled with a status/body/JSON, aborted with a network error, or let through. Newest route wins; times limits uses.

Argument Type Description
action "fulfill" | "abort" | "continue" Default: "fulfill".
body string Response body for fulfill.
delay_ms integer Wait this long before answering (to test loading states).
error string Network error for abort: Failed, Aborted, TimedOut, AccessDenied, ConnectionClosed, ConnectionReset, ConnectionRefused, NameNotResolved, InternetDisconnected, BlockedByClient.
headers object Default: {}.
json any JSON response body for fulfill; sets content-type: application/json.
method string Only this HTTP method (e.g. POST).
status integer Response status for fulfill (default 200).
times integer Only the first N matching requests; afterwards the route is removed.
url string URL glob: ** matches anything, * anything but /, ? one character, e.g. **/api/todos*.
url_regex string Regular expression matched against the full URL, instead of url.

TypeScript: page.route({ …options }) · Python: page.route(**options)

unroute

Remove a route by id, or all routes.

Argument Type Description
id string Route id from route; all routes when omitted.

TypeScript: page.unroute({ …options }) · Python: page.unroute(**options)

list_routes

Active routes with how often each matched.

No arguments.

TypeScript: page.listRoutes() · Python: page.list_routes()

get_requests

Requests the page made (method, URL, status, whether mocked), newest last.

No arguments.

TypeScript: page.getRequests() · Python: page.get_requests()

Tabs and frames

new_tab

Open a new tab (optionally at a URL) in this session and make it active.

Argument Type Description
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).
url string Page to open in the new tab; blank when omitted.
wait_until "dom_content_loaded" | "load" | "network_idle" Default: "load".

TypeScript: page.newTab({ …options }) · Python: page.new_tab(**options)

list_tabs

Open tabs of this session, including popups and target=_blank pages, with the active one marked.

No arguments.

TypeScript: page.listTabs() · Python: page.list_tabs()

switch_tab

Make a tab active by index, URL, title, or the newest one; waits for a matching tab to open (e.g. a popup).

Argument Type Description
index integer Tab index from list_tabs.
last boolean The newest tab. Default: false.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).
title_contains string First tab whose title contains this text. Waits for it to open.
url_contains string First tab whose URL contains this text. Waits for it to open (e.g. a popup).

TypeScript: page.switchTab({ …options }) · Python: page.switch_tab(**options)

close_tab

Close a tab (the active one by default); the opener or the last tab becomes active.

Argument Type Description
index integer Tab index from list_tabs; the active tab when omitted.

TypeScript: page.closeTab({ …options }) · Python: page.close_tab(**options)

list_frames

Frames (iframes) of the active tab with their names and URLs.

No arguments.

TypeScript: page.listFrames() · Python: page.list_frames()

switch_frame

Act inside an iframe (by its element, name or URL) until switch_frame with main=true or a navigation. Works for cross-site iframes.

Argument Type Description
main boolean Back to the tab's main frame. Default: false.
name string Frame name (<iframe name>), anywhere in the tab.
target target A string or a locator: see Targets and locators.
timeout_ms integer How long to wait, in ms. Default: the session's timeout (10 s; in swiftbrowse test, --expect-timeout-ms).
url_contains string First frame whose URL contains this text, anywhere in the tab.

TypeScript: page.switchFrame({ …options }) · Python: page.switch_frame(**options)

Saved state

save_storage_state

Save this session's cookies and the localStorage of its open origins (a logged-in state) under a name, in Playwright's storageState format.

Argument Type Description
name (required) string Name of the saved state (letters, digits, '-', '_', '.'); stored as <name>.json in the state directory, in Playwright's storageState format.

TypeScript: page.saveStorageState(name) · Python: page.save_storage_state(name)

load_storage_state

Restore a saved state (cookies and localStorage) into this session, e.g. to start logged in.

Argument Type Description
name (required) string Name of the saved state (letters, digits, '-', '_', '.'); stored as <name>.json in the state directory, in Playwright's storageState format.

TypeScript: page.loadStorageState(name) · Python: page.load_storage_state(name)

Video

start_video

Start recording a video of the active tab (follows tab switches).

No arguments.

TypeScript: page.startVideo() · Python: page.start_video()

stop_video

Stop recording and save the video (WebM via ffmpeg) to the evidence directory.

Argument Type Description
name string File name stem in the evidence directory (default video).

TypeScript: page.stopVideo({ …options }) · Python: page.stop_video(**options)

Diagnostics and reports

get_console_errors

Console errors and uncaught exceptions seen in this session.

No arguments.

TypeScript: page.getConsoleErrors() · Python: page.get_console_errors()

get_network_failures

Failed requests and HTTP >= 400 responses seen in this session.

No arguments.

TypeScript: page.getNetworkFailures() · Python: page.get_network_failures()

get_test_report

Assertions, failed actions, diagnostics and evidence for this session.

No arguments.

TypeScript: page.getTestReport() · Python: page.get_test_report()

get_capabilities

Backend, engine version and per-feature support for this session.

No arguments.

TypeScript: page.getCapabilities() · Python: page.get_capabilities()


Command line

Every swiftbrowse command and option, generated from --help of version 0.1.2.

The AI browser for browser automation and end-to-end testing

Usage: swiftbrowse <COMMAND>

Commands:
  doctor        Report the environment: OS, browser binaries, versions, desktop WebView dependencies
  capabilities  Start a session and print the backend's capability report
  run           Run a JSON script of operations and print every result plus the test report
  test          Run end-to-end tests: every `*.test.json` / `*.spec.json` under the given paths, in parallel sessions, with retries and list/JSON/JUnit/HTML reports
  snapshot      Navigate and print a compact page snapshot
  screenshot    Navigate and save a screenshot into the evidence directory
  mcp           Serve the Model Context Protocol over stdio
  sdk           Serve the SDK protocol (JSON lines on stdin/stdout) for the TypeScript and Python SDKs
  daemon        Manage the warm-browser daemon that headless sessions attach to
  help          Print this message or the help of the given subcommand(s)

Options:
  -h, --help     Print help
  -V, --version  Print version

swiftbrowse test

Run end-to-end tests: every `*.test.json` / `*.spec.json` under the given paths, in parallel sessions, with retries and list/JSON/JUnit/HTML reports

Usage: swiftbrowse test [OPTIONS] [PATHS]...

Arguments:
  [PATHS]...  Test files or directories to search for `*.test.json` and `*.spec.json`, plus `*.setup.json` files that run first (e.g. to log in and save the state). Default: the current directory

Options:
  -j, --workers <WORKERS>
          Tests run at the same time, each in its own isolated session (default: half the CPU cores)
      --retries <RETRIES>
          Retry a failing test this many times; a test that passes on retry is reported as flaky [default: 0]
  -g, --grep <GREP>
          Only run tests whose title (`file › test @tag`) matches this regular expression
      --grep-invert <GREP_INVERT>
          Skip tests whose title matches this regular expression
      --repeat-each <REPEAT_EACH>
          Run every test this many times (to find flaky tests) [default: 1]
      --shard <SHARD>
          Run one part of the suite, e.g. `2/4`, to split it across CI machines
      --max-failures <MAX_FAILURES>
          Stop starting tests after this many failures (`-x` is `--max-failures 1`)
  -x
          
      --forbid-only
          Fail if any test is marked `only` (use on CI)
      --base-url <BASE_URL>
          Resolve relative `navigate` URLs against this URL (overrides a file's `base_url`)
      --serve <DIR>
          Serve this directory over HTTP on a free local port and use it as the base URL
      --web-server <COMMAND>
          Start this command (e.g. `npm run dev`) before the tests and stop it after; tests wait until `--web-server-url` accepts connections. An already running server is reused
      --web-server-url <URL>
          
      --var <NAME=VALUE>
          Set a `${name}` variable, overriding the file's `vars` (repeatable)
      --expect-timeout-ms <EXPECT_TIMEOUT_MS>
          How long assertions and actions wait for the page (default 5000) [default: 5000]
      --test-timeout-ms <TEST_TIMEOUT_MS>
          Budget for one test attempt, including hooks (default 30000) [default: 30000]
      --trace <TRACE>
          Record a trace: a screenshot and the page as text after every step, in one self-contained trace.html per test. `retain-on-failure` keeps failed tests' only [default: off] [possible values: off, on, retain-on-failure]
      --video <VIDEO>
          Record a video (WebM, needs ffmpeg: `npx playwright install ffmpeg`) [default: off] [possible values: off, on, retain-on-failure]
      --reporter <REPORTER>
          Console output: `list` (default), `dot`, `json` (results on stdout) or `quiet` [default: list]
      --output-dir <OUTPUT_DIR>
          Where screenshots, page snapshots and results.json go [default: test-results]
      --junit <JUNIT>
          Also write a JUnit XML report here
      --html <HTML>
          Also write a self-contained HTML report here
      --list
          Print the tests that would run, without running them
      --headed
          Show the browser window (needs a display)
      --chromium <CHROMIUM>
          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>
          Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>
          Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>
          
      --allow-file-urls
          Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>
          Default per-operation timeout
      --no-approval
          Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon
          Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate
          Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>
          Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help
          Print help

swiftbrowse run

Run a JSON script of operations and print every result plus the test report

Usage: swiftbrowse run [OPTIONS] <SCRIPT>

Arguments:
  <SCRIPT>  Script file, or `-` for stdin

Options:
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help

swiftbrowse mcp

Serve the Model Context Protocol over stdio

Usage: swiftbrowse mcp [OPTIONS]

Options:
      --no-prewarm                   Do not launch the default session's browser until the first tool call
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help

swiftbrowse sdk

Serve the SDK protocol (JSON lines on stdin/stdout) for the TypeScript and Python SDKs

Usage: swiftbrowse sdk [OPTIONS]

Options:
      --test-defaults                Test defaults, as in `swiftbrowse test`: no approval for consequential clicks, `evaluate` allowed, uploads from the working directory
      --schema                       Print every operation with its JSON schema and exit (for code generation)
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help

swiftbrowse doctor

Report the environment: OS, browser binaries, versions, desktop WebView dependencies

Usage: swiftbrowse doctor [OPTIONS]

Options:
      --json  
  -h, --help  Print help

swiftbrowse snapshot

Navigate and print a compact page snapshot

Usage: swiftbrowse snapshot [OPTIONS] <URL>

Arguments:
  <URL>  

Options:
      --full                         
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help

swiftbrowse screenshot

Navigate and save a screenshot into the evidence directory

Usage: swiftbrowse screenshot [OPTIONS] <URL>

Arguments:
  <URL>  

Options:
      --name <NAME>                  File name stem (no path separators) [default: screenshot]
      --full-page                    
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help

swiftbrowse capabilities

Start a session and print the backend's capability report

Usage: swiftbrowse capabilities [OPTIONS]

Options:
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help

swiftbrowse daemon

Manage the warm-browser daemon that headless sessions attach to

Usage: swiftbrowse daemon <COMMAND>

Commands:
  run     Run the daemon in the foreground (clients start it automatically)
  status  Show whether the daemons are running and what they are doing
  stop    Stop every running daemon; their browsers and all contexts close
  help    Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

Exit codes

swiftbrowse test: 0 every test passed (flaky tests count as passed), 1 a test failed, 2 the configuration or a test file is invalid (nothing ran). swiftbrowse run: 0 when every step succeeded, otherwise 1.


Results and errors

Every operation, from every interface, returns the same ActionResult.

{
  "ok": true,
  "op": "click",
  "action_id": "s1-2",
  "session_id": "s1",
  "target": {
    "element": { "ref": "e1", "role": "button", "name": "Sign in", "tag": "button" },
    "strategy": "role",
    "candidates": 1
  },
  "page": { "url": "http://localhost:3000/dashboard", "title": "Dashboard" },
  "verification": {
    "status": "passed",
    "checks": [
      { "condition": { "type": "url_contains", "value": "dashboard" }, "passed": true,
        "observed": { "url": "http://localhost:3000/dashboard" } }
    ]
  },
  "timing": { "started_at_ms": 1792905230645, "duration_ms": 59, "timeout_ms": 10000 }
}

Fields that would be empty (data, error, evidence, notes) are left out.

Field Meaning
ok The operation succeeded and any requested verification passed.
target The element acted on, which locator strategy found it, and how many candidates there were.
page URL and title after the operation.
verification.status passed, failed, or not_checked. A click without expect reports not_checked: the input was delivered, but nothing confirmed the outcome.
verification.checks Each condition with passed and what was observed.
data The operation's output: a snapshot, an evaluate result, a list of tabs, …
error Present when ok is false: kind, message, recoverable, and sometimes details (such as the candidates of an ambiguous locator).
evidence Files written (screenshots, videos, saved states), with paths.
notes What else happened: navigated, url_changed, dom_changed, a dialog that was dismissed, a wait for a client-rendered page, …

Error kinds

kind Recoverable Meaning
not_found yes No element matched (yet).
not_actionable yes The element is hidden, disabled, covered or moving.
stale_element yes The element was removed or replaced.
timeout yes A wait or an operation ran out of time.
ambiguous no Several elements matched and no nth was given. details lists them.
verification_failed no The action happened, but its expect didn't hold. Don't repeat it blindly.
approval_required no A consequential click needs approved: true (MCP's default policy).
permission_denied no The policy forbids it (host not allowed, upload outside allowed directories, evaluate off).
navigation_failed no The page didn't load (DNS, connection, certificate, …).
invalid_argument no The request is malformed.
unsupported no This backend can't do it.
session_not_found, session_limit no Session problems.
backend no The browser failed unexpectedly.

"Recoverable" means a later attempt could succeed without changing the request. SwiftBrowse already retries these automatically until the action's timeout, but only when the failure happened before any input reached the page. A click that was delivered is never retried.

A failed interaction also attaches a screenshot to evidence.

The test report

get_test_report returns the session's verdict: every assertion with its result, failed actions, console errors, network failures and evidence. The test runner builds its reports from the same data.


Configuration and security

Environment variables

Variable Effect
SWIFTBROWSE_CHROMIUM Chromium binary to use for every session. It also disables the automatic switch to the headless shell.
SWIFTBROWSE_HEADLESS_SHELL Headless-shell binary for headless sessions.
SWIFTBROWSE_FFMPEG ffmpeg binary for video.
SWIFTBROWSE_BIN The swiftbrowse binary the SDKs run.
SWIFTBROWSE_DAEMON=off Don't use the warm-browser daemon; launch a browser per process (same as --no-daemon).
SWIFTBROWSE_DAEMONS How many warm browsers may run at once. Default: the number of CPUs, at most 8.
SWIFTBROWSE_DAEMON_SOCKET Use a private daemon at this socket path, for example to isolate a benchmark.
SWIFTBROWSE_TRANSPORT=websocket On Linux and macOS, talk to Chromium over a local DevTools port instead of a pipe.
SWIFTBROWSE_NO_SANDBOX=1 Run Chromium without its sandbox. This is automatic as root, or when the system has no usable sandbox.
SWIFTBROWSE_TRACE=1 Print launch and navigation timings to stderr.
PLAYWRIGHT_BROWSERS_PATH Where Playwright-installed browsers are looked for, besides the default cache folders.

Finding a browser

In order: --chromium / SWIFTBROWSE_CHROMIUM, then the newest Playwright-installed build (~/.cache/ms-playwright, ~/Library/Caches/ms-playwright or %LOCALAPPDATA%\ms-playwright), then standard Chrome and Chromium install locations and PATH. Headless sessions use Chromium's headless shell when one is installed (npx playwright install chromium-headless-shell). swiftbrowse doctor shows what was found.

The warm-browser daemon

The first headless session starts a background swiftbrowse daemon that keeps a Chromium running. Later sessions, whether from another test run, an SDK or an MCP server, attach to it instead of launching a browser. From process start to a loaded page takes about 50 ms instead of about 160 ms.

swiftbrowse daemon status
swiftbrowse daemon stop

Policy

The same options exist on run, test, mcp, sdk, snapshot, screenshot and capabilities:

Option Effect
--allow-host HOST Only allow navigation to these hosts (repeatable; *.example.com allowed).
--block-host HOST Never navigate to these hosts.
--allow-file-urls Permit file:// URLs. Only http, https, about and data are allowed by default.
--allow-evaluate Allow evaluate. Always on in run, test and the SDKs' test defaults.
--allow-upload-dir DIR Let upload_file read files from DIR (repeatable).
--no-approval Don't require approval for consequential clicks. On in test and the SDKs' test defaults.
--timeout-ms MS Default per-operation timeout (10000; in test, --expect-timeout-ms).
--evidence-dir DIR Where screenshots, videos and saved states are written.
--headed Show the browser.
--no-daemon Launch a browser instead of using the daemon.

Security model


Benchmarks

All numbers come from benchmark scripts in the repository, which you can run yourself. They come from one machine (a 4-CPU cloud container) and small demo apps. On a slow app, the app's own time dominates, so read them as the tool's overhead, not as a promise about your suite.

Test runner vs Playwright Test

The same 29 tests, written three ways:

They cover forms, filtering, hover menus, back and forward, reloads, target=_blank and popup tabs, same-site, cross-site and nested iframes, file upload, network mocking (data, errors, aborts, delays, times, POST checks) and a saved login.

All three used the same app server, the same Chromium headless-shell binary, and Playwright Test 1.64.0. Each run is a whole CLI command, start to exit, as CI runs it. The table shows the median of 5 interleaved runs. At most workers tests run at once in every tool. "First run" means no warm browser was running, which is what a fresh CI job gets. Every suite passed on every run.

Tests Workers Playwright Test swiftbrowse test SwiftBrowse SDK swiftbrowse test, first run
29 1 10454 ms 4519 ms (2.3×) 5065 ms (2.1×) 4657 ms (2.2×)
29 4 6511 ms 2150 ms (3.0×) 2814 ms (2.3×) 2351 ms (2.8×)
145 1 46427 ms 22900 ms (2.0×) 24188 ms (1.9×) 22973 ms (2.0×)
145 4 23904 ms 10343 ms (2.3×) 12034 ms (2.0×) 10699 ms (2.2×)

Where the time goes: Playwright Test starts Node, transforms the test files and starts a browser per worker. SwiftBrowse is a native process whose sessions attach to warm browsers in milliseconds. Much of the remaining time is the demo app's own deliberate waits (120 ms to add an item, 150 ms to sign in, 600 ms in the loading-state test), which no tool can skip.

cd bench && npm install && node e2e.mjs ../target/release/swiftbrowse

Agent tasks vs Playwright MCP

The benchmark drives SwiftBrowse MCP and Playwright MCP 0.0.83 over the same transport (MCP stdio), the way an agent does: take a snapshot, read element refs from it, act, confirm the outcome. Playwright MCP ran twice: with its defaults, and tuned with --timeout-settle 0, its best case. Both used the same Chromium binary; medians of 15 iterations.

Task SwiftBrowse MCP Playwright MCP (tuned) Playwright MCP (defaults)
Sign-up form: fill ×2, select, check, submit, confirm 335 ms · 7 calls · 1.1 KB 472 ms · 8 calls · 2.2 KB 1,302 ms · 8 calls · 2.2 KB
400-row page: snapshot, click "Edit row 250", confirm 166 ms · 3 calls · 6.6 KB 1,037 ms · 4 calls · 152.6 KB 1,604 ms · 4 calls · 152.6 KB
400-row page, direct: click by role and name, confirm 153 ms · 2 calls · 0.2 KB 834 ms · 3 calls · 0.8 KB 1,415 ms · 3 calls · 0.8 KB
Cold start to first loaded page, own browser 162 ms 704 ms 715 ms
Cold start to first loaded page, warm daemon 52 ms 704 ms 715 ms

The KB column is how many bytes the tools returned into the agent's context. SwiftBrowse snapshots are bounded to 150 elements, so on the 400-row page the snapshot is smaller but doesn't contain row 250: the agent has to locate it, which the snapshot itself tells it to do.

cd bench && npm install && node agents.mjs ../target/release/swiftbrowse --iter 15 --cold 10

Library operations vs Playwright

Identical tasks with the Playwright 1.64.0 library API and SwiftBrowse (through MCP over stdio), using the same headless-shell binary. p50 in ms:

Task SwiftBrowse Playwright
Cold start to loaded page, own browser 159 153 (parity)
Cold start to loaded page, warm daemon 55 153
navigate 19.8 20.8
fill (email) 7.1 33.0
click (checkbox label) 16.9 37.6
Whole form task 336 452
Snapshot, 400-row page 18.3 157
Role and name lookup, 400-row page 4.4 20.3

With its own browser, cold start is at parity: both tools are bounded by Chromium's startup. SwiftBrowse does more work in some steps (fill reads the value back; navigate waits for client-rendered pages that are still empty).

cd bench && npm install && node bench.mjs ../target/release/swiftbrowse --iter 50 --cold 20

Limitations and roadmap

SwiftBrowse is at version 0.1.2. Here is what it doesn't do yet, so you don't have to find out the hard way.

Not supported yet

Known limitations

Planned

Something missing that you need? Open an issue and say what you're trying to test.

# SwiftBrowse documentation (version 0.1.2)

> SwiftBrowse is the AI browser for browser automation and end-to-end testing: a native engine (Rust, driving Chromium over the DevTools Protocol) with one contract across a JSON test runner (`swiftbrowse test`), TypeScript and Python SDKs, and an MCP server for AI agents.

The complete documentation in one file. Online: https://steady-ember-82q7.here.now/ · Source: https://github.com/swiftbrowse/SwiftBrowse

<!-- https://steady-ember-82q7.here.now/index.html -->

## SwiftBrowse

The AI browser for browser automation and end-to-end testing. A native engine (Rust, driving Chromium over the DevTools Protocol) with one contract across a test runner, TypeScript and Python SDKs, and an MCP server for AI agents.

```sh
npm install -g swiftbrowse          # or: pip install swiftbrowse
npx playwright install chromium chromium-headless-shell
swiftbrowse doctor
```

### What it is

SwiftBrowse does one job three ways, with the same operations and the same results everywhere:

- **An end-to-end test runner.** `swiftbrowse test` runs tests written as JSON files: parallel workers, retries and flaky detection, sharding, saved logins, network mocking, traces, video, and JUnit/HTML reports.
- **Tests in code.** The [TypeScript](https://steady-ember-82q7.here.now/typescript.html) and [Python](https://steady-ember-82q7.here.now/python.html) SDKs expose every operation as a typed method, for use with `node:test`, Vitest, Jest, pytest or unittest.
- **A browser for AI agents.** `swiftbrowse mcp` serves the Model Context Protocol. A flow an agent works out over MCP can be saved as a test unchanged.

A step in a test file, an MCP tool call and an SDK method call are the same request:

```json
{ "op": "click", "target": "Sign in", "expect": { "type": "url_contains", "value": "/dashboard" } }
```

```ts
await page.click('Sign in', { expect: { type: 'url_contains', value: '/dashboard' } });
```

### Why use it

- **Fast.** On the same 29-test suite, `swiftbrowse test` finishes in a third to a half of Playwright Test's time. New sessions attach to a warm browser in about 50 ms. See [Benchmarks](https://steady-ember-82q7.here.now/benchmarks.html).
- **Reliable actions.** Every click waits until its target is visible, enabled, not covered and not moving. The click is checked again as the mouse goes down and up, so a layout shift can't send it to the wrong element.
- **Honest results.** An action can verify its own outcome (`expect`). Ambiguous locators are errors, never silent guesses. Every failure says what kind it is and whether retrying could help.
- **Evidence on failure.** You get a screenshot, the page as text, console errors and failed requests. Optionally there's a trace with a live, inspectable copy of the page at every step, and a video.
- **Small footprint.** A single native binary, with no Node runtime needed to run JSON tests. The Python SDK uses the standard library only; the TypeScript SDK has no dependencies.

### Where to go next

- [Getting started](https://steady-ember-82q7.here.now/getting-started.html): Install, run your first test, try the SDK and MCP.
- [Test files](https://steady-ember-82q7.here.now/writing-tests.html): Steps, hooks, variables, tags, setup files.
- [Targets and locators](https://steady-ember-82q7.here.now/locators.html): How SwiftBrowse finds elements.
- [Assertions](https://steady-ember-82q7.here.now/assertions.html): Auto-waiting conditions for URL, text, elements and more.
- [Network mocking](https://steady-ember-82q7.here.now/network.html): Fulfill, fail, delay or watch requests.
- [Saved logins](https://steady-ember-82q7.here.now/auth.html): Log in once, start every test signed in.
- [Traces, video and reports](https://steady-ember-82q7.here.now/debugging.html): See exactly what happened when a test failed.
- [Continuous integration](https://steady-ember-82q7.here.now/ci.html): GitHub Actions, sharding, JUnit.
- [Operations reference](https://steady-ember-82q7.here.now/operations.html): Every operation and argument, generated from the engine.

### Platforms

Prebuilt binaries are available for Linux (x64 and arm64, glibc 2.35+), macOS (Apple Silicon and Intel) and Windows x64. They ship inside the npm and PyPI packages and as archives on the [GitHub releases page](https://github.com/swiftbrowse/SwiftBrowse/releases). SwiftBrowse drives Chromium; other browser engines are not supported yet (see [Limitations](https://steady-ember-82q7.here.now/limitations.html)).

> SwiftBrowse is young (version 0.1.2). If something breaks or the docs leave you guessing, please [open an issue](https://github.com/swiftbrowse/SwiftBrowse/issues). Include the output of `swiftbrowse doctor`.


---

<!-- https://steady-ember-82q7.here.now/getting-started.html -->

## Getting started

Install SwiftBrowse, give it a Chromium to drive, and run a first test as a JSON file, in TypeScript or in Python.

### Install

Pick one. Both ship the native `swiftbrowse` binary for your platform.

```sh
npm install -g swiftbrowse      # global CLI; or `npm install -D swiftbrowse` in a project, then `npx swiftbrowse`
```

```sh
pip install swiftbrowse         # installs the `swiftbrowse` command and the Python SDK
```

On npm, the binary comes from a platform package (`swiftbrowse-linux-x64`, `swiftbrowse-linux-arm64`, `swiftbrowse-darwin-arm64`, `swiftbrowse-darwin-x64` or `swiftbrowse-windows-x64`), which npm installs automatically. On PyPI, the wheel for your platform contains it. You can also download an archive from the [releases page](https://github.com/swiftbrowse/SwiftBrowse/releases), or build from source with `cargo build --release` (Rust 1.89+).

### Get a browser

SwiftBrowse drives Chromium. The easiest way to get the right build is Playwright's installer, which SwiftBrowse finds automatically:

```sh
npx playwright install chromium chromium-headless-shell
```

The headless shell is what headless sessions use; it starts about three times faster than full Chromium. Full Chromium is used for headed sessions (`--headed`). An installed Chrome or Chromium on your `PATH` or in a standard location also works. To pick a binary yourself, set `SWIFTBROWSE_CHROMIUM=/path/to/chrome`.

Check what SwiftBrowse found:

```sh
swiftbrowse doctor
```

`doctor` lists the operating system, the browsers it found and their versions, ffmpeg (needed only for video), and warnings, such as running as root (Chromium's sandbox is then off).

### Your first test

Make a folder with a tiny page to test, `site/index.html`:

```html
<!doctype html>
<title>Hello</title>
<h1>Hello</h1>
<label>Name <input id="name"></label>
<button onclick="document.querySelector('output').textContent = 'Hi, ' + document.querySelector('#name').value">Greet</button>
<output></output>
```

and a test, `hello.test.json`:

```json
{
  "name": "hello page",
  "tests": [
    {
      "name": "greets by name",
      "steps": [
        { "op": "navigate", "url": "index.html" },
        { "op": "expect", "condition": { "type": "visible", "target": { "by": "role", "role": "heading", "name": "Hello" } } },
        { "op": "fill", "target": "Name", "value": "Ada" },
        { "op": "click", "target": "Greet", "expect": { "type": "text_visible", "text": "Hi, Ada" } }
      ]
    }
  ]
}
```

Run it:

```sh
swiftbrowse test --serve site
```

`--serve site` serves the folder on a free local port and resolves relative URLs such as `index.html` against it. For a real app, use `--base-url http://localhost:3000/` or let the runner start your dev server with `--web-server` (see [The test runner](https://steady-ember-82q7.here.now/test-runner.html)).

`swiftbrowse test` finds every `*.test.json` and `*.spec.json` under the current directory (or the paths you give it). It runs the tests in parallel, each in its own isolated browser context, and prints a list of results. Results, failure screenshots and page snapshots go to `test-results/`.

Now break it on purpose: change `"Hi, Ada"` to `"Hi, Bob"` and run again. The failure shows what was expected, what the page actually had, a screenshot, and the page as text. Then try the other outputs:

```sh
swiftbrowse test --serve site --reporter dot --html report.html     # compact output plus an HTML report
swiftbrowse test --serve site --trace on                            # a step-by-step trace.html per test
```

Read [Test files](https://steady-ember-82q7.here.now/writing-tests.html) next for everything a test file can contain.

### The example suite

The repository has a complete example: a small demo app and 29 tests covering forms, tabs, popups, iframes, uploads, network mocking and a saved login.

```sh
git clone https://github.com/swiftbrowse/SwiftBrowse
cd SwiftBrowse/examples/e2e
swiftbrowse test tests --serve site       # serves the demo app and runs every test
```

### Tests in code

If you'd rather write tests in a programming language, use an SDK. It runs the same engine. Serve the `site` folder from above (for example `python3 -m http.server 3000 -d site`) and then:

TypeScript or JavaScript (`npm install -D swiftbrowse`), run with `node --test`:

```js
import { test } from 'node:test';
import { launch } from 'swiftbrowse';

test('greets by name', async () => {
  const browser = await launch();
  const page = await browser.newSession();
  await page.navigate('http://localhost:3000/');
  await page.fill('Name', 'Ada');
  await page.click('Greet', { expect: { type: 'text_visible', text: 'Hi, Ada' } });
  await page.expect({ type: 'title_equals', value: 'Hello' });
  await browser.close();
});
```

Python (`pip install swiftbrowse`), run with `pytest`:

```python
from swiftbrowse import launch

def test_greets_by_name():
    with launch() as browser, browser.new_session() as page:
        page.navigate("http://localhost:3000/")
        page.fill("Name", "Ada")
        page.click("Greet", expect={"type": "text_visible", "text": "Hi, Ada"})
        page.expect({"type": "title_equals", "value": "Hello"})
```

See [TypeScript and JavaScript](https://steady-ember-82q7.here.now/typescript.html) and [Python](https://steady-ember-82q7.here.now/python.html).

### For AI agents

Point an MCP client at `swiftbrowse mcp`:

```json
{ "mcpServers": { "swiftbrowse": { "command": "swiftbrowse", "args": ["mcp"] } } }
```

See [MCP server](https://steady-ember-82q7.here.now/mcp.html).

### Quick commands

```sh
swiftbrowse snapshot https://your-app.test             # the page as compact text, with element refs
swiftbrowse screenshot https://your-app.test --full-page
swiftbrowse run script.json                           # run a list of steps, print every result
swiftbrowse daemon status                             # the warm browsers new sessions attach to
```

### Something not working?

1. Run `swiftbrowse doctor` and check that a browser was found.
2. Run the failing test with `--trace on` and open the `trace.html` it prints.
3. [Open an issue](https://github.com/swiftbrowse/SwiftBrowse/issues). Include your OS, how you installed SwiftBrowse, the command you ran, its output, and `swiftbrowse doctor --json`.


---

<!-- https://steady-ember-82q7.here.now/writing-tests.html -->

## Test files

A test file is JSON. The runner picks up every `*.test.json` and `*.spec.json`, plus `*.setup.json` files that run before everything else. Each test is a list of steps, and each step is one [operation](https://steady-ember-82q7.here.now/operations.html).

### A complete file

```json
{
  "name": "sign in",
  "base_url": "http://localhost:3000/",
  "vars": { "email": "ada@example.com" },
  "tags": ["auth"],
  "before_each": [ { "op": "navigate", "url": "login" } ],
  "after_each": [ { "op": "expect", "condition": { "type": "no_console_errors" } } ],
  "tests": [
    {
      "name": "signs in",
      "tags": ["smoke"],
      "steps": [
        { "op": "fill", "target": "Email", "value": "${email}" },
        { "op": "fill", "target": "Password", "value": "${env.TEST_PASSWORD}" },
        { "op": "click", "target": { "by": "role", "role": "button", "name": "Sign in" },
          "expect": { "type": "url_contains", "value": "dashboard" } },
        { "op": "expect", "condition": { "type": "text_equals", "target": { "by": "test_id", "id": "hello" }, "text": "Welcome, ${email}" } }
      ]
    },
    {
      "name": "rejects a wrong password",
      "retries": 2,
      "timeout_ms": 10000,
      "steps": [
        { "op": "fill", "target": "Email", "value": "${email}" },
        { "op": "fill", "target": "Password", "value": "wrong" },
        { "op": "click", "target": "Sign in" },
        { "op": "expect", "condition": { "type": "text_visible", "text": "Wrong email or password" } }
      ]
    },
    { "name": "resets the password", "skip": "waiting for the mail server", "steps": [] }
  ]
}
```

### File fields

| Field | Meaning |
|---|---|
| `name` | Suite name shown in reports. Default: the file name without `.test.json`. |
| `base_url` | Relative `navigate` and `new_tab` URLs are resolved against it. `--base-url` and `--serve` override it. |
| `vars` | Values for `${name}` placeholders anywhere in the file. `--var name=value` overrides them. |
| `tags` | Tags added to every test in the file. |
| `before_each` | Steps run before every test, in the test's session. |
| `after_each` | Steps run after every test, **even when the test failed**. |
| `storage_state` | Name of a saved state to start every test from: see [Saved logins](https://steady-ember-82q7.here.now/auth.html). |
| `tests` | The tests. |
| `steps` | Instead of `tests`: a file that is one test (the same format as a `swiftbrowse run` script). |

Unknown fields are an error, so a typo such as `befor_each` is reported instead of being ignored.

### Test fields

| Field | Meaning |
|---|---|
| `name` | Required. Shown as `suite › name`, with tags appended as `@tag`. |
| `steps` | Required. The steps, in order. The test stops at the first failing step. |
| `tags` | Tags for filtering with `--grep @smoke`. |
| `skip` | `true` or a reason string. The test is reported as skipped. |
| `only` | Run only the tests marked `only` (across all files). `--forbid-only` makes this an error on CI. |
| `retries` | Retries for this test, overriding `--retries`. |
| `timeout_ms` | Time budget for one attempt of this test, including hooks. Default 30 s (`--test-timeout-ms`). |

### Steps

A step is an object with `op` plus the operation's arguments:

```json
{ "op": "select_option", "target": "Country", "option": "Norway" }
```

Every [operation](https://steady-ember-82q7.here.now/operations.html) can be a step: `navigate`, `click`, `fill`, `type_text`, `press_key`, `select_option`, `check`, `hover`, `upload_file`, `go_back`, `go_forward`, `reload`, `evaluate`, `wait_for`, `route`, `switch_tab`, `switch_frame`, `save_storage_state`, `get_screenshot` and the rest.

The test runner adds one more: **`expect`**, an auto-waiting assertion with either one `condition` or a list of `conditions`. It retries until every condition holds or the expect timeout (5 s, `--expect-timeout-ms`, or `timeout_ms` on the step) runs out. See [Assertions](https://steady-ember-82q7.here.now/assertions.html).

```json
{ "op": "expect", "conditions": [
  { "type": "url_contains", "value": "/cart" },
  { "type": "count", "target": { "by": "css", "selector": ".item" }, "count": 3 }
] }
```

#### Verifying an action's outcome

`click`, `hover` and `press_key` accept an `expect` condition of their own. The action counts as successful only if the condition holds afterwards:

```json
{ "op": "click", "target": "Add to cart", "expect": { "type": "text_visible", "text": "1 item" } }
```

`fill`, `select_option`, `check` and `upload_file` always verify their result (the field's value, the selected option, the checked state, the chosen files).

### Variables

- `${name}`: from the file's `vars`, overridden by `--var name=value`.
- `${env.NAME}`: from the environment. Use it for secrets so they stay out of test files.

An undefined variable is an error before any test runs (exit code 2). Variables work in any string, including URLs, targets and conditions.

### Setup files

A file named `*.setup.json` runs before all other tests. If any setup test fails, the other tests are skipped. Use it to log in once and save the state; see [Saved logins](https://steady-ember-82q7.here.now/auth.html).

### Scripts

A file with top-level `steps` instead of `tests` is a single test. That's also the format of `swiftbrowse run`, which runs one script and prints every result as JSON:

```json
{
  "name": "check the homepage",
  "steps": [
    { "op": "navigate", "url": "http://localhost:3000/" },
    { "op": "assert_page_state", "conditions": [ { "type": "title_equals", "value": "Hello" } ] }
  ]
}
```

```sh
swiftbrowse run check.json
```

A script stops at its first failing step unless it sets `"stop_on_failure": false` (only `run` supports that).

### Isolation

Every test runs in a fresh, isolated browser context with its own cookies, localStorage, cache and tabs. Tests don't share state unless you share it on purpose with a [saved state](https://steady-ember-82q7.here.now/auth.html). Tests in different files and tests in the same file run in parallel up to `--workers`.

### Policy inside tests

Tests target test environments, so the runner is permissive where the MCP server is careful:

- `evaluate` (running JavaScript in the page) is allowed.
- Clicks on buttons such as "Delete" or "Pay" need no approval.
- `upload_file` may read files from the working directory and the test file's directory. Relative paths are resolved against the test file.

See [Configuration and security](https://steady-ember-82q7.here.now/configuration.html) for the full policy.


---

<!-- https://steady-ember-82q7.here.now/locators.html -->

## Targets and locators

Every operation that acts on an element takes a `target`: either a plain string or a locator object.

### Strings

A string is the quickest way to point at something:

```json
{ "op": "fill", "target": "Email", "value": "ada@example.com" }
{ "op": "click", "target": "Sign in" }
```

A string tries these strategies in order and uses the first that matches:

1. accessible name (what a screen reader announces: button text, `aria-label`, `alt`, …)
2. form label (`<label for>`, a wrapping `<label>`, or `aria-label`)
3. test id (`data-testid`, `data-test-id`, `data-test`)
4. exact text
5. partial text

The result reports which strategy matched (`target.strategy`), so you can tell what was found.

### Locator objects

When a string is ambiguous or you want to be exact, use a locator with `by`:

| Locator | Example | Matches |
|---|---|---|
| `role` | `{ "by": "role", "role": "button", "name": "Save" }` | Elements with this ARIA role (implicit or explicit) and, optionally, this accessible name. Add `"exact": true` for an exact name match. `"role": "*"` matches any non-generic role. |
| `label` | `{ "by": "label", "text": "Email" }` | Form controls whose label or `aria-label` matches. `exact` is optional. |
| `test_id` | `{ "by": "test_id", "id": "cart-count" }` | `data-testid`, `data-test-id` or `data-test`. |
| `text` | `{ "by": "text", "text": "Welcome back" }` | The innermost elements whose text matches. `exact` is optional. |
| `css` | `{ "by": "css", "selector": "form .error" }` | A CSS selector. |
| `xpath` | `{ "by": "xpath", "expr": "//table//tr[3]" }` | An XPath expression. |
| `ref` | `{ "by": "ref", "ref": "e12" }` | A handle from a page snapshot or an earlier lookup (mostly for agents). |
| `auto` | `{ "by": "auto", "query": "Email" }` | The same as a plain string. |

Name and text matching ignores case and collapses whitespace. Without `exact` it matches substrings ("Save" matches "Save draft"); with `"exact": true` the whole name must match.

Prefer `role`, `label`, `test_id` and text over CSS and XPath. They follow what the user sees, so they survive markup changes and they check that your page is accessible.

### Several matches: `nth` and the selection rule

The rule for choosing among several matches is strict, and the same everywhere:

1. If at least one match is **visible**, hidden matches are ignored.
2. If more than one match remains and no `nth` is given, the step fails with **`ambiguous`**. The error lists the candidates.
3. With `nth`, the zero-based match in document order is used.

```json
{ "op": "click", "target": { "by": "role", "role": "button", "name": "Delete", "nth": 2 } }
```

SwiftBrowse never silently picks the first of several matches, because that's how tests end up clicking the wrong row.

### What happens before an action

Before `click`, `hover`, `check`, `fill`, `select_option` and the other actions touch an element, SwiftBrowse waits (up to the action's timeout) until:

- the element exists and matches exactly one candidate
- it is visible and has a size
- it is enabled (for actions that need it)
- it isn't covered by another element at the point where the mouse will land
- it has stopped moving (animations and layout shifts have settled)
- the page has finished parsing its scripts

The point is checked again **as the mouse goes down and up**. If a layout shift would make the click land on a different element, the click is blocked and retried instead of hitting the wrong thing.

Failures that happen before any input reaches the page (not found yet, hidden, disabled, covered, moving, stale) are retried automatically until the timeout. A click that was actually delivered is **never** repeated, so an action can't be performed twice. See [Results and errors](https://steady-ember-82q7.here.now/results.html).

### Frames and shadow DOM

- Locators search the current frame. Use [`switch_frame`](https://steady-ember-82q7.here.now/tabs-frames-uploads.html) to act inside an iframe.
- `role`, `label`, `text` and `test_id` look inside open shadow roots; CSS selectors don't pierce shadow roots.


---

<!-- https://steady-ember-82q7.here.now/assertions.html -->

## Assertions

Assertions in SwiftBrowse **wait**: they check the page repeatedly until every condition holds, and fail only when the timeout runs out (5 s by default). You don't need sleeps or manual waits.

### Where conditions are used

| Where | Form |
|---|---|
| Test files | `{ "op": "expect", "condition": {…} }` or `"conditions": [{…}, {…}]` |
| Any interface | `assert_page_state` with `conditions` (recorded in the test report), `wait_for` with one `condition` |
| After an action | `click`, `hover` and `press_key` take `"expect": {…}` to verify their outcome |
| TypeScript SDK | `await page.expect({…})` or `page.expect([{…}, {…}], { timeoutMs: 10000 })` |
| Python SDK | `page.expect({…})` or `page.expect([...], timeout_ms=10000)` |

### Conditions

| `type` | Fields | Passes when |
|---|---|---|
| `url_equals` | `value` | The URL is exactly `value`. |
| `url_contains` | `value` | The URL contains `value`. |
| `url_matches` | `pattern` | The full URL matches the regular expression. |
| `title_equals` | `value` | The title is exactly `value`. |
| `title_contains` | `value` | The title contains `value`. |
| `visible` | `target` | At least one match is visible (or the `nth` match is). |
| `hidden` | `target` | No match is visible (including when nothing matches). |
| `count` | `target`, `count` | Exactly `count` elements match, visible or not. |
| `text_visible` | `text` | The text is visible somewhere on the page. |
| `text_absent` | `text` | The text is not visible anywhere. |
| `text_equals` | `target`, `text` | The element's whole visible text, whitespace-normalized, equals `text`. |
| `text_contains` | `target`, `text` | The element's visible text contains `text`. |
| `value_equals` | `target`, `value` | A form field's value equals `value`. |
| `enabled` | `target` | The element is enabled. |
| `checked` | `target`, `checked` | A checkbox, radio or `aria-checked` element is (`true`) or isn't (`false`) checked. |
| `no_console_errors` | | No console errors or uncaught exceptions so far in this session. |
| `no_network_failures` | | No failed requests or HTTP responses ≥ 400 so far in this session. |
| `request_seen` | `url_contains`, optional `method` | The page has sent a request whose URL contains this (with this method). |

`target` takes the same strings and locators as actions: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html).

### Examples

```json
{ "op": "expect", "condition": { "type": "url_matches", "pattern": "/orders/\\d+$" } }

{ "op": "expect", "conditions": [
  { "type": "text_equals", "target": { "by": "role", "role": "status" }, "text": "3 items" },
  { "type": "hidden", "target": { "by": "css", "selector": ".spinner" } },
  { "type": "no_console_errors" }
] }

{ "op": "click", "target": "Save",
  "expect": { "type": "request_seen", "url_contains": "/api/profile", "method": "PUT" } }
```

### What a failure shows

When an assertion fails, the result lists every condition with `passed` and what was **observed**: the actual URL, the actual text, how many elements matched. You see why it failed, not just that it did. The test runner prints this and keeps a screenshot and a text snapshot of the page. With `--trace` you can also step through the run (see [Traces, video and reports](https://steady-ember-82q7.here.now/debugging.html)).

### Timeouts

| Setting | Default | Applies to |
|---|---|---|
| `--expect-timeout-ms` | 5000 | `expect` steps in test files |
| `timeout_ms` on a step | | That step only |
| `timeoutMs` / `timeout_ms` on SDK `expect` | 5000 | That assertion |
| `--test-timeout-ms` / `timeout_ms` on a test | 30000 | One attempt of a whole test, including hooks |


---

<!-- https://steady-ember-82q7.here.now/network.html -->

## Network mocking

`route` intercepts the page's requests. A matching request can be answered with your own response, failed with a network error, delayed, or let through. `get_requests` and the `request_seen` condition check what the page sent.

### Fulfill with JSON

```json
{ "op": "route", "url": "**/api/todos", "json": [ { "title": "Mocked one" }, { "title": "Mocked two" } ] }
```

`json` sets the body and `content-type: application/json`. Add the route **before** the page makes the request, which usually means before `navigate`.

### Status codes, bodies and headers

```json
{ "op": "route", "url": "**/api/todos", "status": 500, "body": "boom" }
{ "op": "route", "url": "**/api/me", "status": 401, "json": { "error": "signed out" }, "headers": { "x-request-id": "test" } }
```

### Network errors

```json
{ "op": "route", "url": "**/api/todos", "action": "abort", "error": "ConnectionRefused" }
```

Errors: `Failed`, `Aborted`, `TimedOut`, `AccessDenied`, `ConnectionClosed`, `ConnectionReset`, `ConnectionRefused`, `NameNotResolved`, `InternetDisconnected`, `BlockedByClient`.

### Slow responses

```json
{ "op": "route", "url": "**/api/todos", "json": [], "delay_ms": 600 }
```

Use it to test loading states:

```json
{ "op": "click", "target": "Retry", "expect": { "type": "text_visible", "text": "Loading…" } }
```

### Matching

| Field | Meaning |
|---|---|
| `url` | A glob: `**` matches anything, `*` anything except `/`, `?` one character. `**/api/todos*` matches `https://x.test/api/todos?page=2`. |
| `url_regex` | A regular expression matched against the full URL, instead of `url`. |
| `method` | Only this HTTP method, e.g. `"POST"`. |
| `times` | Only the first N matching requests; after that the route removes itself. |
| `action` | `fulfill` (the default when you give a status, body or JSON), `abort`, or `continue` to let the request through unchanged. |

With neither `url` nor `url_regex`, the route matches every request.

**The newest route wins.** Add a general route first and a more specific or temporary one after it:

```json
{ "op": "route", "url": "**/api/todos", "json": [ { "title": "Back again" } ] },
{ "op": "route", "url": "**/api/todos", "status": 503, "times": 1 },
{ "op": "navigate", "url": "api.html" },
{ "op": "expect", "condition": { "type": "text_visible", "text": "HTTP 503" } },
{ "op": "click", "target": "Retry", "expect": { "type": "text_visible", "text": "Back again" } }
```

The first request gets the 503. The one-time route then removes itself, and the retry gets the data.

### Different answers per method

```json
{ "op": "route", "url": "**/api/todos", "method": "GET", "json": [] },
{ "op": "route", "url": "**/api/todos", "method": "POST", "status": 201, "json": { "id": 42 } }
```

### Checking what the page sent

```json
{ "op": "expect", "condition": { "type": "request_seen", "url_contains": "/api/todos", "method": "POST" } }
```

`get_requests` returns every request the page made (method, URL, status, and whether it was mocked), newest last. `list_routes` shows the active routes and how many requests each has matched.

### Removing routes

```json
{ "op": "unroute", "id": "r1" }
{ "op": "unroute" }
```

The first form removes one route by the `id` that `route` returned; the second removes them all. Routes belong to the session, so they also go away when the test ends.

### Scope

Routes apply to every tab and frame in the session, including popups and cross-site iframes. Interception is switched on only while at least one route exists, so tests without routes pay nothing for it.

### In code

```ts
await page.route({ url: '**/api/todos', json: [{ title: 'Mocked' }] });
await page.navigate(`${base}/api.html`);
await page.expect({ type: 'text_visible', text: 'Mocked' });
```

```python
page.route(url="**/api/todos", status=500, body="boom")
```


---

<!-- https://steady-ember-82q7.here.now/auth.html -->

## Saved logins

Log in once, save the session's state, and start other tests from it. The state is cookies plus the localStorage of the open origins, saved in Playwright's `storageState` format.

### With the test runner

**1. A setup file logs in and saves the state.** Files named `*.setup.json` run before all other tests; if one fails, every other test is skipped.

```json
{
  "name": "member login",
  "tests": [
    {
      "name": "logs in and saves the state",
      "steps": [
        { "op": "navigate", "url": "login.html" },
        { "op": "fill", "target": "Email", "value": "ada@example.com" },
        { "op": "fill", "target": "Password", "value": "${env.TEST_PASSWORD}" },
        { "op": "click", "target": { "by": "role", "role": "button", "name": "Sign in" },
          "expect": { "type": "url_contains", "value": "dashboard" } },
        { "op": "save_storage_state", "name": "member" }
      ]
    }
  ]
}
```

**2. Test files start from it** with `storage_state`:

```json
{
  "name": "members area",
  "storage_state": "member",
  "tests": [
    { "name": "starts logged in", "steps": [
      { "op": "navigate", "url": "members.html" },
      { "op": "expect", "condition": { "type": "text_visible", "text": "Members area for Ada" } }
    ] }
  ]
}
```

Every test gets **its own copy** of the state. A test that logs out or changes localStorage doesn't affect the others.

Files without `storage_state` start signed out, so a suite can test signed-in and guest behavior side by side.

### Where the state is stored

The runner writes it to `<output-dir>/.auth/<name>.json` (default `test-results/.auth/`). The file is readable by your user only. It contains live session cookies, so don't commit it: add `test-results/` to `.gitignore`.

### Anywhere else

`save_storage_state` and `load_storage_state` are ordinary operations, so they work over MCP and in the SDKs as well. Outside the test runner, the state is saved in the evidence directory.

```ts
await page.saveStorageState('member');
// later, in another session:
const other = await browser.newSession();
await other.loadStorageState('member');
await other.navigate(`${base}/members.html`);
```

```python
page.save_storage_state("member")
other = browser.new_session()
other.load_storage_state("member")
```

`load_storage_state` sets the cookies and seeds each origin's localStorage before your next navigation, so the page sees the state on its first load.


---

<!-- https://steady-ember-82q7.here.now/tabs-frames-uploads.html -->

## Tabs, frames and uploads

### Tabs and popups

A session can have several tabs: links with `target="_blank"`, `window.open` popups, and tabs you open yourself. Actions apply to the **active** tab.

```json
{ "op": "click", "target": "Open about in a new tab" },
{ "op": "switch_tab", "url_contains": "about.html" },
{ "op": "expect", "condition": { "type": "title_equals", "value": "About" } },
{ "op": "close_tab" }
```

| Operation | What it does |
|---|---|
| `switch_tab` | Makes a tab active by `index`, `url_contains`, `title_contains`, or `last: true` (the newest). It **waits** for a matching tab to appear, so you can call it right after the click that opens a popup. |
| `new_tab` | Opens a tab, optionally at a `url`, and makes it active. |
| `list_tabs` | Lists the tabs with their index, URL and title, and marks the active one. |
| `close_tab` | Closes the active tab (or `index`). Its opener, or else the last tab, becomes active. |

A popup sign-in:

```json
{ "op": "click", "target": "Sign in with popup" },
{ "op": "switch_tab", "last": true },
{ "op": "fill", "target": "Email", "value": "ada@example.com" },
{ "op": "click", "target": "Continue" },
{ "op": "switch_tab", "index": 0 },
{ "op": "expect", "condition": { "type": "text_visible", "text": "Signed in" } }
```

A click that opens a new tab returns as soon as the tab exists; it doesn't wait for the original page to change.

### Frames

Actions and locators work in one frame at a time. `switch_frame` moves into an iframe, same-site or cross-site, and nested iframes one level at a time:

```json
{ "op": "switch_frame", "target": { "by": "css", "selector": "iframe[name=payment]" } },
{ "op": "fill", "target": "Card number", "value": "4242 4242 4242 4242" },
{ "op": "switch_frame", "main": true }
```

| Argument | Selects |
|---|---|
| `target` | The `<iframe>` element, located in the current frame. Use this to go into nested frames step by step. |
| `name` | A frame by its `name` attribute, anywhere in the tab. |
| `url_contains` | The first frame whose URL contains this text, anywhere in the tab. |
| `main: true` | Back to the tab's top-level page. |

`switch_frame` waits for the frame to exist. You stay in the frame until the next `switch_frame` or a navigation of the tab. `list_frames` lists the tab's frames with their names and URLs.

Clicks inside frames use real mouse input at the right position on the page, including frames inside frames. Clicks inside an iframe scaled with CSS `transform` are refused rather than risk missing (see [Limitations](https://steady-ember-82q7.here.now/limitations.html)).

### File uploads

`upload_file` sets files on an `<input type="file">` without opening a file dialog, and verifies the selection. It works on inputs hidden behind a styled button:

```json
{ "op": "upload_file", "target": "Documents", "files": ["fixtures/notes.txt", "fixtures/contacts.csv"] }
```

The target must resolve to the file input itself. Its label works (`"Documents"` above), as do a test id or CSS. Targeting the styled button instead is an error that says to target the input.

For safety, uploads only read from allowed directories:

| Where | Allowed directories | Relative paths are resolved against |
|---|---|---|
| Test runner | the working directory and the test file's directory | the test file's directory |
| SDKs (default test settings) | the working directory | the working directory |
| MCP and `run` | none, unless you pass `--allow-upload-dir DIR` | the working directory |

A file outside these directories is refused with `permission_denied`.

### Dialogs

`alert()` is acknowledged. `confirm()` and `prompt()` are **dismissed**: SwiftBrowse never accepts on the user's behalf. Both are reported in the result's notes and in page snapshots.


---

<!-- https://steady-ember-82q7.here.now/test-runner.html -->

## The test runner

```sh
swiftbrowse test [OPTIONS] [PATHS]...
```

`swiftbrowse test` finds test files, runs the tests in parallel isolated sessions, retries failures if you ask, and reports the results. Paths can be files or directories; the default is the current directory. Directories are searched recursively for `*.test.json`, `*.spec.json` and `*.setup.json`, skipping hidden directories, `node_modules` and `target`.

### Common runs

```sh
swiftbrowse test                                   # everything under the current directory
swiftbrowse test tests/checkout.test.json          # one file
swiftbrowse test -g @smoke                         # tests tagged smoke
swiftbrowse test -g "checkout › pays" -j 1         # one test, one worker
swiftbrowse test --retries 2 --reporter dot        # CI-style
swiftbrowse test --repeat-each 20 -g "flaky one"   # hunt a flaky test
swiftbrowse test --list                            # show what would run
```

### Parallelism

`-j/--workers N` runs N tests at a time; the default is half the CPU cores. Every test has its own browser context, so tests in the same file run in parallel too. Setup files run first, then everything else.

Sessions attach to warm background browsers (the [daemon](https://steady-ember-82q7.here.now/configuration.html#the-warm-browser-daemon)), one per parallel worker, so a new test starts in milliseconds instead of launching Chromium. The first run after the daemons have exited starts them, which costs about a tenth of a second per browser.

### Picking tests

| Option | Effect |
|---|---|
| `-g, --grep REGEX` | Only tests whose title matches. A title is `file › test @tag1 @tag2`. |
| `--grep-invert REGEX` | Skip tests whose title matches. |
| `--shard i/n` | Run part *i* of *n*, to split a suite across CI machines. |
| `only` in a file | Run only tests marked `only`. Use `--forbid-only` on CI so a forgotten `only` fails the run. |
| `skip` in a file | Report the test as skipped. |

### Retries, flakiness and failures

| Option | Effect |
|---|---|
| `--retries N` | Retry a failing test up to N times. A test that fails and then passes is reported as **flaky**, and the run still passes. |
| `--repeat-each N` | Run every test N times. |
| `-x`, `--max-failures N` | Stop starting new tests after N failures (`-x` is 1). |
| `--test-timeout-ms` | Time budget for one attempt, including hooks. Default 30000. |
| `--expect-timeout-ms` | How long `expect` steps and actions wait for the page. Default 5000. |

### Where the app comes from

| Option | Effect |
|---|---|
| `--base-url URL` | Resolve relative `navigate` URLs against this URL. |
| `--serve DIR` | Serve a static directory on a free local port and use it as the base URL. Good for static sites and demos. |
| `--web-server CMD` + `--web-server-url URL` | Start your dev server (e.g. `npm run dev`) before the tests, wait until the URL accepts connections, and stop the server afterwards. If something already answers at the URL, it's reused. |
| `--var name=value` | Set a `${name}` variable (repeatable). |

```sh
swiftbrowse test --web-server "npm run dev" --web-server-url http://localhost:3000 --base-url http://localhost:3000/
```

### Output

| Option | Effect |
|---|---|
| `--reporter list` | One line per test (the default). |
| `--reporter dot` | One character per test. |
| `--reporter json` | The full results as JSON on stdout. |
| `--reporter quiet` | Only the summary. |
| `--output-dir DIR` | Where `results.json`, screenshots, page snapshots, traces, videos and saved states go. Default `test-results`. |
| `--junit FILE` | Also write a JUnit XML report, which most CI systems display. |
| `--html FILE` | Also write a self-contained HTML report. |
| `--trace off\|on\|retain-on-failure` | Record a trace per test. |
| `--video off\|on\|retain-on-failure` | Record a WebM video per test. |

See [Traces, video and reports](https://steady-ember-82q7.here.now/debugging.html).

### Exit codes

| Code | Meaning |
|---|---|
| `0` | Every test passed (flaky tests count as passed). |
| `1` | At least one test failed. |
| `2` | Bad configuration or an invalid test file. Nothing ran. |

Test files are validated before any browser starts: unknown fields, unknown operations, invalid arguments and undefined variables are reported with the file and step number.

### Headed runs

`--headed` shows the browser window, which is useful while writing a test. Headed sessions launch their own browser instead of using the daemon.

```sh
swiftbrowse test tests/login.test.json --headed -j 1
```


---

<!-- https://steady-ember-82q7.here.now/debugging.html -->

## Traces, video and reports

### What every failure keeps

Even with no options, a failing test leaves in `test-results/`:

- a **screenshot** of the page at the moment of failure
- the **page as text**: URL, title, headings, and the visible interactive elements
- the **console errors** and **failed requests** the session saw
- the **error**: its kind (`not_found`, `ambiguous`, `timeout`, `verification_failed`, …), the step, and for assertions every condition with what was observed

The list reporter prints this. `test-results/results.json` has it all in machine-readable form.

### Traces

```sh
swiftbrowse test --trace retain-on-failure     # keep traces of failed tests only
swiftbrowse test --trace on                    # keep every test's trace
```

A trace is one self-contained `trace.html` per test. Open it in any browser; it needs no viewer and no server. It contains:

- **A step timeline**, with each step's duration and result.
- **Screenshot**: the page after each step.
- **Live DOM**: a real, script-free copy of the page after each step, including current form values, open shadow roots and the page's live CSS. Hover over and click elements to inspect them. The step's target is outlined, and the inspector suggests a locator for any element you pick. You can also open the snapshot in a new tab and use your browser's devtools on it.
- **Text**: the page as SwiftBrowse reads it.
- For assertions, **expected vs observed** for every condition.
- **Every request** the page made, with status, and whether it was mocked.

A trace is the fastest way to see why a test failed on CI: download it and open it.

### Video

```sh
swiftbrowse test --video retain-on-failure
```

Records a WebM video of each test and follows tab switches. Encoding needs ffmpeg. Install it with `npx playwright install ffmpeg`, put `ffmpeg` on your `PATH`, or set `SWIFTBROWSE_FFMPEG`. Without ffmpeg, the frames are kept as JPEG images instead.

Over MCP or in the SDKs, use `start_video` and `stop_video`.

### HTML report

```sh
swiftbrowse test --html report.html
```

A single self-contained file with every test, its status, attempts, durations and errors, with failure screenshots embedded and links to traces and videos.

### JSON and JUnit

- `--junit junit.xml`: JUnit XML, which GitHub Actions reporters, GitLab, Jenkins, CircleCI and Azure DevOps understand. Skipped and failed tests are marked as such.
- `test-results/results.json`, always written: the summary (passed, failed, flaky, skipped, duration) and every test with every attempt, error, and evidence path.
- `--reporter json`: the same JSON on stdout.

### Debugging locally

```sh
swiftbrowse test tests/cart.test.json -g "removes an item" --headed -j 1 --trace on
```

- `--headed` shows the browser.
- `swiftbrowse snapshot URL` prints what SwiftBrowse sees on a page: each element with its role, name and a `ref`. It's the quickest way to choose a target.
- `SWIFTBROWSE_TRACE=1` prints launch and navigation timings to stderr.


---

<!-- https://steady-ember-82q7.here.now/ci.html -->

## Continuous integration

On CI, SwiftBrowse needs the `swiftbrowse` binary and a Chromium. Optionally, install ffmpeg for video.

### GitHub Actions

```yaml
name: e2e
on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright install --with-deps chromium chromium-headless-shell
      - run: npx swiftbrowse test --retries 2 --forbid-only --reporter dot --junit junit.xml --html report.html --trace retain-on-failure
        env:
          TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-results
          path: |
            test-results/
            report.html
```

This assumes `swiftbrowse` is a dev dependency (`npm install -D swiftbrowse`). With Python, use `pip install swiftbrowse` and run `swiftbrowse test`.

`--with-deps` installs the system libraries Chromium needs on a fresh Ubuntu runner.

#### With your app's dev server

```yaml
      - run: npx swiftbrowse test --web-server "npm run start" --web-server-url http://localhost:3000 --base-url http://localhost:3000/
```

### Sharding

Split a large suite across machines:

```yaml
jobs:
  e2e:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      # …
      - run: npx swiftbrowse test --shard ${{ matrix.shard }}/4 --junit junit-${{ matrix.shard }}.xml
```

### Tips

- `--forbid-only` fails the run if someone committed a test marked `only`.
- `--retries 2` keeps the run green on rare flakes, but they're still reported as **flaky**, so you can see and fix them.
- `--trace retain-on-failure` costs nothing for passing tests and gives you a full trace for each failure.
- Keep secrets in environment variables and use `${env.NAME}` in test files.
- Running as root, as in many containers, turns off Chromium's sandbox, because it can't start otherwise. `doctor` warns about it; the container is then your isolation boundary.
- Exit codes: `0` passed, `1` test failures, `2` invalid configuration.


---

<!-- https://steady-ember-82q7.here.now/typescript.html -->

## TypeScript and JavaScript

```sh
npm install -D swiftbrowse
```

The `swiftbrowse` package is an ES module with TypeScript types and no dependencies. It runs the native `swiftbrowse` binary as a child process and talks to it over JSON lines. Use it with any test framework: `node:test`, Vitest, Jest, Mocha.

### A test

```js
import { test, before, after } from 'node:test';
import { launch } from 'swiftbrowse';

let browser;
before(async () => { browser = await launch(); });
after(() => browser.close());

test('adds a todo', async () => {
  const page = await browser.newSession();
  await page.navigate('http://localhost:3000/');
  await page.fill('New todo', 'Write docs');
  await page.pressKey('Enter', { expect: { type: 'text_visible', text: 'Write docs' } });
  await page.expect([
    { type: 'count', target: { by: 'role', role: 'listitem' }, count: 1 },
    { type: 'no_console_errors' },
  ]);
  await page.close();
});
```

```sh
node --test
```

Start one `launch()` per test file (or per worker) and one `newSession()` per test. A session is isolated: its own cookies, storage and tabs. Sessions run concurrently, and each attaches to the warm browser daemon, so creating one takes milliseconds.

### API

#### `launch(options?) → Promise<Browser>`

Starts a `swiftbrowse sdk` process.

| Option | Default | Meaning |
|---|---|---|
| `testDefaults` | `true` | No approval prompts for "Delete" or "Pay" clicks, `evaluate` allowed, uploads from the working directory. Set it to `false` for the agent-safe policy. |
| `headed` | `false` | Show browser windows. |
| `noDaemon` | `false` | Launch a browser for this process instead of attaching to the warm daemon. |
| `chromium` | discovered | Path to a Chromium binary. |
| `evidenceDir` | temp directory | Where screenshots, videos and saved states go. |
| `timeoutMs` | 10000 | How long actions wait for their target, in ms. |
| `allowHosts` / `blockHosts` | | Restrict navigation (`*.example.com` patterns allowed). |
| `uploadDirs` | | Extra directories `uploadFile` may read from. |
| `allowFileUrls` | `false` | Permit `file://` navigation. |
| `throwOnError` | `true` | Throw `SwiftBrowseError` when an operation fails. |
| `executable` | found | Path to the `swiftbrowse` binary. |

#### `browser.newSession({ headless?, viewport?, throwOnError? }) → Promise<Session>`

`viewport` is `[width, height]`; the default is `[1280, 800]`.

#### `browser.close()`

Closes every session and stops the process. Call it in `after`/`afterAll`.

#### Session methods

Every [operation](https://steady-ember-82q7.here.now/operations.html) is a camelCase method: required arguments in order, then an options object whose keys are the operation's other arguments in snake_case.

```js
await page.navigate(url, { wait_until: 'network_idle' });
await page.click('Save', { expect: { type: 'text_visible', text: 'Saved' }, timeout_ms: 10000 });
await page.fill({ by: 'label', text: 'Email' }, 'ada@example.com');
await page.selectOption('Country', 'Norway');
await page.check('I agree');
await page.uploadFile('Documents', ['fixtures/notes.txt']);
await page.route({ url: '**/api/todos', json: [] });
await page.switchTab({ url_contains: 'checkout' });
await page.switchFrame({ name: 'payment' });
await page.saveStorageState('member');
const shot = await page.getScreenshot({ full_page: true });
```

Plus three helpers:

- `page.expect(condition | condition[], { timeoutMs = 5000, name })`: an auto-waiting assertion. See [Assertions](https://steady-ember-82q7.here.now/assertions.html).
- `page.text()`: the page as compact text (URL, title, headings, interactive elements).
- `page.run(op, args)`: any operation by name.

#### Results and errors

Every method resolves to an [`ActionResult`](https://steady-ember-82q7.here.now/results.html). If the operation failed, it throws a `SwiftBrowseError` instead, unless `throwOnError` is `false`. The error carries `kind` (for example `not_found`, `ambiguous` or `verification_failed`) and the full `result`, and its message includes any notes. Test frameworks report it like any failed assertion.

```js
import { SwiftBrowseError } from 'swiftbrowse';
try {
  await page.click('Missing', { timeout_ms: 500 });
} catch (e) {
  if (e instanceof SwiftBrowseError && e.kind === 'not_found') { /* … */ }
}
```

### Types

Methods, options, `Target`, `Condition` and `ActionResult` are typed. The types are generated from the engine's schemas, so editors autocomplete every argument and condition, and CI fails if they drift from the engine.

### The binary

The SDK uses, in order: the `executable` option, `$SWIFTBROWSE_BIN`, the binary from the platform package npm installed (`swiftbrowse-<platform>`), then `swiftbrowse` on `PATH`. The package also installs the CLI: `npx swiftbrowse test`.

### Parallelism with `node:test`

`node --test` runs test files in parallel processes. Tests inside a file run in order unless you use `{ concurrency: true }`. Each file's `launch()` gets its own sessions on the shared warm browsers.


---

<!-- https://steady-ember-82q7.here.now/python.html -->

## Python

```sh
pip install swiftbrowse
```

The `swiftbrowse` package uses the standard library only and needs Python 3.8+. The platform wheels include the native binary. It works with pytest, unittest or plain scripts.

### A test with pytest

```python
import pytest
from swiftbrowse import launch

@pytest.fixture(scope="session")
def browser():
    with launch() as b:
        yield b

@pytest.fixture
def page(browser):
    with browser.new_session() as s:
        yield s

def test_adds_a_todo(page):
    page.navigate("http://localhost:3000/")
    page.fill("New todo", "Write docs")
    page.press_key("Enter", expect={"type": "text_visible", "text": "Write docs"})
    page.expect([
        {"type": "count", "target": {"by": "role", "role": "listitem"}, "count": 1},
        {"type": "no_console_errors"},
    ])
```

Each `new_session()` is isolated: its own cookies, storage and tabs. It attaches to the warm browser daemon, so creating one per test is cheap.

### API

#### `launch(executable=None, *, ...) → Browser`

| Keyword | Default | Meaning |
|---|---|---|
| `test_defaults` | `True` | No approval prompts for "Delete" or "Pay" clicks, `evaluate` allowed, uploads from the working directory. Set it to `False` for the agent-safe policy. |
| `headed` | `False` | Show browser windows. |
| `no_daemon` | `False` | Launch a browser for this process instead of attaching to the warm daemon. |
| `chromium` | discovered | Path to a Chromium binary. |
| `evidence_dir` | temp directory | Where screenshots, videos and saved states go. |
| `timeout_ms` | 10000 | How long actions wait for their target, in ms. |
| `allow_hosts` / `block_hosts` | | Restrict navigation. |
| `upload_dirs` | | Extra directories `upload_file` may read from. |
| `allow_file_urls` | `False` | Permit `file://` navigation. |
| `throw_on_error` | `True` | Raise `SwiftBrowseError` when an operation fails. |

`Browser` and `Session` are context managers, so `with` closes them.

#### `browser.new_session(*, headless=None, viewport=None, throw_on_error=None) → Session`

`viewport` is `(width, height)`; the default is `(1280, 800)`.

#### Session methods

Every [operation](https://steady-ember-82q7.here.now/operations.html) is a method with its documented signature: required arguments positionally, the rest by keyword.

```python
page.navigate(url, wait_until="network_idle")
page.click("Save", expect={"type": "text_visible", "text": "Saved"}, timeout_ms=10000)
page.fill({"by": "label", "text": "Email"}, "ada@example.com")
page.select_option("Country", "Norway")
page.check("I agree")
page.upload_file("Documents", ["fixtures/notes.txt"])
page.route(url="**/api/todos", json=[])
page.switch_tab(url_contains="checkout")
page.switch_frame(name="payment")
page.save_storage_state("member")
shot = page.get_screenshot(full_page=True)
```

Plus:

- `page.expect(condition_or_list, *, timeout_ms=5000, name=None)`: an auto-waiting assertion. See [Assertions](https://steady-ember-82q7.here.now/assertions.html).
- `page.text()`: the page as compact text.
- `page.run(op, args)`: any operation by name.

#### Results and errors

Methods return the [`ActionResult`](https://steady-ember-82q7.here.now/results.html) as a dict. If the operation failed, they raise `SwiftBrowseError` instead (unless `throw_on_error=False`), with `.kind` and the full `.result`.

```python
from swiftbrowse import SwiftBrowseError

try:
    page.click("Missing", timeout_ms=500)
except SwiftBrowseError as e:
    assert e.kind == "not_found"
```

### Threads

A `Browser` sends one request at a time; calls from several threads are serialized. For parallel tests, use pytest-xdist: each worker process gets its own `launch()`.

### The binary

The SDK uses, in order: the `executable` argument, `$SWIFTBROWSE_BIN`, the binary inside the installed wheel, then `swiftbrowse` on `PATH`. The package also installs the `swiftbrowse` command (`swiftbrowse test`, `swiftbrowse mcp`, …), and `python -m swiftbrowse` works too.


---

<!-- https://steady-ember-82q7.here.now/mcp.html -->

## MCP server

`swiftbrowse mcp` serves the [Model Context Protocol](https://modelcontextprotocol.io) over stdio, so AI agents (Claude Code, Claude Desktop, Cursor and other MCP clients) can drive a real browser.

### Setup

```json
{
  "mcpServers": {
    "swiftbrowse": { "command": "swiftbrowse", "args": ["mcp"] }
  }
}
```

With npx: `"command": "npx", "args": ["-y", "swiftbrowse", "mcp"]`. In Claude Code:

```sh
claude mcp add swiftbrowse -- swiftbrowse mcp
```

The server starts launching the default session's browser right away, so the first tool call doesn't wait (turn this off with `--no-prewarm`). It supports MCP protocol versions 2024-11-05, 2025-03-26 and 2025-06-18.

### Tools

Every [operation](https://steady-ember-82q7.here.now/operations.html) is a tool with the same name and arguments, plus:

| Tool | Does |
|---|---|
| `session_create` | Start another isolated session (its own cookies, storage and tabs). |
| `session_close` | Close a session. |
| `session_list` | List open sessions. |

Tools use the `default` session unless given a `session_id`.

### Built for agents

- **Compact output.** Tool results reach the agent as short text, for example `ok click button "Sign up" [e12] · verified: text_visible`. The full structured result is in `structuredContent`.
- **Bounded snapshots.** `get_page_snapshot` returns one line per interactive element with a `ref` (`textbox "Email" [e3] type=email`), capped at 150 elements. `since_last: true` returns only what changed (`+`, `~`, `-`).
- **Act and verify in one call.** `click` with `expect` performs the click and confirms the outcome, so the agent doesn't need a separate wait.
- **Honest failures.** Errors say what kind they are and whether retrying could help. A click that was delivered is never silently retried.

In a benchmark of typical agent tasks, this used about half the tool calls of Playwright MCP and far fewer bytes of context (see [Benchmarks](https://steady-ember-82q7.here.now/benchmarks.html)).

### Safety defaults

The MCP server is careful by default, because an agent is acting on the user's behalf:

- Clicks on elements that look consequential (buy, pay, checkout, delete, transfer, …) return `approval_required` until they are repeated with `approved: true`. Pass `--no-approval` only for test environments.
- `evaluate` (running JavaScript) is off unless you pass `--allow-evaluate`.
- `upload_file` reads nothing unless you pass `--allow-upload-dir DIR`.
- `file://` URLs need `--allow-file-urls`. Restrict navigation with `--allow-host` and `--block-host`.
- `confirm()` and `prompt()` dialogs are dismissed, never accepted. Password values never appear in results.

```json
{ "command": "swiftbrowse", "args": ["mcp", "--allow-host", "staging.example.com", "--allow-upload-dir", "/home/me/fixtures"] }
```

### From agent session to test

Every tool call is an operation, so a flow an agent worked out can be saved as a test file. Put the tool calls in `steps` as `{ "op": <tool>, ...arguments }` and run them with `swiftbrowse test` or `swiftbrowse run`.


---

<!-- https://steady-ember-82q7.here.now/operations.html -->

## Operations

Every operation SwiftBrowse can perform. This page is generated from the engine's own schemas (`swiftbrowse sdk --schema`, version 0.1.2), so it matches the binary exactly.

The same operation has the same name and arguments everywhere:

- **Test files and `run` scripts:** a step `{ "op": "fill", "target": "Email", "value": "ada@example.com" }`. Test files also have [`expect`](https://steady-ember-82q7.here.now/assertions.html).
- **MCP:** a tool named after the operation, with the arguments as its input.
- **TypeScript SDK:** `page.fill('Email', 'ada@example.com', { timeout_ms: 2000 })`, a camelCase method, with required arguments in order and the rest in an options object.
- **Python SDK:** `page.fill("Email", "ada@example.com", timeout_ms=2000)`, with required arguments positionally and the rest by keyword.

A `target` is a string or a locator object: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). A `condition` is described in [Assertions](https://steady-ember-82q7.here.now/assertions.html). Every operation returns an [ActionResult](https://steady-ember-82q7.here.now/results.html).

- **Navigation:** [`navigate`](#navigate), [`go_back`](#goback), [`go_forward`](#goforward), [`reload`](#reload)
- **Reading the page:** [`get_page_snapshot`](#getpagesnapshot), [`get_accessibility_tree`](#getaccessibilitytree), [`find_element`](#findelement), [`get_screenshot`](#getscreenshot), [`get_dom_snapshot`](#getdomsnapshot)
- **Actions:** [`click`](#click), [`hover`](#hover), [`check`](#check), [`fill`](#fill), [`type_text`](#typetext), [`press_key`](#presskey), [`select_option`](#selectoption), [`upload_file`](#uploadfile), [`evaluate`](#evaluate)
- **Waiting and assertions:** [`wait_for`](#waitfor), [`assert_page_state`](#assertpagestate)
- **Network:** [`route`](#route), [`unroute`](#unroute), [`list_routes`](#listroutes), [`get_requests`](#getrequests)
- **Tabs and frames:** [`new_tab`](#newtab), [`list_tabs`](#listtabs), [`switch_tab`](#switchtab), [`close_tab`](#closetab), [`list_frames`](#listframes), [`switch_frame`](#switchframe)
- **Saved state:** [`save_storage_state`](#savestoragestate), [`load_storage_state`](#loadstoragestate)
- **Video:** [`start_video`](#startvideo), [`stop_video`](#stopvideo)
- **Diagnostics and reports:** [`get_console_errors`](#getconsoleerrors), [`get_network_failures`](#getnetworkfailures), [`get_test_report`](#gettestreport), [`get_capabilities`](#getcapabilities)

### Navigation

#### navigate

Open a URL, wait for the requested load state and report the HTTP status.

| Argument | Type | Description |
|---|---|---|
| `url` (required) | string |  |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |
| `wait_until` | "dom_content_loaded" \| "load" \| "network_idle" | Default: `"load"`. |

TypeScript: `page.navigate(url, { …options })` · Python: `page.navigate(url, **options)`

#### go_back

Go back one history entry and wait for the page to load.

| Argument | Type | Description |
|---|---|---|
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.goBack({ …options })` · Python: `page.go_back(**options)`

#### go_forward

Go forward one history entry and wait for the page to load.

| Argument | Type | Description |
|---|---|---|
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.goForward({ …options })` · Python: `page.go_forward(**options)`

#### reload

Reload the page and wait for it to load.

| Argument | Type | Description |
|---|---|---|
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.reload({ …options })` · Python: `page.reload(**options)`

### Reading the page

#### get_page_snapshot

Compact structured view of the page: URL, title, headings, visible interactive elements with ref handles, dialogs, alerts and validation messages. Use since_last to get only changes.

| Argument | Type | Description |
|---|---|---|
| `format` | "text" \| "json" | Default: `"text"`. |
| `max_elements` | integer |  |
| `mode` | "compact" \| "full" | Default: `"compact"`. |
| `since_last` | boolean | Return only what changed since this session's previous snapshot. Default: `false`. |

TypeScript: `page.getPageSnapshot({ …options })` · Python: `page.get_page_snapshot(**options)`

#### get_accessibility_tree

Bounded accessibility tree (role, name, value) from the browser.

| Argument | Type | Description |
|---|---|---|
| `max_nodes` | integer |  |

TypeScript: `page.getAccessibilityTree({ …options })` · Python: `page.get_accessibility_tree(**options)`

#### find_element

Resolve a locator. Fails with `ambiguous` when several elements match and no nth is given.

| Argument | Type | Description |
|---|---|---|
| `target` (required) | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `all` | boolean | Return every match instead of requiring a unique one. Default: `false`. |

TypeScript: `page.findElement(target, { …options })` · Python: `page.find_element(target, **options)`

#### get_screenshot

Save a PNG screenshot to the evidence directory and return its path.

| Argument | Type | Description |
|---|---|---|
| `format` | ImageFormat | Default: `"png"`. |
| `full_page` | boolean | Default: `false`. |
| `name` | string | File name stem inside the evidence directory; path separators are rejected. |

TypeScript: `page.getScreenshot({ …options })` · Python: `page.get_screenshot(**options)`

#### get_dom_snapshot

A self-contained, script-free HTML copy of the page (form state, shadow roots, live CSS) for trace viewers. Large; prefer get_page_snapshot to read a page.

| Argument | Type | Description |
|---|---|---|
| `mark` | string | Mark this element (a ref) with `data-sb-target` in the snapshot. |

TypeScript: `page.getDomSnapshot({ …options })` · Python: `page.get_dom_snapshot(**options)`

### Actions

#### click

Click an element after checking it is visible, enabled and not covered. Pass expect to verify the outcome. Consequential actions (buy, pay, delete, ...) need approved=true.

| Argument | Type | Description |
|---|---|---|
| `target` (required) | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `approved` | boolean | Set after a human approved a consequential action. Default: `false`. |
| `consequential` | boolean | Override the policy's consequential-action detection. |
| `expect` | condition | See [Assertions](https://steady-ember-82q7.here.now/assertions.html). |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.click(target, { …options })` · Python: `page.click(target, **options)`

#### hover

Move the mouse over an element after checking it is visible and not covered. Pass expect to verify what appears.

| Argument | Type | Description |
|---|---|---|
| `target` (required) | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `expect` | condition | See [Assertions](https://steady-ember-82q7.here.now/assertions.html). |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.hover(target, { …options })` · Python: `page.hover(target, **options)`

#### check

Leave a checkbox, radio or aria-checked element checked (or unchecked with checked=false), clicking only if needed, and verify the state.

| Argument | Type | Description |
|---|---|---|
| `target` (required) | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `checked` | boolean | The state to leave it in. Default true; false unchecks. Default: `true`. |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.check(target, { …options })` · Python: `page.check(target, **options)`

#### fill

Replace a field's value and verify the resulting value.

| Argument | Type | Description |
|---|---|---|
| `target` (required) | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `value` (required) | string |  |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.fill(target, value, { …options })` · Python: `page.fill(target, value, **options)`

#### type_text

Type text key by key into the target or focused element.

| Argument | Type | Description |
|---|---|---|
| `text` (required) | string |  |
| `target` | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.typeText(text, { …options })` · Python: `page.type_text(text, **options)`

#### press_key

Press a key or chord, optionally on a target and with an expected outcome.

| Argument | Type | Description |
|---|---|---|
| `key` (required) | string | Key name such as `Enter`, `Tab`, `Escape`, `ArrowDown`, `a`, or a chord like `Control+a`. |
| `expect` | condition | See [Assertions](https://steady-ember-82q7.here.now/assertions.html). |
| `target` | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.pressKey(key, { …options })` · Python: `page.press_key(key, **options)`

#### select_option

Choose an option of a <select> by value or label and verify the selection.

| Argument | Type | Description |
|---|---|---|
| `target` (required) | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `option` (required) | string | Option value or visible label. |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.selectOption(target, option, { …options })` · Python: `page.select_option(target, option, **options)`

#### upload_file

Select files on an <input type=file> and verify the selection. Only files inside directories the policy allows.

| Argument | Type | Description |
|---|---|---|
| `target` (required) | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `files` (required) | string[] | Files to select; an empty list clears the selection. Must lie in a directory the policy allows for uploads. |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.uploadFile(target, files, { …options })` · Python: `page.upload_file(target, files, **options)`

#### evaluate

Run a JavaScript expression in the page and return its JSON result. Only available when the session policy allows scripts (the test runner does; MCP needs --allow-evaluate).

| Argument | Type | Description |
|---|---|---|
| `expression` (required) | string | JavaScript expression evaluated in the page; promises are awaited and the JSON-serializable result is returned. Disabled unless the policy allows it. |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.evaluate(expression, { …options })` · Python: `page.evaluate(expression, **options)`

### Waiting and assertions

#### wait_for

Wait until a condition holds or the timeout expires.

| Argument | Type | Description |
|---|---|---|
| `condition` (required) | condition | See [Assertions](https://steady-ember-82q7.here.now/assertions.html). |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |

TypeScript: `page.waitFor(condition, { …options })` · Python: `page.wait_for(condition, **options)`

#### assert_page_state

Check URL, title, element, text, value or diagnostics conditions. Recorded in the test report.

| Argument | Type | Description |
|---|---|---|
| `conditions` (required) | condition[] | Every condition must hold: see [Assertions](https://steady-ember-82q7.here.now/assertions.html). |
| `name` | string | Label recorded in the test report. |
| `timeout_ms` | integer | Wait up to this long for all conditions to hold. Default 0: check once. |

TypeScript: `page.assertPageState(conditions, { …options })` · Python: `page.assert_page_state(conditions, **options)`

### Network

#### route

Mock the network: requests matching a URL glob or regex (and method) are fulfilled with a status/body/JSON, aborted with a network error, or let through. Newest route wins; times limits uses.

| Argument | Type | Description |
|---|---|---|
| `action` | "fulfill" \| "abort" \| "continue" | Default: `"fulfill"`. |
| `body` | string | Response body for `fulfill`. |
| `delay_ms` | integer | Wait this long before answering (to test loading states). |
| `error` | string | Network error for `abort`: Failed, Aborted, TimedOut, AccessDenied, ConnectionClosed, ConnectionReset, ConnectionRefused, NameNotResolved, InternetDisconnected, BlockedByClient. |
| `headers` | object | Default: `{}`. |
| `json` | any | JSON response body for `fulfill`; sets `content-type: application/json`. |
| `method` | string | Only this HTTP method (e.g. `POST`). |
| `status` | integer | Response status for `fulfill` (default 200). |
| `times` | integer | Only the first N matching requests; afterwards the route is removed. |
| `url` | string | URL glob: `**` matches anything, `*` anything but `/`, `?` one character, e.g. `**/api/todos*`. |
| `url_regex` | string | Regular expression matched against the full URL, instead of `url`. |

TypeScript: `page.route({ …options })` · Python: `page.route(**options)`

#### unroute

Remove a route by id, or all routes.

| Argument | Type | Description |
|---|---|---|
| `id` | string | Route id from `route`; all routes when omitted. |

TypeScript: `page.unroute({ …options })` · Python: `page.unroute(**options)`

#### list_routes

Active routes with how often each matched.

No arguments.

TypeScript: `page.listRoutes()` · Python: `page.list_routes()`

#### get_requests

Requests the page made (method, URL, status, whether mocked), newest last.

No arguments.

TypeScript: `page.getRequests()` · Python: `page.get_requests()`

### Tabs and frames

#### new_tab

Open a new tab (optionally at a URL) in this session and make it active.

| Argument | Type | Description |
|---|---|---|
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |
| `url` | string | Page to open in the new tab; blank when omitted. |
| `wait_until` | "dom_content_loaded" \| "load" \| "network_idle" | Default: `"load"`. |

TypeScript: `page.newTab({ …options })` · Python: `page.new_tab(**options)`

#### list_tabs

Open tabs of this session, including popups and target=_blank pages, with the active one marked.

No arguments.

TypeScript: `page.listTabs()` · Python: `page.list_tabs()`

#### switch_tab

Make a tab active by index, URL, title, or the newest one; waits for a matching tab to open (e.g. a popup).

| Argument | Type | Description |
|---|---|---|
| `index` | integer | Tab index from `list_tabs`. |
| `last` | boolean | The newest tab. Default: `false`. |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |
| `title_contains` | string | First tab whose title contains this text. Waits for it to open. |
| `url_contains` | string | First tab whose URL contains this text. Waits for it to open (e.g. a popup). |

TypeScript: `page.switchTab({ …options })` · Python: `page.switch_tab(**options)`

#### close_tab

Close a tab (the active one by default); the opener or the last tab becomes active.

| Argument | Type | Description |
|---|---|---|
| `index` | integer | Tab index from `list_tabs`; the active tab when omitted. |

TypeScript: `page.closeTab({ …options })` · Python: `page.close_tab(**options)`

#### list_frames

Frames (iframes) of the active tab with their names and URLs.

No arguments.

TypeScript: `page.listFrames()` · Python: `page.list_frames()`

#### switch_frame

Act inside an iframe (by its element, name or URL) until switch_frame with main=true or a navigation. Works for cross-site iframes.

| Argument | Type | Description |
|---|---|---|
| `main` | boolean | Back to the tab's main frame. Default: `false`. |
| `name` | string | Frame name (`<iframe name>`), anywhere in the tab. |
| `target` | target | A string or a locator: see [Targets and locators](https://steady-ember-82q7.here.now/locators.html). |
| `timeout_ms` | integer | How long to wait, in ms. Default: the session's timeout (10 s; in `swiftbrowse test`, `--expect-timeout-ms`). |
| `url_contains` | string | First frame whose URL contains this text, anywhere in the tab. |

TypeScript: `page.switchFrame({ …options })` · Python: `page.switch_frame(**options)`

### Saved state

#### save_storage_state

Save this session's cookies and the localStorage of its open origins (a logged-in state) under a name, in Playwright's storageState format.

| Argument | Type | Description |
|---|---|---|
| `name` (required) | string | Name of the saved state (letters, digits, '-', '_', '.'); stored as `<name>.json` in the state directory, in Playwright's storageState format. |

TypeScript: `page.saveStorageState(name)` · Python: `page.save_storage_state(name)`

#### load_storage_state

Restore a saved state (cookies and localStorage) into this session, e.g. to start logged in.

| Argument | Type | Description |
|---|---|---|
| `name` (required) | string | Name of the saved state (letters, digits, '-', '_', '.'); stored as `<name>.json` in the state directory, in Playwright's storageState format. |

TypeScript: `page.loadStorageState(name)` · Python: `page.load_storage_state(name)`

### Video

#### start_video

Start recording a video of the active tab (follows tab switches).

No arguments.

TypeScript: `page.startVideo()` · Python: `page.start_video()`

#### stop_video

Stop recording and save the video (WebM via ffmpeg) to the evidence directory.

| Argument | Type | Description |
|---|---|---|
| `name` | string | File name stem in the evidence directory (default `video`). |

TypeScript: `page.stopVideo({ …options })` · Python: `page.stop_video(**options)`

### Diagnostics and reports

#### get_console_errors

Console errors and uncaught exceptions seen in this session.

No arguments.

TypeScript: `page.getConsoleErrors()` · Python: `page.get_console_errors()`

#### get_network_failures

Failed requests and HTTP >= 400 responses seen in this session.

No arguments.

TypeScript: `page.getNetworkFailures()` · Python: `page.get_network_failures()`

#### get_test_report

Assertions, failed actions, diagnostics and evidence for this session.

No arguments.

TypeScript: `page.getTestReport()` · Python: `page.get_test_report()`

#### get_capabilities

Backend, engine version and per-feature support for this session.

No arguments.

TypeScript: `page.getCapabilities()` · Python: `page.get_capabilities()`


---

<!-- https://steady-ember-82q7.here.now/cli.html -->

## Command line

Every `swiftbrowse` command and option, generated from `--help` of version 0.1.2.

```text
The AI browser for browser automation and end-to-end testing

Usage: swiftbrowse <COMMAND>

Commands:
  doctor        Report the environment: OS, browser binaries, versions, desktop WebView dependencies
  capabilities  Start a session and print the backend's capability report
  run           Run a JSON script of operations and print every result plus the test report
  test          Run end-to-end tests: every `*.test.json` / `*.spec.json` under the given paths, in parallel sessions, with retries and list/JSON/JUnit/HTML reports
  snapshot      Navigate and print a compact page snapshot
  screenshot    Navigate and save a screenshot into the evidence directory
  mcp           Serve the Model Context Protocol over stdio
  sdk           Serve the SDK protocol (JSON lines on stdin/stdout) for the TypeScript and Python SDKs
  daemon        Manage the warm-browser daemon that headless sessions attach to
  help          Print this message or the help of the given subcommand(s)

Options:
  -h, --help     Print help
  -V, --version  Print version
```

### swiftbrowse test

```text
Run end-to-end tests: every `*.test.json` / `*.spec.json` under the given paths, in parallel sessions, with retries and list/JSON/JUnit/HTML reports

Usage: swiftbrowse test [OPTIONS] [PATHS]...

Arguments:
  [PATHS]...  Test files or directories to search for `*.test.json` and `*.spec.json`, plus `*.setup.json` files that run first (e.g. to log in and save the state). Default: the current directory

Options:
  -j, --workers <WORKERS>
          Tests run at the same time, each in its own isolated session (default: half the CPU cores)
      --retries <RETRIES>
          Retry a failing test this many times; a test that passes on retry is reported as flaky [default: 0]
  -g, --grep <GREP>
          Only run tests whose title (`file › test @tag`) matches this regular expression
      --grep-invert <GREP_INVERT>
          Skip tests whose title matches this regular expression
      --repeat-each <REPEAT_EACH>
          Run every test this many times (to find flaky tests) [default: 1]
      --shard <SHARD>
          Run one part of the suite, e.g. `2/4`, to split it across CI machines
      --max-failures <MAX_FAILURES>
          Stop starting tests after this many failures (`-x` is `--max-failures 1`)
  -x
          
      --forbid-only
          Fail if any test is marked `only` (use on CI)
      --base-url <BASE_URL>
          Resolve relative `navigate` URLs against this URL (overrides a file's `base_url`)
      --serve <DIR>
          Serve this directory over HTTP on a free local port and use it as the base URL
      --web-server <COMMAND>
          Start this command (e.g. `npm run dev`) before the tests and stop it after; tests wait until `--web-server-url` accepts connections. An already running server is reused
      --web-server-url <URL>
          
      --var <NAME=VALUE>
          Set a `${name}` variable, overriding the file's `vars` (repeatable)
      --expect-timeout-ms <EXPECT_TIMEOUT_MS>
          How long assertions and actions wait for the page (default 5000) [default: 5000]
      --test-timeout-ms <TEST_TIMEOUT_MS>
          Budget for one test attempt, including hooks (default 30000) [default: 30000]
      --trace <TRACE>
          Record a trace: a screenshot and the page as text after every step, in one self-contained trace.html per test. `retain-on-failure` keeps failed tests' only [default: off] [possible values: off, on, retain-on-failure]
      --video <VIDEO>
          Record a video (WebM, needs ffmpeg: `npx playwright install ffmpeg`) [default: off] [possible values: off, on, retain-on-failure]
      --reporter <REPORTER>
          Console output: `list` (default), `dot`, `json` (results on stdout) or `quiet` [default: list]
      --output-dir <OUTPUT_DIR>
          Where screenshots, page snapshots and results.json go [default: test-results]
      --junit <JUNIT>
          Also write a JUnit XML report here
      --html <HTML>
          Also write a self-contained HTML report here
      --list
          Print the tests that would run, without running them
      --headed
          Show the browser window (needs a display)
      --chromium <CHROMIUM>
          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>
          Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>
          Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>
          
      --allow-file-urls
          Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>
          Default per-operation timeout
      --no-approval
          Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon
          Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate
          Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>
          Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help
          Print help
```

### swiftbrowse run

```text
Run a JSON script of operations and print every result plus the test report

Usage: swiftbrowse run [OPTIONS] <SCRIPT>

Arguments:
  <SCRIPT>  Script file, or `-` for stdin

Options:
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help
```

### swiftbrowse mcp

```text
Serve the Model Context Protocol over stdio

Usage: swiftbrowse mcp [OPTIONS]

Options:
      --no-prewarm                   Do not launch the default session's browser until the first tool call
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help
```

### swiftbrowse sdk

```text
Serve the SDK protocol (JSON lines on stdin/stdout) for the TypeScript and Python SDKs

Usage: swiftbrowse sdk [OPTIONS]

Options:
      --test-defaults                Test defaults, as in `swiftbrowse test`: no approval for consequential clicks, `evaluate` allowed, uploads from the working directory
      --schema                       Print every operation with its JSON schema and exit (for code generation)
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help
```

### swiftbrowse doctor

```text
Report the environment: OS, browser binaries, versions, desktop WebView dependencies

Usage: swiftbrowse doctor [OPTIONS]

Options:
      --json  
  -h, --help  Print help
```

### swiftbrowse snapshot

```text
Navigate and print a compact page snapshot

Usage: swiftbrowse snapshot [OPTIONS] <URL>

Arguments:
  <URL>  

Options:
      --full                         
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help
```

### swiftbrowse screenshot

```text
Navigate and save a screenshot into the evidence directory

Usage: swiftbrowse screenshot [OPTIONS] <URL>

Arguments:
  <URL>  

Options:
      --name <NAME>                  File name stem (no path separators) [default: screenshot]
      --full-page                    
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help
```

### swiftbrowse capabilities

```text
Start a session and print the backend's capability report

Usage: swiftbrowse capabilities [OPTIONS]

Options:
      --headed                       Show the browser window (needs a display)
      --chromium <CHROMIUM>          Chromium binary; defaults to $SWIFTBROWSE_CHROMIUM, then discovery (see `doctor`)
      --evidence-dir <EVIDENCE_DIR>  Directory for screenshots and other evidence
      --allow-host <ALLOW_HOSTS>     Restrict navigation to these hosts (repeatable; `*.example.com` allowed)
      --block-host <BLOCK_HOSTS>     
      --allow-file-urls              Permit file:// navigation
      --timeout-ms <TIMEOUT_MS>      Default per-operation timeout
      --no-approval                  Do not require approval for consequential clicks (CI against test environments only)
      --no-daemon                    Launch a browser for this process instead of attaching to the warm-browser daemon (also `SWIFTBROWSE_DAEMON=off`)
      --allow-evaluate               Allow the `evaluate` operation (page scripts). `run` and `test` always allow it
      --allow-upload-dir <DIR>       Let `upload_file` read files from this directory (repeatable). Uploads are off without it
  -h, --help                         Print help
```

### swiftbrowse daemon

```text
Manage the warm-browser daemon that headless sessions attach to

Usage: swiftbrowse daemon <COMMAND>

Commands:
  run     Run the daemon in the foreground (clients start it automatically)
  status  Show whether the daemons are running and what they are doing
  stop    Stop every running daemon; their browsers and all contexts close
  help    Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help
```

### Exit codes

`swiftbrowse test`: `0` every test passed (flaky tests count as passed), `1` a test failed, `2` the configuration or a test file is invalid (nothing ran). `swiftbrowse run`: `0` when every step succeeded, otherwise `1`.


---

<!-- https://steady-ember-82q7.here.now/results.html -->

## Results and errors

Every operation, from every interface, returns the same `ActionResult`.

```json
{
  "ok": true,
  "op": "click",
  "action_id": "s1-2",
  "session_id": "s1",
  "target": {
    "element": { "ref": "e1", "role": "button", "name": "Sign in", "tag": "button" },
    "strategy": "role",
    "candidates": 1
  },
  "page": { "url": "http://localhost:3000/dashboard", "title": "Dashboard" },
  "verification": {
    "status": "passed",
    "checks": [
      { "condition": { "type": "url_contains", "value": "dashboard" }, "passed": true,
        "observed": { "url": "http://localhost:3000/dashboard" } }
    ]
  },
  "timing": { "started_at_ms": 1792905230645, "duration_ms": 59, "timeout_ms": 10000 }
}
```

Fields that would be empty (`data`, `error`, `evidence`, `notes`) are left out.

| Field | Meaning |
|---|---|
| `ok` | The operation succeeded **and** any requested verification passed. |
| `target` | The element acted on, which locator strategy found it, and how many candidates there were. |
| `page` | URL and title after the operation. |
| `verification.status` | `passed`, `failed`, or `not_checked`. A click without `expect` reports `not_checked`: the input was delivered, but nothing confirmed the outcome. |
| `verification.checks` | Each condition with `passed` and what was `observed`. |
| `data` | The operation's output: a snapshot, an evaluate result, a list of tabs, … |
| `error` | Present when `ok` is false: `kind`, `message`, `recoverable`, and sometimes `details` (such as the candidates of an ambiguous locator). |
| `evidence` | Files written (screenshots, videos, saved states), with paths. |
| `notes` | What else happened: `navigated`, `url_changed`, `dom_changed`, a dialog that was dismissed, a wait for a client-rendered page, … |

### Error kinds

| `kind` | Recoverable | Meaning |
|---|---|---|
| `not_found` | yes | No element matched (yet). |
| `not_actionable` | yes | The element is hidden, disabled, covered or moving. |
| `stale_element` | yes | The element was removed or replaced. |
| `timeout` | yes | A wait or an operation ran out of time. |
| `ambiguous` | no | Several elements matched and no `nth` was given. `details` lists them. |
| `verification_failed` | no | The action happened, but its `expect` didn't hold. Don't repeat it blindly. |
| `approval_required` | no | A consequential click needs `approved: true` (MCP's default policy). |
| `permission_denied` | no | The policy forbids it (host not allowed, upload outside allowed directories, `evaluate` off). |
| `navigation_failed` | no | The page didn't load (DNS, connection, certificate, …). |
| `invalid_argument` | no | The request is malformed. |
| `unsupported` | no | This backend can't do it. |
| `session_not_found`, `session_limit` | no | Session problems. |
| `backend` | no | The browser failed unexpectedly. |

"Recoverable" means a later attempt could succeed without changing the request. SwiftBrowse already retries these automatically until the action's timeout, but only when the failure happened **before** any input reached the page. A click that was delivered is never retried.

A failed interaction also attaches a screenshot to `evidence`.

### The test report

`get_test_report` returns the session's verdict: every assertion with its result, failed actions, console errors, network failures and evidence. The test runner builds its reports from the same data.


---

<!-- https://steady-ember-82q7.here.now/configuration.html -->

## Configuration and security

### Environment variables

| Variable | Effect |
|---|---|
| `SWIFTBROWSE_CHROMIUM` | Chromium binary to use for every session. It also disables the automatic switch to the headless shell. |
| `SWIFTBROWSE_HEADLESS_SHELL` | Headless-shell binary for headless sessions. |
| `SWIFTBROWSE_FFMPEG` | ffmpeg binary for video. |
| `SWIFTBROWSE_BIN` | The `swiftbrowse` binary the SDKs run. |
| `SWIFTBROWSE_DAEMON=off` | Don't use the warm-browser daemon; launch a browser per process (same as `--no-daemon`). |
| `SWIFTBROWSE_DAEMONS` | How many warm browsers may run at once. Default: the number of CPUs, at most 8. |
| `SWIFTBROWSE_DAEMON_SOCKET` | Use a private daemon at this socket path, for example to isolate a benchmark. |
| `SWIFTBROWSE_TRANSPORT=websocket` | On Linux and macOS, talk to Chromium over a local DevTools port instead of a pipe. |
| `SWIFTBROWSE_NO_SANDBOX=1` | Run Chromium without its sandbox. This is automatic as root, or when the system has no usable sandbox. |
| `SWIFTBROWSE_TRACE=1` | Print launch and navigation timings to stderr. |
| `PLAYWRIGHT_BROWSERS_PATH` | Where Playwright-installed browsers are looked for, besides the default cache folders. |

### Finding a browser

In order: `--chromium` / `SWIFTBROWSE_CHROMIUM`, then the newest Playwright-installed build (`~/.cache/ms-playwright`, `~/Library/Caches/ms-playwright` or `%LOCALAPPDATA%\ms-playwright`), then standard Chrome and Chromium install locations and `PATH`. Headless sessions use Chromium's headless shell when one is installed (`npx playwright install chromium-headless-shell`). `swiftbrowse doctor` shows what was found.

### The warm-browser daemon

The first headless session starts a background `swiftbrowse daemon` that keeps a Chromium running. Later sessions, whether from another test run, an SDK or an MCP server, attach to it instead of launching a browser. From process start to a loaded page takes about 50 ms instead of about 160 ms.

- **Prepared tabs.** The daemon keeps spare tabs ready, each in a fresh browser context with its renderer started and the session already set up. A new session takes one and navigates immediately.
- **Parallel runs.** Each parallel client gets its own daemon (up to `SWIFTBROWSE_DAEMONS`), so parallel workers don't share one browser's main thread.
- **Isolation.** Every session has its own browser context, with separate cookies, storage and cache, which is disposed when the session closes or its process disconnects. A client can only use the contexts, tabs and sessions it created. Browser-wide commands are refused.
- **Access.** On Linux and macOS, the daemon listens on a Unix socket in a private (`0700`) directory and checks the connecting user's id; the browser runs on a pipe, so no TCP port is opened. On Windows, it uses a named pipe that refuses remote clients. Everywhere, a client must present a random token stored in your private state directory.
- **Lifetime.** A daemon exits after 10 idle minutes, when its browser dies, or on `swiftbrowse daemon stop`. Daemons are tied to the SwiftBrowse version, so different builds never mix.
- **Trade-off.** Daemon sessions share a browser *process* and are separated by browser context. If you need a separate browser process per session, use `--no-daemon`. Headed sessions never use the daemon.

```sh
swiftbrowse daemon status
swiftbrowse daemon stop
```

### Policy

The same options exist on `run`, `test`, `mcp`, `sdk`, `snapshot`, `screenshot` and `capabilities`:

| Option | Effect |
|---|---|
| `--allow-host HOST` | Only allow navigation to these hosts (repeatable; `*.example.com` allowed). |
| `--block-host HOST` | Never navigate to these hosts. |
| `--allow-file-urls` | Permit `file://` URLs. Only `http`, `https`, `about` and `data` are allowed by default. |
| `--allow-evaluate` | Allow `evaluate`. Always on in `run`, `test` and the SDKs' test defaults. |
| `--allow-upload-dir DIR` | Let `upload_file` read files from DIR (repeatable). |
| `--no-approval` | Don't require approval for consequential clicks. On in `test` and the SDKs' test defaults. |
| `--timeout-ms MS` | Default per-operation timeout (10000; in `test`, `--expect-timeout-ms`). |
| `--evidence-dir DIR` | Where screenshots, videos and saved states are written. |
| `--headed` | Show the browser. |
| `--no-daemon` | Launch a browser instead of using the daemon. |

### Security model

- **Consequential clicks.** Clicks whose target text looks like buying, paying, checking out, deleting or transferring return `approval_required` until they are repeated with `approved: true`. The detection uses text patterns, not an understanding of the page, so treat it as a guard rail. You can also mark a click `consequential: true` yourself.
- **Dialogs.** `confirm()` and `prompt()` are dismissed, never accepted on the user's behalf. `alert()` is acknowledged. Both are reported.
- **Secrets.** Password field values are never echoed in results.
- **Files.** Writes go only to the evidence directory, and file names are validated (no path separators). Uploads read only from allowed directories.
- **Page isolation.** SwiftBrowse's in-page helper runs in an isolated JavaScript world, so page scripts can't see it or tamper with it.
- **Cleanup.** Each non-daemon session gets its own browser process and a temporary profile, which are deleted on close. The MCP server cleans up on SIGTERM, SIGINT and SIGHUP. On Linux, browsers also die if the server is killed, and the next launch sweeps up profiles left by dead processes.
- **Root.** Chromium's sandbox can't start as root, so it is turned off there. `doctor` and the capability report warn about it; rely on your container as the boundary.


---

<!-- https://steady-ember-82q7.here.now/benchmarks.html -->

## Benchmarks

All numbers come from benchmark scripts in the repository, which you can run yourself. They come from one machine (a 4-CPU cloud container) and small demo apps. On a slow app, the app's own time dominates, so read them as the tool's overhead, not as a promise about your suite.

### Test runner vs Playwright Test

The same 29 tests, written three ways:

- JSON files run by `swiftbrowse test` ([`examples/e2e/tests`](https://github.com/swiftbrowse/SwiftBrowse/tree/main/examples/e2e/tests))
- hand-written TypeScript with the SDK under `node:test` ([`bench/e2e/sdk`](https://github.com/swiftbrowse/SwiftBrowse/tree/main/bench/e2e/sdk))
- idiomatic Playwright Test with a setup project and `fullyParallel` ([`bench/e2e/specs`](https://github.com/swiftbrowse/SwiftBrowse/tree/main/bench/e2e/specs))

They cover forms, filtering, hover menus, back and forward, reloads, `target=_blank` and popup tabs, same-site, cross-site and nested iframes, file upload, network mocking (data, errors, aborts, delays, `times`, POST checks) and a saved login.

All three used the same app server, the same Chromium headless-shell binary, and Playwright Test 1.64.0. Each run is a whole CLI command, start to exit, as CI runs it. The table shows the median of 5 interleaved runs. At most `workers` tests run at once in every tool. "First run" means no warm browser was running, which is what a fresh CI job gets. Every suite passed on every run.

| Tests | Workers | Playwright Test | `swiftbrowse test` | SwiftBrowse SDK | `swiftbrowse test`, first run |
|---|---|---|---|---|---|
| 29 | 1 | 10454 ms | **4519 ms** (2.3×) | **5065 ms** (2.1×) | **4657 ms** (2.2×) |
| 29 | 4 | 6511 ms | **2150 ms** (3.0×) | **2814 ms** (2.3×) | **2351 ms** (2.8×) |
| 145 | 1 | 46427 ms | **22900 ms** (2.0×) | **24188 ms** (1.9×) | **22973 ms** (2.0×) |
| 145 | 4 | 23904 ms | **10343 ms** (2.3×) | **12034 ms** (2.0×) | **10699 ms** (2.2×) |

Where the time goes: Playwright Test starts Node, transforms the test files and starts a browser per worker. SwiftBrowse is a native process whose sessions attach to warm browsers in milliseconds. Much of the remaining time is the demo app's own deliberate waits (120 ms to add an item, 150 ms to sign in, 600 ms in the loading-state test), which no tool can skip.

```sh
cd bench && npm install && node e2e.mjs ../target/release/swiftbrowse
```

### Agent tasks vs Playwright MCP

The benchmark drives SwiftBrowse MCP and Playwright MCP 0.0.83 over the same transport (MCP stdio), the way an agent does: take a snapshot, read element refs from it, act, confirm the outcome. Playwright MCP ran twice: with its defaults, and tuned with `--timeout-settle 0`, its best case. Both used the same Chromium binary; medians of 15 iterations.

| Task | SwiftBrowse MCP | Playwright MCP (tuned) | Playwright MCP (defaults) |
|---|---|---|---|
| Sign-up form: fill ×2, select, check, submit, confirm | **335 ms · 7 calls · 1.1 KB** | 472 ms · 8 calls · 2.2 KB | 1,302 ms · 8 calls · 2.2 KB |
| 400-row page: snapshot, click "Edit row 250", confirm | **166 ms · 3 calls · 6.6 KB** | 1,037 ms · 4 calls · 152.6 KB | 1,604 ms · 4 calls · 152.6 KB |
| 400-row page, direct: click by role and name, confirm | **153 ms · 2 calls · 0.2 KB** | 834 ms · 3 calls · 0.8 KB | 1,415 ms · 3 calls · 0.8 KB |
| Cold start to first loaded page, own browser | **162 ms** | 704 ms | 715 ms |
| Cold start to first loaded page, warm daemon | **52 ms** | 704 ms | 715 ms |

The KB column is how many bytes the tools returned into the agent's context. SwiftBrowse snapshots are bounded to 150 elements, so on the 400-row page the snapshot is smaller but doesn't contain row 250: the agent has to locate it, which the snapshot itself tells it to do.

```sh
cd bench && npm install && node agents.mjs ../target/release/swiftbrowse --iter 15 --cold 10
```

### Library operations vs Playwright

Identical tasks with the Playwright 1.64.0 library API and SwiftBrowse (through MCP over stdio), using the same headless-shell binary. p50 in ms:

| Task | SwiftBrowse | Playwright |
|---|---:|---:|
| Cold start to loaded page, own browser | 159 | 153 (parity) |
| Cold start to loaded page, warm daemon | **55** | 153 |
| `navigate` | 19.8 | 20.8 |
| `fill` (email) | **7.1** | 33.0 |
| `click` (checkbox label) | **16.9** | 37.6 |
| Whole form task | **336** | 452 |
| Snapshot, 400-row page | **18.3** | 157 |
| Role and name lookup, 400-row page | **4.4** | 20.3 |

With its own browser, cold start is at parity: both tools are bounded by Chromium's startup. SwiftBrowse does more work in some steps (`fill` reads the value back; `navigate` waits for client-rendered pages that are still empty).

```sh
cd bench && npm install && node bench.mjs ../target/release/swiftbrowse --iter 50 --cold 20
```


---

<!-- https://steady-ember-82q7.here.now/limitations.html -->

## Limitations and roadmap

SwiftBrowse is at version 0.1.2. Here is what it doesn't do yet, so you don't have to find out the hard way.

### Not supported yet

- **Chromium only.** No Firefox or WebKit.
- **No visual comparison.** There are no screenshot assertions or pixel diffs.
- **No downloads.** File downloads are reported as unsupported in the capability report.
- **No fixtures or custom reporters inside the SDKs.** Use your test framework's (pytest fixtures, `node:test` hooks, Vitest reporters). The JSON test runner has its own reporters.
- **No mobile emulation presets** beyond setting the viewport.

### Known limitations

- **One frame at a time.** Actions and locators work in the current frame; use `switch_frame` to move between frames.
- **CSS-scaled iframes.** Clicks inside an iframe scaled with CSS `transform` are refused rather than mapped, because the click guard detects that they would miss.
- **Shadow DOM and CSS.** CSS selectors don't pierce shadow roots. Role, label, text and test-id locators do look inside open shadow roots.
- **Consequential-action detection** (MCP's approval prompts) uses text patterns, not an understanding of the page.
- **Traces** take a DOM snapshot after each step, not before and after. Cross-origin stylesheets stay as links in the snapshot.
- **Daemon sessions** share a browser process and are isolated by browser context. Use `--no-daemon` if you need separate processes.

### Planned

- Desktop app shell (Tauri/Wry) for headed, local agent use
- Remote, authenticated MCP transport
- Visual comparison
- Downloads

Something missing that you need? [Open an issue](https://github.com/swiftbrowse/SwiftBrowse/issues) and say what you're trying to test.