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
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
SwiftBrowse does one job three ways, with the same operations and the same results everywhere:
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.node:test, Vitest, Jest, pytest or unittest.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:
{ "op": "click", "target": "Sign in", "expect": { "type": "url_contains", "value": "/dashboard" } }
await page.click('Sign in', { expect: { type: 'url_contains', value: '/dashboard' } });
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.expect). Ambiguous locators are errors, never silent guesses. Every failure says what kind it is and whether retrying could help.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.
Install SwiftBrowse, give it a Chromium to drive, and run a first test as a JSON file, in TypeScript or in Python.
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+).
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).
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 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
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.
Point an MCP client at swiftbrowse mcp:
{ "mcpServers": { "swiftbrowse": { "command": "swiftbrowse", "args": ["mcp"] } } }
See MCP server.
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
swiftbrowse doctor and check that a browser was found.--trace on and open the trace.html it prints.swiftbrowse doctor --json.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.
{
"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": [] }
]
}
| 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.
| 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). |
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 }
] }
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).
${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.
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.
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).
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.
Tests target test environments, so the runner is permissive where the MCP server is careful:
evaluate (running JavaScript in the page) is allowed.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 for the full policy.
Every operation that acts on an element takes a target: either a plain string or a locator object.
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:
aria-label, alt, …)<label for>, a wrapping <label>, or aria-label)data-testid, data-test-id, data-test)The result reports which strategy matched (target.strategy), so you can tell what was found.
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.
nth and the selection ruleThe rule for choosing among several matches is strict, and the same everywhere:
nth is given, the step fails with ambiguous. The error lists the candidates.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.
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.
switch_frame to act inside an iframe.role, label, text and test_id look inside open shadow roots; CSS selectors don't pierce shadow roots.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 | 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) |
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.
{ "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" } }
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).
| 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 |
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.
{ "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.
{ "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" } }
{ "op": "route", "url": "**/api/todos", "action": "abort", "error": "ConnectionRefused" }
Errors: Failed, Aborted, TimedOut, AccessDenied, ConnectionClosed, ConnectionReset, ConnectionRefused, NameNotResolved, InternetDisconnected, BlockedByClient.
{ "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…" } }
| 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.
{ "op": "route", "url": "**/api/todos", "method": "GET", "json": [] },
{ "op": "route", "url": "**/api/todos", "method": "POST", "status": 201, "json": { "id": 42 } }
{ "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.
{ "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.
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.
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")
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.
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.
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.
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.
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.
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).
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.
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.
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.
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
-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.
| 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. |
| 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. |
| 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/
| 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.
| 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 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
Even with no options, a failing test leaves in test-results/:
not_found, ambiguous, timeout, verification_failed, …), the step, and for assertions every condition with what was observedThe list reporter prints this. test-results/results.json has it all in machine-readable form.
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.
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.
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.
--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.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.On CI, SwiftBrowse needs the swiftbrowse binary and a Chromium. Optionally, install ffmpeg for video.
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.
- run: npx swiftbrowse test --web-server "npm run start" --web-server-url http://localhost:3000 --base-url http://localhost:3000/
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
--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.${env.NAME} in test files.doctor warns about it; the container is then your isolation boundary.0 passed, 1 test failures, 2 invalid configuration.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.
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.
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.
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:
page.expect(condition | condition[], { timeoutMs = 5000, name }): an auto-waiting assertion. See Assertions.page.text(): the page as compact text (URL, title, headings, interactive elements).page.run(op, args): any operation by name.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') { /* … */ }
}
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 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.
node:testnode --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.
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.
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.
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) → Sessionviewport is (width, height); the default is (1280, 800).
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:
page.expect(condition_or_list, *, timeout_ms=5000, name=None): an auto-waiting assertion. See Assertions.page.text(): the page as compact text.page.run(op, args): any operation by name.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"
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 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.
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.
{
"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.
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.
ok click button "Sign up" [e12] · verified: text_visible. The full structured result is in structuredContent.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 (+, ~, -).click with expect performs the click and confirms the outcome, so the agent doesn't need a separate wait.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).
The MCP server is careful by default, because an agent is acting on the user's behalf:
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.{ "command": "swiftbrowse", "args": ["mcp", "--allow-host", "staging.example.com", "--allow-upload-dir", "/home/me/fixtures"] }
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.
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:
run scripts: a step { "op": "fill", "target": "Email", "value": "ada@example.com" }. Test files also have expect.page.fill('Email', 'ada@example.com', { timeout_ms: 2000 }), a camelCase method, with required arguments in order and the rest in an options object.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. A condition is described in Assertions. Every operation returns an ActionResult.
navigate, go_back, go_forward, reloadget_page_snapshot, get_accessibility_tree, find_element, get_screenshot, get_dom_snapshotclick, hover, check, fill, type_text, press_key, select_option, upload_file, evaluatewait_for, assert_page_stateroute, unroute, list_routes, get_requestsnew_tab, list_tabs, switch_tab, close_tab, list_frames, switch_framesave_storage_state, load_storage_statestart_video, stop_videoget_console_errors, get_network_failures, get_test_report, get_capabilitiesOpen 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 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 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 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)
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)
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)
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)
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)
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)
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)
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)
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)
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 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 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)
Choose an option of a
| Argument | Type | Description |
|---|---|---|
target (required) |
target | A string or a locator: see Targets and locators. |
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)
Select files on an 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)
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)
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)
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)
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)
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)
Active routes with how often each matched.
No arguments.
TypeScript: page.listRoutes() · Python: page.list_routes()
Requests the page made (method, URL, status, whether mocked), newest last.
No arguments.
TypeScript: page.getRequests() · Python: page.get_requests()
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)
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()
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 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)
Frames (iframes) of the active tab with their names and URLs.
No arguments.
TypeScript: page.listFrames() · Python: page.list_frames()
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)
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)
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)
Start recording a video of the active tab (follows tab switches).
No arguments.
TypeScript: page.startVideo() · Python: page.start_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)
Console errors and uncaught exceptions seen in this session.
No arguments.
TypeScript: page.getConsoleErrors() · Python: page.get_console_errors()
Failed requests and HTTP >= 400 responses seen in this session.
No arguments.
TypeScript: page.getNetworkFailures() · Python: page.get_network_failures()
Assertions, failed actions, diagnostics and evidence for this session.
No arguments.
TypeScript: page.getTestReport() · Python: page.get_test_report()
Backend, engine version and per-feature support for this session.
No arguments.
TypeScript: page.getCapabilities() · Python: page.get_capabilities()
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
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
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
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
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
Report the environment: OS, browser binaries, versions, desktop WebView dependencies
Usage: swiftbrowse doctor [OPTIONS]
Options:
--json
-h, --help Print help
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
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
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
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
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.
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, … |
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.
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.
| 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. |
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 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_DAEMONS), so parallel workers don't share one browser's main thread.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.swiftbrowse daemon stop. Daemons are tied to the SwiftBrowse version, so different builds never mix.--no-daemon. Headed sessions never use the daemon.swiftbrowse daemon status
swiftbrowse daemon stop
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. |
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.confirm() and prompt() are dismissed, never accepted on the user's behalf. alert() is acknowledged. Both are reported.doctor and the capability report warn about it; rely on your container as the boundary.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.
The same 29 tests, written three ways:
swiftbrowse test (examples/e2e/tests)node:test (bench/e2e/sdk)fullyParallel (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.
cd bench && npm install && node e2e.mjs ../target/release/swiftbrowse
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
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
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.
node:test hooks, Vitest reporters). The JSON test runner has its own reporters.switch_frame to move between frames.transform are refused rather than mapped, because the click guard detects that they would miss.--no-daemon if you need separate processes.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.