playwright-rs-usage
Skill166 starsChanged 5 days agoApache-2.0View on GitHub
Procedural reference for using playwright-rs in Rust browser-automation code — object model (Browser/Context/Page/Locator), the `locator!()` macro, builder pattern for options, auto-wait semantics, adding the crate and installing its browsers, and how to capture / inspect traces for failure diagnosis. Use when writing tests or scripts with playwright-rs as a dependency. Loaded automatically when the current repo has playwright-rs in its Cargo.toml.
- Installs packages
In this repository5 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- doctest-conventionsSkill
- driver-bumpSkill
- release-processSkill
- supply-chainSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 0 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 43
Prefer leaving them on. default-features = false is only needed to swap ring for aws-lc, and it drops every default, so the replacements …
- Very specific: line 100
If CI caches the browsers (~/.cache/ms-playwright) and launches WebKit, put the runner image in the cache key and its restore-keys prefix.
- Very specific: line 334
Waiting on arbitrary state:
wait_for_function: When there is no selector to wait on — a JS flag, a store, a counter — … - Very specific: line 408
Use trace::open(path) then TraceReader::actions() to walk the reassembled action stream, TraceReader::network() for HTTP traffic, and …
- Very specific: line 420
Result, not exceptions: Use ? to propagate. - Very specific: line 423
No async
Drop: Always close browsers / stop tracing explicitly in a cleanup block — don't rely on RAII for I/O. - Very specific: line 434
Manual sleeps before assertions: Use expect(..) and let it auto-retry.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Using playwright-rs
Rust bindings for Microsoft Playwright. This crate is a thin JSON-RPC client to the upstream Playwright server, so the API mirrors playwright-python / java / .NET semantics. When unsure about a method's behavior, the upstream Playwright docs are the authoritative reference.
Drift discipline. The canonical API tour lives in the crate-level rustdoc at https://docs.rs/playwright-rs — that's compile-checked against the actual code. This skill is the "what to reach for, what to avoid" overlay: durable conventions, not a method-by-method reference. Two build gates hold it to the crate: the Rust code block below is compiled against the real API, and every cargo feature and browser engine the crate exposes must be named somewhere in this file, so a capability cannot ship here undocumented.
Neither gate checks that this prose is accurate. That residue is real. The text is written in concepts (auto-wait, builder pattern) rather than specific method names so it ages slowly, but when it and the crate disagree, the crate wins: check https://docs.rs/playwright-rs for the version you actually have.
Before any of this works
Two things, and the second one is the one that surprises people.
The crate. playwright-rs in [dependencies], or [dev-dependencies]
if it is test-only, alongside tokio with the full feature. Defaults are
native-tls, macros, ring, and route-service: transport, the
locator!() macro, the crypto backend, and in-process serving through
route_service.
Prefer leaving them on. default-features = false is only needed to swap
ring for aws-lc, and it drops every default, so the replacements have to
be listed back explicitly or locator!() disappears and no TLS transport
remains:
playwright-rs = { version = "0.18", default-features = false, features = [
"aws-lc",
"native-tls",
"macros",
"route-service",
] }Three capabilities are opt-in and off unless asked for. screenshot-diff
turns on pixel-diff screenshot assertions, so reach for it when the task
calls for comparing a rendering against a baseline rather than asserting on
the DOM. trace re-exports the trace-zip parser as playwright_rs::trace,
for code that reads back the traces it recorded. cli builds an installer
binary for use outside a Cargo project;
inside one, prefer the example below, because cargo install compiles a
second copy of the crate that then has to be kept in sync with the
project's lockfile.
The browsers, which are a separate install and are required. A fresh
checkout that only adds the dependency will fail at launch. The crate
bundles one pinned Playwright driver and each driver expects matching
browser builds, so install them through the crate rather than through a
global npx playwright install. That way the browser version rides
Cargo.lock and cannot drift from the driver. Copy
examples/install-browsers.rs
into the project's examples/ and run it once:
cargo run --example install-browsers # all
cargo run --example install-browsers -- chromium firefox # or a subsetThe same line belongs in CI before the test step. Never pin a Playwright
version in a workflow or a package.json: dependabot cannot see the
former and bumps the latter on npm's cadence rather than the crate's, and
either way the driver and browsers stop matching. The driver ships its own
Node runtime, so no setup-node step is needed. In a setup script or
Dockerfile, call
install_browsers
directly instead.
On Linux the browsers also need system libraries: pass --with-deps to
the example (CI usually wants this; it runs the package manager under
sudo), or call install_browsers_with_deps instead. Without it only
browsers install, on every platform — the driver prints a missing-library
box and still exits 0, as npx playwright install does, so the failure
surfaces later as a browser that will not launch. Two traps worth naming:
cargo run --example only resolves examples in the current package, so a
consumer copies the file into their own examples/ first; and Linux is not
special-cased, so a call that omits the flag there installs no libraries.
If CI caches the browsers (~/.cache/ms-playwright) and launches WebKit,
put the runner image in the cache key and its restore-keys prefix.
WebKit is built per OS release, and the installer skips a revision that is
already on disk, so a key on runner.os alone hands one Ubuntu's WebKit to
another, where it fails to start on a missing libicudata. The image is
$ImageOS (ubuntu24, ubuntu26, macos15), a runner variable rather
than part of the env context, so read it in a step first:
echo "os=$ImageOS" >> "$GITHUB_OUTPUT".
The driver, which the build script downloads. The Playwright server and its Node runtime, about 130 MB, are assembled when the crate first compiles, so a fresh build needs network access and a browser launch never does. Every job that compiles the crate pays for it, including ones that only lint or run unit tests, and a mutation runner that builds in several copies pays several times. Two environment variables fix that, set at the job level because a value that differs between steps reruns the build script:
jobs:
lint: # compiles the crate, launches nothing
env:
PLAYWRIGHT_SKIP_DRIVER_DOWNLOAD: "1"
test: # launches browsers
env:
PLAYWRIGHT_DRIVER_CACHE_DIR: ${{ github.workspace }}/.playwright-driver
steps:
- uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-driver # beside the browsers
# The lockfile hash moves on every playwright-rs bump. An
# unversioned key keeps hitting after a bump, so the new driver is
# downloaded every run and never saved.
key: ${{ runner.os }}-playwright-driver-${{ hashFiles('**/Cargo.lock') }}The same skip knob belongs in a local mutation-testing script. A failed download does not fail the build: cargo hides a dependency's build warnings, so the first sign is the launch error, and a lib-only gate never notices at all.
Object model
Playwright start here — Playwright::launch().await?
└── BrowserType .chromium() / .firefox() / .webkit()
└── Browser .launch().await? → owns the browser process
└── BrowserContext isolated cookies / storage
└── Page one tab
└── Locator selector with auto-waitLocator is the workhorse. Build with page.locator(...) or the
semantic get_by_* helpers (get_by_role, get_by_text, etc. —
see docs.rs for the full list) and chain action / assertion methods.
Conventions to follow
Result<T>andasync/awaitontokio. One error type:playwright_rs::Error. Use?to propagate.- Builders / setters for option-heavy methods.
goto,click,screenshot,fill,tracing().start, etc. take anOptionsstruct. These are#[non_exhaustive](so upstream option additions stay non-breaking) — struct literals won't compile. Construct with the type'sbuilder()where it has one, otherwise chain setters offDefault/new():GetByRoleOptions::default().name("OK").exact(true),Cookie::new(name, value).domain("example.com"). The exact method names live on docs.rs; don't memorize them. - Auto-wait + auto-retry. Locator-based actions wait until the
element is actionable;
expect()assertions retry until they hold or time out. Never inserttokio::time::sleepbetween an action and a check — that's a smell. If a wait feels necessary, you probably wantexpect(locator).to_be_visible().awaitor similar. locator!()macro for literal selectors. Compile-time validation catches typos and structural errors. Fall back to&stronly for selectors computed at runtime.- No reimplemented browser protocols. This crate is intentionally thin over the Playwright server. Anything you can't do via Playwright itself, you can't do here.
Minimal test skeleton
This block is compile-checked by cargo xtask verify-agent-docs —
if the API drifts, the verifier fails:
use anyhow::Result;
use playwright_rs::{Playwright, locator, expect};
#[tokio::test]
async fn login_flow() -> Result<()> {
let pw = Playwright::launch().await?;
let browser = pw.chromium().launch().await?;
let context = browser.new_context().await?;
let page = context.new_page().await?;
page.goto("https://example.com/login", None).await?;
page.locator(locator!("input[name='user']")).fill("alice", None).await?;
page.locator(locator!("input[name='pass']")).fill("hunter2", None).await?;
page.locator(locator!("text=Sign in")).click(None).await?;
expect(page.locator(locator!(".welcome"))).to_be_visible().await?;
browser.close().await?;
Ok(())
}Configuring the test runner
Under cargo nextest, browser tests fail out of the box for reasons that
have nothing to do with the test. Add .config/nextest.toml to the
consuming project before writing many of them:
[profile.default]
# A browser and its driver take longer than nextest's 100ms default to be
# reaped after a test ends. Without this, passing tests are intermittently
# flagged "leaky" and fail the run.
leak-timeout = "1s"
# Launching a browser is slow, and Firefox and WebKit are slower than
# Chromium. Worst on Windows.
slow-timeout = { period = "30s", terminate-after = 2 }
[profile.ci]
leak-timeout = "1s"
slow-timeout = { period = "60s", terminate-after = 2 }
retries = 1leak-timeout is the one that will otherwise cost an afternoon: the
symptom is an intermittent "leaky" failure on a test whose assertions all
passed, which reads like a bug in the test.
Raise slow-timeout further for tests that drive Firefox or WebKit
specifically, using a [[profile.default.overrides]] block with a filter.
Reach for retries only in CI, where a retried browser launch is cheaper
than a re-run; locally it hides flakes you want to see.
Teardown itself is already synchronous: dropping Playwright blocks until
the driver exits, closing every browser cleanly rather than signalling them
(which would truncate in-flight traces, videos and HARs). In async code
prefer playwright.shutdown().await, which does the same work without
blocking a runtime thread.
Capabilities worth reaching for
Concept-level pointers; the exact options live on docs.rs.
- Stable / redacted screenshots.
ScreenshotOptionscarriesanimations(Disabled)for flake-free shots (freeze CSS animations before capture) andmask/mask_colorto overpaint dynamic or sensitive elements. Reach foranimations(Disabled)whenever a screenshot races an animation. - Context-level events. Beyond per-page handlers,
BrowserContextobserves activity across all its pages —on_download,on_page_load/on_page_close,on_frame_attached/_detached/_navigated— andBrowser::on_contextfires for each new context. Use these for multi-tab fixtures instead of wiring every page individually. - HAR network capture.
tracing().start_har(path, ..)/stop_har()records all network traffic to a HAR — inspect it in browser devtools or replay it deterministically withroute_from_har. A sibling to trace capture. - Typed page probes.
page.evaluate::<R, T>(expr, arg)deserializes the JS return value straight into any serdeDeserializetype — define a struct for the shape and skip manual parsing (None::<&()>for the no-argument case).evaluate_value(returnsString) is only for one-off scalar probes; if you catch yourself returning delimited strings from JS and splitting them in Rust, switch toevaluate. Runnable walkthroughs:examples/evaluate_typed.rsandexamples/canvas_pixels.rs(canvas pixel assertions for wasm/canvas frontends). - Drags:
Locator::drag_tocovers everything, including canvas. It drives the realpointerdown→ capture →pointermove→pointerupchain (works againstsetPointerCaptureUIs), andDragToOptions::target_position— an offset from the target's top-left — turns it into "drag to a coordinate": pass the containing canvas/stage as the target. Prefer it over held-buttonMouse::move_tosequences, which hang on headless Linux. Don't hand-roll syntheticPointerEventdispatch via evaluate. - External drag-and-drop.
Locator::dropsimulates dragging files or data in from outside the page (upload zones), distinct fromdrag_to, which drags one element onto another within the page. - File System Access API flows.
page.fake_file_system()installs an opt-in fake ofshowSaveFilePicker/showOpenFilePicker(native dialogs no automation tool can drive): seed opens withset_open_file, assert saves withlast_saved_bytes(), control permission state. Don't hand-roll anadd_init_scriptpicker shim. - Accessibility-tree assertions.
expect_page(&page) .to_match_aria_snapshot(..)(and the locator form) guard the page's ARIA structure as a regression check;aria_snapshotcan emit[box=..]bounding boxes for visual/agent reasoning.aria_snapshot_jsonreturns the same tree asserde_json::Valuerather than YAML markup, which is what an agent walking the tree wants; the YAML form is for a human reading a diff. InAriaSnapshotMode::Aievery element carries a ref (button "Save" [ref=e2]), andpage.get_by_ref("e2")turns a ref back into a locator: snapshot, pick a ref, act. A ref belongs to the snapshot that reported it, so take a fresh one after the page changes. - Pages behind HTTP auth.
BrowserContextOptions::builder() .http_credentials(vec![HttpCredentials::new("user", "pass")])— do not hand-set anAuthorizationheader. Give an entry anoriginwhen the test talks to more than one host; the first matching entry wins.APIRequestContextOptionstakes the same list, and is the only one that honorssend(HttpCredentialsSend::Always)— which you need when the server answers403rather than401, leaving nothing to react to. - Dialogs: wait for the close, not the open.
on_dialoghands you the dialog to accept or dismiss;on_dialog_closedfires once it has been answered, which is the point at which the page is interactive again. Both exist onPageandBrowserContext, and context handlers run first. - Visibility is a locator, not a pseudo-class.
locator.visible()narrows to the visible matches (Playwright's replacement for:visible), andfilter(FilterOptions::default().visible(false))is the hidden half. - Testing a page's WebMCP tools. A page that registers tools for
agents through the WebMCP browser API is tested through
page.webmcp():tools(None)lists them with their input schema, andcall_tool::<_, serde_json::Value>("add", &json!({"a": 2}), None)runs one (&()for no input). A tool's own failure comes back as a result withisError, not as anErr. The API is behind a flag: launch Chromium withargs(vec!["--enable-features=WebMCP".into()]), Firefox with thedom.modelcontext.enabledanddom.modelcontext.testing.enableduser prefs; WebKit has none. - Scoping to each parent.
cells.nth(1).within(&rows)is the second cell of every row:nth,first, andlastresolve inside each element the parent matches, which a chainedrows.locator(..)cannot express. Both locators must come from the same frame. - Frames without naming the iframe.
page.frame_locator(None)searches every frame in the subtree, so no iframe selector is needed;Frame::frame_locatortakes the same optional argument. The rest of the locator resolves inside one frame, and matching several is an error, so keep the inner selector unique. - Waiting on arbitrary state:
wait_for_function. When there is no selector to wait on — a JS flag, a store, a counter —page.wait_for_function("() => window.app?.ready", None)polls a predicate and resolves to its value as aJSHandle. The locator form binds the matched element as the first argument (locator.wait_for_function("el => el.dataset.state === 'done'", None)) and returns(). Prefer this overpoll_until-around-evaluateloops: the driver polls onrequestAnimationFrameand enforces the timeout.WaitForFunctionOptions::polling_intervalswitches to timer polling (page-global form only). - Calling back into Rust:
evaluate_with_callback. Hands the expression a Rust closure as its argument — JS calls it, the arguments come to Rust, and the closure's return value resolves the JS promise. The expression may stash the function for later (event listeners); the binding lives until the page closes, so register once rather than in a loop. For a permanently installedwindow.fn, useexpose_function/expose_bindinginstead. - Session save & replay.
context.storage_state(None)captures cookies and per-origin storage;StorageStateOptions::default().credentials(true).indexed_db(true).opfs(true)additionally captures WebAuthn passkeys, IndexedDB, and each origin's private file system.set_storage_state(state)restores into any context — a replace, not a merge: the driver clears storage for every visited origin, and restoring a state withoutcredentialsdisposes an installed virtual authenticator. The fast path for "log in once, reuse everywhere". - Opting out of auto-scroll. Pointer actions scroll the target into
view before acting;
ClickOptions::builder().scroll(Scroll::None)makes the action fail instead — the way to assert something is already visible, or to avoid scroll side effects. - Serving the app from inside the test:
route_service(the release 0.18.0 and later). When the frontend under test is served by Rust (an axumRouter, or a built wasm bundle in a directory via tower-http'sServeDir), hand the service topage.route_service("https://app.example/**", service)or theBrowserContextform and navigate to that origin. Every matching request is answered by the service in-process: no listener, no port, any origin includinghttps://, works in sandboxes. Prefer it over binding an ephemeral port and spawningaxum::servefor a test. Reach for a real listener instead when the app depends on streaming responses, server-sent events, WebSockets (those go throughroute_web_socket), or connection-level behavior, since route interception delivers whole bodies and has no connection. The Sleeping to wait for a page is wrong anyway, and wronger here: a delay tuned against a real listener can be too short once the bundle crosses the driver channel, which is the one behavior change a consumer hit converting a suite. Wait on what the app renders. Theroute_servicemodule rustdoc has the full contract and a wasm testing section.
Debugging failures with traces
Rust has no async Drop, so trace cleanup is explicit. The
canonical pattern: capture the test result, run cleanup
unconditionally, pass the trace path only on failure.
See examples/trace_on_failure.rs
for the runnable end-to-end version — it's compiled by
cargo check --examples so it can't silently rot.
To view a captured trace: playwright show-trace trace.zip. The
viewer is language-agnostic — same UI JS / Python users see. The
hosted version is at https://trace.playwright.dev.
Programmatic trace inspection
For CI bots, agent feedback loops, or any code that wants to read what
happened in a trace without re-running the test, turn on the trace
feature and the parser is playwright_rs::trace, at a version this crate
already pins:
playwright-rs = { version = "0.18", features = ["trace"] }Use trace::open(path) then TraceReader::actions() to walk the
reassembled action stream, TraceReader::network() for HTTP traffic, and
TraceReader::blob(path) to pull a screencast frame or response body out
of the archive. Code that reads traces without driving a browser can
depend on playwright-rs-trace alone instead; it needs 0.2 or later to
read traces from the current driver, since 0.1 reads only the older
trace format and refuses them. The crate's //! rustdoc on
https://docs.rs/playwright-rs-trace has a runnable example.
Things that look like playwright-python but aren't quite
- No
sync_playwright. Async only; everything awaits ontokio. Result, not exceptions. Use?to propagate.- No keyword arguments. Options come through
Optionsstructs with..Default::default(), notname=valuein method calls. - No async
Drop. Always close browsers / stop tracing explicitly in a cleanup block — don't rely on RAII for I/O. - Locators are values, not lazy proxies.
page.locator(...)returns aLocatoryou can.clone()and re-use cheaply. - Functions cannot ride inside evaluate args. Python passes
callables directly; here the callback is a dedicated parameter
(
evaluate_with_callback) since serialized data cannot carry a Rust closure.
Common pitfalls
- Manual sleeps before assertions. Use
expect(..)and let it auto-retry. - Closing the browser before
tracing.stop()— traces are written on stop, so order matters: stop tracing first, then close. - Hardcoding selectors as
&str. Switch tolocator!()for any selector you write as a literal.
References
- Full API: https://docs.rs/playwright-rs
- Runnable examples: https://github.com/padamson/playwright-rust/tree/main/crates/playwright/examples
- Upstream Playwright docs: https://playwright.dev/docs/api
- Trace format / parser: https://docs.rs/playwright-rs-trace
locator!()macro: https://docs.rs/playwright-rs-macros
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

