CLI Usage
If playwriter command is not found, install globally or use npx/bunx:
npm install -g playwriter@latest
# or use without installing:
npx playwriter@latest session new
bunx playwriter@latest session new
If using npx or bunx always use @latest for the first session command. so we are sure of using the latest version of the package
Session management
Each session runs in an isolated sandbox with its own state object. Use sessions to:
- Keep state separate between different tasks or agents
- Persist data (pages, variables) across multiple execute calls
- Avoid interference when multiple agents use playwriter simultaneously
Get a new session ID to use in commands:
playwriter session new
# outputs: 1
Always use your own session - pass -s <id> to all commands. Using the same session preserves your state between calls. Using a different session gives you a fresh state.
List all active sessions with their state keys:
playwriter session list
# ID State Keys
# --------------
# 1 myPage, userData
# 2 -
Reset a session if the browser connection is stale or broken:
playwriter session reset <sessionId>
Remote access (control browser from another machine)
Playwriter can control a Chrome browser running on a different machine over the internet. The host machine runs playwriter serve with a traforo tunnel, and the remote machine connects through the tunnel URL.
# Host machine (has Chrome + extension)
npx -y traforo -p 19988 -- npx -y playwriter serve --token MY_SECRET_TOKEN
# Remote machine
export PLAYWRITER_HOST=https://<tunnel-id>-tunnel.traforo.dev
export PLAYWRITER_TOKEN=MY_SECRET_TOKEN
playwriter session new
playwriter -s 1 -e "await page.goto('https://example.com')"
For the full guide (Docker, LAN, MCP config, security), see: https://playwriter.dev/docs/remote-access
Direct CDP connection (no extension needed)
Playwriter can connect directly to a Chrome instance via the Chrome DevTools Protocol, bypassing the browser extension entirely. This is useful for:
- Chrome running with remote debugging enabled (CI, Docker, headless environments)
- Cloud browser providers that expose a CDP endpoint (e.g.
wss://xxx.cdp.browser-use.com) - Any service or machine that gives you a
ws://orwss://URL to a Chrome DevTools session
Prerequisites: you need a CDP-enabled Chrome. Either:
- Open
chrome://inspect/#remote-debuggingin Chrome - Launch Chrome with
--remote-debugging-port=9222 - Use
playwriter browser start(enables debugging automatically) - Use a cloud browser provider URL (no local Chrome needed)
CLI usage:
# Auto-discover local Chrome instances with debugging enabled
playwriter session new --direct
# Connect to a specific CDP endpoint (local or cloud browser provider)
playwriter session new --direct ws://localhost:9222/devtools/browser/...
playwriter session new --direct wss://xxx.cdp.browser-use.com
# Connect to a remote Chrome instance (host:port auto-resolves to ws://)
playwriter session new --direct 192.168.1.50:9222
# Then use the session normally
playwriter -s 1 -e "await page.goto('https://example.com')"
MCP configuration (for AI assistants): set the PLAYWRITER_DIRECT env var in your MCP client config. If the user provides a CDP URL (like wss://xxx.cdp.browser-use.com), use it as the value:
{
"mcpServers": {
"playwriter": {
"command": "npx",
"args": ["-y", "playwriter@latest"],
"env": {
"PLAYWRITER_DIRECT": "wss://xxx.cdp.browser-use.com"
}
}
}
}
PLAYWRITER_DIRECT accepts:
1— auto-discover Chrome on port 9222ws://orwss://URL — explicit WebSocket endpoint (local or cloud browser provider)host:port— resolves via HTTP probe to a ws:// URL
Limitations: screen recording (recording.start/recording.stop) is not available in direct CDP mode since it relies on the extension's chrome.tabCapture API.
Headless browser (no extension, no user browser)
Launch a headless Chrome automatically. No extension setup, no user browser involvement. Useful when the user doesn't want their personal browser used, in CI/server environments, or for fully autonomous automation.
# Install Chrome for Testing (first time only, if no Chrome is available)
playwriter browser install
# Launch headless Chrome and create a session
playwriter session new --browser headless
# Use the session normally
playwriter -s 1 -e "await page.goto('https://example.com')"
playwriter -s 1 -e "console.log(await snapshot({ page }))"
Multiple sessions reuse the same headless Chrome process. Recording is not available in headless mode.
If no Chrome binary is found, playwriter session new --browser headless will tell you to run playwriter browser install first to download Chrome for Testing.
Cloud browsers (stealth, proxies, CAPTCHA solving)
Cloud browsers are full Chromium instances running in the cloud. They work exactly like a local Chrome session but with stealth and anti-detection built in. No local Chrome or extension needed.
When to use cloud browsers:
- CAPTCHA bypass. Cloudflare Turnstile, reCAPTCHA v2/v3, and hCaptcha are solved automatically via token injection. No API keys, no manual solving, no extra code.
- Anti-detection. Stealth Chromium patches remove
navigator.webdriver, CDP leak fingerprints, and other automation signals. Sites that block Playwright, Puppeteer, or Selenium work normally. - Residential proxies. Route traffic through residential IPs in 195+ countries with
--proxy <region>. Proxy is disabled by default to save cost; enable it only when you need anti-detection or geo-targeting. - VPS and headless environments. Run browser automation from any server without installing Chrome. The cloud browser runs remotely and you connect via CDP.
- Parallel execution. Spin up multiple cloud browsers to run tasks in parallel with subagents. Each browser is an isolated instance with its own IP, fingerprint, and cookie jar.
- Multiple identities. Control separate logged-in accounts on the same site simultaneously. Each cloud browser has independent cookies and storage, so sessions don't interfere with each other.
Authentication: two options depending on your environment.
# Option 1: Interactive login (opens browser for OAuth)
playwriter cloud login
# Option 2: API key (for CI, VPS, headless — no browser needed)
# Create one at https://playwriter.dev/dashboard, then:
export PLAYWRITER_API_KEY=pw_xxxxx
# Check active cloud sessions
playwriter cloud status
# Start a cloud browser session (no proxy, cheapest)
playwriter session new --browser cloud
# Start with US residential proxy (for anti-detection / geo-targeting)
playwriter session new --browser cloud --proxy us
# Use a different region
playwriter session new --browser cloud --proxy de
# Use a custom proxy
playwriter session new --browser cloud --custom-proxy user:pass@host:8080
Cloud sessions auto-stop after 10 minutes of inactivity. When proxy is enabled, raster images are blocked by default to reduce bandwidth costs. Pass --disable-proxy-bandwidth-acceleration if you need images to load.
Execute code
playwriter -s <sessionId> -e "<code>"
The -s flag specifies a session ID (required). Get one with playwriter session new. Use the same session to persist state across commands.
Execution timeout: default is 10000ms. Override per call with --timeout <ms>, or set a new default via env:
# One-off longer timeout
playwriter -s 1 --timeout 120000 -e '...'
# Default for all -e/-f in this shell
export PLAYWRITER_EXEC_TIMEOUT=30000
playwriter -s 1 -e '...'
PLAYWRITER_EXEC_TIMEOUT is the default fallback. --timeout overrides it, and MCP clients can set the env var or pass a per-call timeout. Examples:
# Navigate to a page
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
# Click a button
playwriter -s 1 -e 'await state.page.click("button")'
# Get page title
playwriter -s 1 -e 'await state.page.title()'
# Take a screenshot
playwriter -s 1 -e 'await state.page.screenshot({ path: "/absolute/path/to/screenshot.png", scale: "css" })'
# Get accessibility snapshot
playwriter -s 1 -e 'await snapshot({ page: state.page })'
# Get accessibility snapshot for a specific iframe
playwriter -s 1 -e 'const frame = await state.page.locator("iframe").contentFrame(); await snapshot({ frame })'
Why single quotes? Always wrap -e code in single quotes ('...') to prevent bash from interpreting $, backticks, and other special characters inside your JS code. Use double quotes or backtick template literals for strings inside the JS code.
Multiline code:
# Preferred: use heredoc with quoted delimiter (disables all bash expansion)
playwriter -s 1 -e "$(cat <<'EOF'
const links = await state.page.$$eval('a', els => els.map(e => e.href));
console.log('Found', links.length, 'links');
const price = text.match(/\$[\d.]+/);
EOF
)"
# Alternative: $'...' syntax (but beware: \n and \t become special, and
# single quotes inside must be escaped as ')
playwriter -s 1 -e $'
const title = await state.page.title();
const url = state.page.url();
console.log({ title, url });
'
Quoting rules summary:
- Single quotes (
'...'): best for one-liners. No bash expansion at all. But you cannot include a literal single quote inside — use double quotes for JS strings instead. - Heredoc (
<<'EOF'): best for multiline code. The quoted'EOF'delimiter disables all bash expansion. Any character works inside, including$, backticks, and single quotes. $'...': allows'escaping but\n,\t,\\become special — conflicts with JS regex patterns.
Execute from file
For longer scripts, use -f instead of -e to execute JavaScript from a file:
playwriter -s 1 -f script.js
The file is read from disk and executed in the same sandbox as -e. All context variables (state, page, context, etc.) are available. -e and -f cannot be used together.
Recording user actions for skill generation
Before any recorder work, run playwriter skill once and read the full output (never truncate).
The user can start recording from the in-page toolbar (Record) or ask you to run playwriter recorder start. Both write the same event file. The toolbar does not pick a session; the relay attaches to any free extension session (or creates one). Session choice does not matter: extension sessions share the same Chrome tabs. You identify the recording later at stop time.
playwriter recorder start records everything the user does in the browser (clicks, typing, navigations, mutating xhr/fetch) as events with generated locator strings. It also saves a jpeg of each visual change into a frames folder (~/.playwriter/recordings/<id>/frames, files named <ms>.jpg). User clicks flash a ripple in those frames. To see the screen at an event, read the jpeg whose filename is closest to that event's ms. When the user asks you to "start recording", run it and let them perform their workflow. You may run playwriter commands on that session if they ask (snapshot, inspect, click something). If they did not ask, ask first. Do not drive the workflow yourself.
playwriter recorder start # reuse the only session, or create one
playwriter recorder start -s 1 # attach to an existing session
playwriter recorder status # active recordings + current page urls
playwriter recorder stop # stop the only active recording
playwriter recorder stop 3 # stop recording 3 when several are active
playwriter recorder events # thin timeline of the latest recording
playwriter recorder events -r 3 # events of recording 3
playwriter recorder events 4 7 # full details of events 4 and 7
Run playwriter recorder stop when they say done, then playwriter recorder events -r <id> to read the events. If stop fails because more than one recording is active, the error lists each recording id, session, and current or last page URL. Pick the one that matches the workflow (or ask the user), then playwriter recorder stop <id>. Replay the flow with playwriter commands only (playwriter -s <id> -e '...'), never raw Playwright. The recorder start output prints full instructions for turning a recording into a reusable skill: a SKILL.md of markdown instructions with example playwriter commands, plus an importable helper script (submit.js, sdk.js) for cheap replay. Recording runs inside the relay daemon, so it survives CLI exits. Pass -s <id> to record an existing session; the recorder attaches to all Playwriter-enabled tabs and does not open a new tab. A recording auto-stops after 20 minutes.
If the user started from the toolbar and then says "done", still run playwriter recorder stop (or stop <id> if several are listed). The toolbar Stop button also works; it stops the recording it started.
Live streaming to RTMP (X Live, Twitch, YouTube)
Niche use case: playwriter stream start|stop|status streams a tab live to RTMP endpoints via ffmpeg, surviving navigation and running 24/7 after the CLI exits. Docs: https://playwriter.dev/docs/streaming
Debugging playwriter issues
If some internal critical error happens you can read the relay server logs to understand the issue. The log file is located in the user home directory:
playwriter logfile # prints the log file path
# typically: ~/.playwriter/relay-server.log
The relay log contains logs from the extension, MCP and WS server. A separate CDP JSONL log is created alongside it (see playwriter logfile) with all CDP commands/responses and events, with long strings truncated. Both files are recreated every time the server starts. For debugging internal playwriter errors, read these files with grep/rg to find relevant lines.
Example: summarize CDP traffic counts by direction + method:
jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c
If you find a bug, you can create a gh issue using gh issue create -R remorses/playwriter --title title --body body. Ask for user confirmation before doing this.
playwriter best practices
Control user's Chrome browser via playwright code snippets. Prefer single-line code with semicolons between statements. Use playwriter immediately without waiting for user actions; only if you get "extension is not connected" or "no browser tabs have Playwriter enabled" should you ask the user to click the playwriter extension icon on the target tab.
When to use playwriter instead of webfetch/curl: If a website is JS-heavy (SPAs like Instagram, Twitter, Facebook, etc.), has cookie consent modals, login walls, lazy-loaded content, carousels, or infinite scroll — always use playwriter. Simple fetch/webfetch will return an empty HTML shell with no content. Do NOT waste time trying curl, webfetch, or parsing raw HTML from JS-rendered sites. Go straight to playwriter: navigate with a real browser, dismiss modals, then extract what you need via page.evaluate() or network interception.
If Chrome is not running, the extension can't connect. Start Chrome from the command line before retrying:
# macOS
open -a "Google Chrome" --args --profile-directory=Default
# Linux
google-chrome --profile-directory=Default &
# Windows (cmd)
start chrome.exe --profile-directory=Default
# Windows (PowerShell)
Start-Process chrome.exe -ArgumentList '--profile-directory=Default'
To also enable automatic tab capture for screen recording (no manual extension click needed), add the --allowlisted-extension-id and --auto-accept-this-tab-capture flags:
# macOS
open -a "Google Chrome" --args --profile-directory=Default --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture
# Linux
google-chrome --profile-directory=Default --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture &
# Windows
start chrome.exe --profile-directory=Default --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture
You can collaborate with the user - they can help with captchas, difficult elements, or reproducing bugs.
Direct CDP mode (no extension needed): Playwriter can connect directly to Chrome's DevTools Protocol, bypassing the extension. This is useful in CI, Docker, headless environments, when Chrome has --remote-debugging-port=9222, or with cloud browser providers (e.g. wss://xxx.cdp.browser-use.com). If the user provides a CDP URL, set PLAYWRITER_DIRECT in the MCP client config:
{
"mcpServers": {
"playwriter": {
"command": "npx",
"args": ["-y", "playwriter@latest"],
"env": {
"PLAYWRITER_DIRECT": "wss://xxx.cdp.browser-use.com"
}
}
}
}
PLAYWRITER_DIRECT accepts 1 (auto-discover Chrome on port 9222), a ws:// or wss:// endpoint (including cloud browser providers), or host:port. Screen recording is not available in direct CDP mode since it relies on the extension's chrome.tabCapture API.
context variables
state- object persisted between calls within your session. Each session has its own isolated state. Use to store pages, data, listeners (e.g.,state.page = await context.newPage())page- a default page (may be shared with other agents). Prefer creating your own page and storing it instate(see "working with pages")context- browser context, access all pages viacontext.pages()require- load Node.js modules (e.g.,const fs = require('node:fs'))import()- use Node.js ESM to load local scripts, packages, and built-ins (e.g.,const helpers = await import('./scripts/helpers.js')). Relative paths resolve from the session cwdimportModule- restricted async import for allowlisted Node.js built-ins (e.g.,const fs = await importModule('node:fs'))- Node.js globals:
setTimeout,setInterval,fetch,URL,Buffer,crypto,process, etc.
Not available in the sandbox: __dirname, __filename.
importing local scripts
Local modules use normal Node.js ESM. Export helper functions from a .js or .mjs file and pass Playwriter values such as page explicitly:
// scripts/page-helpers.mjs
export async function getPageInfo({ page }) {
return {
title: await page.title(),
url: page.url(),
}
}
Load the module from the directory where the Playwriter session was created:
const { getPageInfo } = await import('./scripts/page-helpers.mjs')
console.log(await getPageInfo({ page }))
Local modules can use static imports and package imports normally:
// scripts/save-title.mjs
import fs from 'node:fs/promises'
import path from 'node:path'
export async function saveTitle({ page, outputPath }) {
await fs.mkdir(path.dirname(outputPath), { recursive: true })
await fs.writeFile(outputPath, await page.title())
}
Security: Modules loaded with import() run with normal Node.js permissions. Only import code you trust. Use sandboxed require or importModule when you need restricted built-ins and scoped filesystem writes.
Important: state is session-isolated but pages are shared across all sessions. See "working with pages" for how to avoid interference.
Sandboxed fs write restrictions: require('node:fs') is scoped. Writes (writeFileSync, mkdirSync, etc.) only succeed in:
- The directory where
playwriterCLI was invoked (the session's cwd) /tmp- The OS temp directory (
os.tmpdir(), e.g./var/folders/.../T/on macOS)
Writing to any other path (e.g. ~/Downloads, ~/Desktop) throws EPERM: operation not permitted, access outside allowed directories. To save files elsewhere, write to a temp path first, then move the file using a shell command outside the sandbox.
rules
- Initialize state.page first: see "working with pages" — at the start of a task, assign
state.page(reuseabout:blankor create one) and usestate.pagefor all automation steps. - Multiple calls: use multiple execute calls for complex logic - helps understand intermediate state and isolate which action failed
- Never close: never call
browser.close()orcontext.close(). Only close pages you created or if user asks - No bringToFront: never call unless user asks - it's disruptive and unnecessary, you can interact with background pages
- Click before keyboard input in extension mode. Call
click()on the target field immediately beforefill()orkeyboardmethods. CDP sends keyboard input to the browser's OS-focused surface, so DOM focus andbringToFront()can still leave text in Chrome's omnibox. - Check state after actions: always verify page state after clicking/submitting (see next section)
- Clean up only your listeners: remove listeners you added by event name or handler reference. Never call
removeAllListeners()because it also removes Playwriter's page error and console listeners. - Tracked page errors are automatic: uncaught errors from pages assigned directly to
statekeys appear in the current or next execute output as[PAGE ERROR]. Errors from pages tracked by other sessions are excluded. - Always print page logs after every action: call
getLatestLogs({ page: state.page, sinceLastCall: true })after every goto, click, or submit to catch console errors and warnings. Do not manually collectpage.on('console')events; manual listeners miss logs emitted before the listener is attached. The firstsinceLastCallcall returns all buffered logs including startup and hydration errors. - CDP sessions: use
getCDPSession({ page: state.page })notstate.page.context().newCDPSession()- NEVER usenewCDPSession()method, it doesn't work through playwriter relay - Wait for load: use
state.page.waitForLoadState('domcontentloaded')notstate.page.waitForEvent('load')- waitForEvent times out if already loaded - Minimize timeouts: prefer proper waits (
waitForSelector,waitForPageLoad) overstate.page.waitForTimeout(). Short timeouts (1-2s) are acceptable for non-deterministic events like animations, tab opens, or async UI updates where no specific selector is available - Snapshot before screenshot: always use
snapshot()first to understand page state (text-based, fast, cheap). Only usescreenshotwhen you specifically need visual/spatial information. Never take a screenshot just to check if a page loaded or to read text content — snapshot gives you that instantly without burning image tokens - Always use absolute file paths for Playwright artifact APIs: for
page.screenshot({ path }),locator.screenshot({ path }),elementHandle.screenshot({ path }),page.pdf({ path }),download.saveAs(path), andvideo.saveAs(path), always pass an absolute path. Relative paths are resolved by Playwright client internals, not the sandboxedfs, so they may use the relay server cwd instead of your session cwd. - Snapshot replaces page.evaluate() for inspection: do NOT write
page.evaluate()calls to manually query class names, bounding boxes, child counts, or visibility flags.snapshot()already shows every interactive element with its text, role, and a ready-to-use locator. If you catch yourself writingdocument.querySelectororgetBoundingClientRectinside evaluate — stop and usesnapshot()instead. Reservepage.evaluate()for actions that modify page state (e.g.,localStorage.clear(), scroll manipulation) or extract non-DOM data (e.g.,window.__CONFIG__)
interaction feedback loop
Every browser interaction must follow observe → act → observe. Never chain multiple actions blindly.
- Open page — get or create your page, navigate to URL
- Observe — print
state.page.url()+snapshot()+getLatestLogs({ sinceLastCall: true }). Always print URL — pages can redirect unexpectedly. - Check — if page isn't ready (loading, wrong URL, content missing), wait and observe again
- Act — perform one action (click, type, submit)
- Observe again — print URL + snapshot + page logs to verify the action's effect
- Repeat from step 3 until task is complete
Always print page logs after every action using getLatestLogs({ sinceLastCall: true }). This returns only new console messages and errors since the last call, so you catch hydration errors, failed network requests, and runtime exceptions without duplicates. The first call returns all buffered logs from the page, including logs emitted before your script started.
// Each step should be a separate execute call:
// Step 1: navigate + observe
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
console.log('URL:', state.page.url())
console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
await snapshot({ page: state.page }).then(console.log)
// Step 2: act + observe
await state.page.locator('button:has-text("Submit")').click()
console.log('URL:', state.page.url())
console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
await snapshot({ page: state.page }).then(console.log)
If nothing changed after an action, try waitForPageLoad({ page: state.page, timeout: 3000 }) or you may have clicked the wrong element.
Deeper observation — when snapshots aren't enough to understand what happened, combine snapshot with filtered logs:
// Search for specific errors in all logs (not just since last call)
const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 })
// Combine snapshot + filtered logs for full picture
const snap = await snapshot({ page: state.page, search: /dialog|error|message/ })
const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 })
console.log('UI:', snap)
console.log('Logs:', logs)
Use getLatestLogs({ sinceLastCall: true }) after every action, getLatestLogs({ search }) for targeted debugging, state.page.url() for navigation, screenshots only for visual layout issues.
common mistakes to avoid
1. Not verifying actions succeeded Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
await state.page.keyboard.type('my text')
await snapshot({ page: state.page, search: /my text/ })
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
2. Assuming paste/upload worked
Clipboard paste (Meta+v) can silently fail. For file uploads, prefer file input:
// Reliable: use file input
const fileInput = state.page.locator('input[type="file"]').first()
await fileInput.setInputFiles('/path/to/image.png')
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example
await state.page.keyboard.press('Meta+v') // always verify with screenshot!
3. Using stale locators from old snapshots
Locators (especially ones with >> nth=) can change when the page updates. Always get a fresh snapshot before clicking, then immediately use locators from that output:
await snapshot({ page: state.page, showDiffSinceLastCall: true })
// Now use the NEW locators from this output
4. Wrong assumptions about current page/element Before destructive actions (delete, submit), verify you're targeting the right thing:
// Before deleting, verify it's the right item
await screenshotWithAccessibilityLabels({ page: state.page })
// READ the screenshot to confirm, THEN proceed with delete
5. Text concatenation without line breaks
keyboard.type() doesn't insert newlines from \n in strings. Use keyboard.press('Enter') between lines:
await state.page.keyboard.type('Line 1')
await state.page.keyboard.press('Enter')
await state.page.keyboard.type('Line 2')
6. Quote escaping in bash
Bash parses $, backticks, and \ inside double-quoted strings. This silently corrupts JS code. Always use single quotes or heredoc:
# single quotes — bash passes everything through literally
playwriter -s 1 -e 'await state.page.locator(`[id="_r_a_"]`).click()'
# heredoc for complex code with mixed quotes
playwriter -s 1 -e "$(cat <<'EOF'
await state.page.locator('[id="_r_a_"]').click()
const match = html.match(/\$[\d.]+/g)
EOF
)"
7. Using screenshots when snapshots suffice Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues. Use snapshot for text checks:
await snapshot({ page: state.page, search: /expected text/i })
8. Assuming page content loaded
Even after goto(), dynamic content may not be ready:
await state.page.goto('https://example.com')
// Content may still be loading via JavaScript!
await state.page.waitForSelector('article', { timeout: 10000 })
// Or use waitForPageLoad utility
await waitForPageLoad({ page: state.page, timeout: 5000 })
9. Not using playwriter for JS-rendered sites Do NOT waste context trying webfetch, curl, or Playwright CLI screenshots on SPAs (Instagram, Twitter, etc.). These return empty HTML shells. Use playwriter directly:
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.page.goto('https://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page: state.page, timeout: 8000 })
await snapshot({ page: state.page, search: /cookie|consent|accept/i }).then(console.log)
10. Login buttons that open popups
Popup windows (window.open with features, OAuth buttons) are auto-relocated to tabs in the main window by the Playwriter extension. The new tab appears in context.pages() and is fully controllable. You will receive a [WARNING] New page opened from current page (index N, initial url: ...) message pointing to the new tab — the initial url may be about:blank for blank-then-scripted popups, so check context.pages()[N].url() for the final URL:
await state.page.locator('button:has-text("Login with Google")').click()
await state.page.waitForTimeout(1000)
// New tab is the last page in the context
const pages = context.pages()
const loginPage = pages[pages.length - 1]
// Complete login flow in loginPage, cookies are shared with original page
await loginPage.locator('[data-email]').first().click()
await loginPage.waitForURL('**/callback**')
// Original page should now be authenticated
11. Click times out or does nothing — snapshot to find the blocker
When a click times out, a modal or overlay is likely intercepting pointer events. Do not retry with different selectors or { force: true } — snapshot to find the blocker:
// click timed out → don't retry blindly, find what's blocking
await snapshot({ page: state.page, search: /dialog|modal/i })
// Found modal → interact with it properly (don't just close via X, it may reappear)
await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click()
12. Never use dispatchEvent or { force: true } to bypass blockers
dispatchEvent(new MouseEvent(...)), { force: true }, and element.click() inside page.evaluate() bypass Playwright checks but do not trigger React/Vue/Svelte handlers — state won't update. Use snapshot to find the real interactive element:
await state.page.getByRole('radio', { name: 'Node.js' }).click()
13. Over-investigating instead of just interacting
When something doesn't respond to a click, do NOT start inspecting CDP event listeners, React fibers, canvas pixel data, or writing page.evaluate() to read class names and bounding boxes. This wastes massive context. Instead:
- Take a
snapshot()— it shows every interactive element and what to click - Try a different interaction pattern if
click()didn't work:- Drawing/annotation tools, canvas paint →
mouse.down, move with steps,mouse.up(see drag section) - Keyboard-activated modes → press the shortcut key (snapshot shows tooltip text like "Draw mode D")
- Sliders, timeline scrubbers → drag pattern
- Collapsed/toggled toolbars → click the toggle first, wait, then interact
- Drawing/annotation tools, canvas paint →
- Take another
snapshot()to see what changed - Only investigate DOM internals if correct interaction patterns produce zero response after 2–3 attempts
accessibility snapshots
await snapshot({ page: state.page, search?, showDiffSinceLastCall? })
search- string/regex to filter results (returns first 10 matching lines)showDiffSinceLastCall- returns diff since last snapshot (default:true, butfalsewhensearchis provided). Passfalseto get full snapshot.
Snapshots return full content on first call, then diffs on subsequent calls. Diff is only returned when shorter than full content. If nothing changed, returns "No changes since last snapshot" message. Use showDiffSinceLastCall: false to always get full content. When search is provided, diffing is disabled by default so the search filters the full content — pass showDiffSinceLastCall: true explicitly to combine both. This diffing behavior also applies to getCleanHTML and getPageMarkdown.
Example output:
- banner:
- link "Home" [id="nav-home"]
- navigation:
- link "Docs" [data-testid="docs-link"]
- link "Blog" role=link[name="Blog"]
Each interactive line ends with a Playwright locator you can pass to state.page.locator().
If multiple elements share the same locator, a >> nth=N suffix is added (0-based)
to make it unique.
Use snapshot locators directly — never invent selectors. The snapshot output IS the selector. Do not guess CSS selectors or getByText when the snapshot already gives you the exact match:
// Snapshot shows: role=radio[name="Nope, Vanilla"] → use it directly
await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click()
// Snapshot shows: role=link[name="SIGN IN"] → or pass raw string to locator()
await state.page.locator('role=link[name="SIGN IN"]').click()
Beware CSS text-transform: snapshots show visual text (heading "NODE.JS") but DOM may be "Node.js". Use case-insensitive regex: getByRole('heading', { name: /node\.js/i }).
If a screenshot shows ref labels like e3, resolve them using the last snapshot:
const snap = await snapshot({ page: state.page })
const locator = refToLocator({ ref: 'e3' })
await state.page.locator(locator!).click()
Search for specific elements:
const snap = await snapshot({ page: state.page, search: /button|submit/i })
Scoping snapshots to a specific element — pass a locator instead of page to snapshot only a subtree. This dramatically reduces output size when you only care about one section of the page (e.g., the main content area, ignoring the sidebar/header/footer):
// Full page snapshot: ~150 lines (sidebar, nav, header, footer, everything)
await snapshot({ page: state.page })
// Scoped to main: ~20 lines (just the content you care about)
await snapshot({ locator: state.page.locator('main') })
// Scope to a specific form, dialog, or section
await snapshot({ locator: state.page.locator('[role="dialog"]') })
await snapshot({ locator: state.page.locator('form#checkout') })
Use this whenever the full page snapshot is dominated by navigation or layout elements you don't need. It saves significant tokens and makes the output much easier to parse.
Filtering large snapshots in JS — when search isn't enough, filter the string directly: snap.split('\n').filter(l => l.includes('dialog') || l.includes('error')).join('\n')
choosing between snapshot methods
Use snapshot for text-heavy pages (forms, articles) — fast, cheap, searchable. Use screenshotWithAccessibilityLabels for complex visual layouts (grids, galleries, dashboards) where spatial position matters. Both share the same ref system and can be combined.
selector best practices
For unknown websites: use snapshot() - it shows what's actually interactive with stable locators.
For development (when you have source code access), prefer stable selectors in this order:
- Best:
[data-testid="submit"]- explicit test attributes, never change accidentally - Good:
getByRole('button', { name: 'Save' })- accessible, semantic - Good:
getByText('Sign in'),getByLabel('Email')- readable, user-facing - OK:
input[name="email"],button[type="submit"]- semantic HTML - Avoid:
.btn-primary,#submit- classes/IDs change frequently - Last resort:
div.container > form > button- fragile, breaks easily
Combine locators for precision:
state.page.locator('tr').filter({ hasText: 'John' }).locator('button').click()
state.page.locator('button').nth(2).click()
If a locator matches multiple elements, Playwright throws "strict mode violation". Use .first(), .last(), or .nth(n):
await state.page.locator('button').first().click() // first match
await state.page.locator('.item').last().click() // last match
await state.page.locator('li').nth(3).click() // 4th item (0-indexed)
working with pages
Pages are shared, state is not. context.pages() returns all browser tabs with playwriter enabled — shared across all sessions. Multiple agents see the same tabs. If another agent navigates or closes a page you're using, you'll be affected. To avoid interference, get your own page.
Get or create your page (first call):
On your very first execute call, reuse an existing empty tab or create a new one, and navigate it in the same execute call. Store it in state and use state.page for all subsequent operations instead of the default page variable:
// Reuse an empty about:blank tab if available, otherwise create a new one.
// IMPORTANT: always navigate immediately in the same call to avoid another
// agent grabbing the same about:blank tab between execute calls.
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.page.goto('https://example.com')
// Use state.page for ALL subsequent operations
Handle page closures gracefully:
The user may close your page by accident (e.g., closing a tab in Chrome). Always check before using it and recreate if needed:
if (!state.page || state.page.isClosed()) {
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
}
await state.page.goto('https://example.com')
Use an existing page only when the user asks:
Only use a page from context.pages() if the user explicitly asks you to control a specific tab they already opened (e.g., they're logged into an app). Find it by URL pattern and store it in state:
const pages = context.pages().filter((x) => x.url().includes('myapp.com'))
if (pages.length === 0) throw new Error('No myapp.com page found. Ask user to enable playwriter on it.')
if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`)
state.targetPage = pages[0]
List all available pages:
context.pages().map((p) => p.url())
Popup windows become tabs automatically:
The extension intercepts Chrome popup windows (window.open(url, '', 'width=...'), OAuth login flows) and relocates them into the main window as regular tabs. You don't need cmd+click or { modifiers: ['Meta'] } to avoid popups. When a page opens another, you receive a `[WARNING] New page ope
…(truncated)