plwr
Clean CLI for Playwright browser automation with CSS selectors. Built on playwright-rs.
Install
Requires Playwright browsers:
For video conversion to non-webm formats, install ffmpeg.
Usage
Start a browser session, navigate, interact, and stop:
Environment variables
| Variable | Effect |
|---|---|
PLAYWRIGHT_HEADED |
Set to any value to run the browser with a visible window |
PLWR_SESSION |
Default session name (default: default) |
PLWR_TIMEOUT |
Default timeout in ms (default: 5000) |
All commands take -S/--session and -T/--timeout as global options,
which override the environment variables.
Starting and stopping
start launches the browser. All other commands require a running session.
Use --headed (or the PLAYWRIGHT_HEADED env var) to show the browser window.
Commands that interact with page content (text, click, wait, eval,
etc.) require a page to be open first via plwr open. Commands that configure
the session (header, viewport) work before any page is opened.
Navigation
open navigates the current page within the existing browser context. Headers,
cookies, and other state are preserved across navigations. There is no separate
goto command — open always reuses the same context. If you need a fresh
context, use plwr stop followed by plwr start and plwr open.
Waiting
Interaction
Supported keys for press: a–z, A–Z, 0–9, Backspace, Tab,
Enter, Escape, Space, Delete, Insert, ArrowUp, ArrowDown,
ArrowLeft, ArrowRight, Home, End, PageUp, PageDown, F1–F12,
Control, Shift, Alt, Meta, and any US keyboard character
(!@#$%^&*()_+-=[]{}\\|;:'",./<>?`~). Chords use +: Control+c,
Shift+Enter, Meta+a.
Checkboxes and radios
Select dropdowns
Querying
Headers
Set extra HTTP headers sent with every request. Headers persist across
navigations within the same session. Can be set before or after open.
Cookies
Viewport
File uploads
JavaScript
DOM tree
Screenshots
Video
# ... do stuff ...
Sessions
Run multiple independent browser sessions in parallel:
Selectors
Playwright uses its own selector engine that extends CSS. Most standard CSS
selectors work directly, but some advanced pseudo-classes need a css= prefix
to bypass Playwright's parser.
Basics
Combinators
Attribute selectors
Unquoted attribute values work directly. For quoted values, use the css=
prefix (see css= prefix below).
Pseudo-classes that work without prefix
Playwright extensions
These are Playwright-specific and don't exist in standard CSS:
The >> operator chains selectors — each segment is scoped to the previous
match. You can mix CSS and Playwright engines:
css= prefix
Playwright's selector parser auto-detects whether a string is CSS, XPath, or a
Playwright selector. Some valid CSS pseudo-classes confuse the auto-detection
because Playwright tries to interpret parenthesized arguments or quoted strings
as its own syntax. Prefixing with css= forces native CSS evaluation.
Need css= prefix:
| Selector | Example |
|---|---|
:last-of-type |
css=.list span:last-of-type |
:first-of-type |
css=.list p:first-of-type |
:nth-of-type() |
css=span:nth-of-type(2) |
:nth-last-child() |
css=li:nth-last-child(1) |
:is() |
css=:is(.card, .sidebar) |
:where() |
css=:where(.card, .sidebar) > p |
Quoted [attr="val"] |
css=[data-testid="login-form"] |
Work without prefix (Playwright recognizes these natively):
:nth-child(), :first-child, :last-child, :not(), :has(), :empty,
:checked, :disabled, :enabled, :required, :visible, :has-text(),
text=, >> nth=N.
Strict mode
Playwright locators are strict by default — if a selector matches multiple
elements, commands like text, click, and attr will fail. Use >> nth=N
or :nth-match() to pick one:
Shell quoting
Watch out for shell metacharacters in selectors. The $ in $= will be
interpreted by bash if not single-quoted:
Example: cctr e2e test
Before (with raw playwright-cli run-code):
===
send a message
===
./pw --session=e2e run-code "async page => {
const input = await page.waitForSelector('.chat-input', { timeout: 2000 });
await input.fill('Hello agent');
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const msgs = document.querySelectorAll('[data-role=assistant]');
return Array.from(msgs).some(m => m.textContent.includes('Hi'));
}, { timeout: 5000 });
}"
---
After (with plwr):
===
send a message
===
plwr fill '.chat-input' 'Hello agent'
plwr press Enter
---
===
agent responds
===
plwr wait '[data-role=assistant]:has-text("Hi")' -T 10000
---