npx -y skills add https://playwriter.dev
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
state object. Use sessions to:playwriter session new # outputs: 1
-s <id> to all commands. Using the same session preserves your state between calls. Using a different session gives you a fresh state.playwriter session list # ID State Keys # -------------- # 1 myPage, userData # 2 -
playwriter session reset <sessionId>
docs, shop, test, or scrape. Local sessions default to playwriter; remote-control sessions default to remote.--tab-group kimaki.playwriter session new --tab-group docs playwriter session new --tab-group scrape --tab-group-color grey
grey, blue, red, yellow, green, pink, purple, cyan, orange; otherwise color is derived from the name. Chrome can collapse or move groups. Dragging a tab between Playwriter groups keeps it connected; dragging it out disconnects it. Local and remote-control extension sessions only.playwriter package as a library. Do not POST /cli/session/new yourself. Docs: https://playwriter.dev/docs/sessions#node-apiimport { connectViaExtension } from 'playwriter' // auto-closes at scope end, also on throw: closes only tabs it opened, deletes the session await using connection = await connectViaExtension({ tabGroup: 'docs' }) const page = await connection.browser.contexts()[0].newPage() await page.goto('https://example.com')
closeTabsOnEnd: false to keep opened tabs after close, so a later process can reuse them via context.pages().selectBrowser: (browsers) => browsers.find((b) => b.email === 'me@work.com'). Each item has id (stable), browser (Chrome, Brave, Ghost...), email.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 "state.page = await context.newPage(); await state.page.goto('https://example.com')"
wss://xxx.cdp.browser-use.com)ws:// or wss:// URL to a Chrome DevTools sessionchrome://inspect/#remote-debugging in Chrome--remote-debugging-port=9222playwriter browser start (enables debugging automatically)# 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 "state.page = await context.newPage(); await state.page.goto('https://example.com')"
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:// or wss:// URL — explicit WebSocket endpoint (local or cloud browser provider)host:port — resolves via HTTP probe to a ws:// URLrecording.start/recording.stop) is not available in direct CDP mode since it relies on the extension's chrome.tabCapture API.context.newPage() (older extensions reject new tabs; then stay in the shared tab). context.pages()[0] is the tab where the user started sharing.playwriter session new --remote <id> # prints a session id; use it normally afterwards, no extra flags needed playwriter -s 1 -e "state.page = context.pages()[0]; console.log(await state.page.title())"
remote tab group. Set another title with --tab-group <name>.state.page = context.pages()[0] for the shared tab, or await context.newPage() for new work.# 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 "state.page = await context.newPage(); await state.page.goto('https://example.com')" playwriter -s 1 -e "console.log(await snapshot({ page: state.page }))"
playwriter session new --browser headless will tell you to run playwriter browser install first to download Chrome for Testing.navigator.webdriver, CDP leak fingerprints, and other automation signals. Sites that block Playwright, Puppeteer, or Selenium work normally.--proxy <region>. Proxy is disabled by default to save cost; enable it only when you need anti-detection or geo-targeting.# 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
--disable-proxy-bandwidth-acceleration if you need images to load.playwriter -s <sessionId> -e "<code>"
-s flag specifies a session ID (required). Get one with playwriter session new. Use the same session to persist state across commands.--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 '...'
.js file and run it with -f instead of fighting with bash quoting:playwriter -s 1 -f ./scripts/my-automation.js
-f and -e cannot be used together. The file content is executed with the same sandbox context (context, state, snapshot, etc.).# 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({ page: state.page, frame })'
-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.# 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 }); '
'...'): 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.<<'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.-f instead of -e to execute JavaScript from a file:playwriter -s 1 -f script.js
-e. All context variables (state, context, etc.) are available. -e and -f cannot be used together.playwriter skill once and read the full output (never truncate).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
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.playwriter recorder stop (or stop <id> if several are listed). The toolbar Stop button also works; it stops the recording it started.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/streamingplaywriter logfile # prints the log file path # typically: ~/.playwriter/relay-server.log
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.jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c
gh issue create -R remorses/playwriter --title title --body body. Ask for user confirmation before doing this.page.evaluate() or network interception.# 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'
--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
--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.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())context - browser context, access all pages via context.pages(), create one with context.newPage()cloud - list active cloud browsers and send a page's cookies to one. Available in local CLI sessions, but not in cloud or remote-control sessionsrequire - 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'))setTimeout, setInterval, fetch, URL, Buffer, crypto, process, etc.__dirname, __filename, and a default page. Reading page throws. Get your own page (see "working with pages").const browsers = await cloud.browsers.list() console.log(browsers) await cloud.sendCookies({ from: state.page, to: browsers[0] })
await cloud.sendCookies({ from: state.page, to: 'cloud-1' })
sendCookies reads cookies for from.url(). from is required. Pass urls when the login uses more than one HTTP origin:await cloud.sendCookies({ from: state.page, to: 'cloud-1', urls: ['https://example.com', 'https://api.example.com'], })
cloud is available for local extension, headless, and direct CLI sessions. It is not available inside managed cloud sessions, remote-control sessions, or the MCP server. Run playwriter cloud login or set PLAYWRITER_API_KEY on the CLI machine before you create the session. The CLI forwards the credentials to the relay without exposing them in the execution scope. Credentials already present on the relay machine also work..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(), } }
const { getPageInfo } = await import('./scripts/page-helpers.mjs') console.log(await getPageInfo({ page: state.page }))
// 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()) }
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.state is session-isolated but pages are shared across all sessions. See "working with pages" for how to avoid interference.fs write restrictions: require('node:fs') is scoped. Writes (writeFileSync, mkdirSync, etc.) only succeed in:playwriter CLI was invoked (the session's cwd)/tmpos.tmpdir(), e.g. /var/folders/.../T/ on macOS)~/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.page. At the start of a task, assign state.page (a new tab, or the tab the user pointed to) and use state.page for all automation steps.browser.close() or context.close(). Only close pages you created or if user asksclick() on the target field immediately before fill() or keyboard methods. CDP sends keyboard input to the browser's OS-focused surface, so DOM focus and bringToFront() can still leave text in Chrome's omnibox.removeAllListeners() because it also removes Playwriter's page error and console listeners.state keys appear in the current or next execute output as [PAGE ERROR]. Errors from pages tracked by other sessions are excluded.getLatestLogs({ page: state.page, sinceLastCall: true }) after every goto, click, or submit to catch console errors and warnings. Do not manually collect page.on('console') events; manual listeners miss logs emitted before the listener is attached. The first sinceLastCall call returns all buffered logs including startup and hydration errors. getLatestLogs needs page. snapshot needs page, or a locator/frame from your tab.getCDPSession({ page: state.page }) not state.page.context().newCDPSession() - NEVER use newCDPSession() method, it doesn't work through playwriter relaystate.page.waitForLoadState('domcontentloaded') not state.page.waitForEvent('load') - waitForEvent times out if already loadedwaitForSelector, waitForPageLoad) over state.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 availablesnapshot({ page: state.page }) first to understand page state (text-based, fast, cheap). Only use screenshot when 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 tokenspage.screenshot({ path }), locator.screenshot({ path }), elementHandle.screenshot({ path }), page.pdf({ path }), download.saveAs(path), and video.saveAs(path), always pass an absolute path. Relative paths are resolved by Playwright client internals, not the sandboxed fs, so they may use the relay server cwd instead of your session cwd.snapshot({ page: state.page }) shows roles, text, and locators — use it to find elements and check visibility. inspect() shows bounding boxes, scroll state, and computed styles — use it when you need layout or scroll info. Do NOT write page.evaluate() calls to query class names, bounding boxes, or visibility flags. Reserve page.evaluate() for state mutations (e.g., localStorage.clear(), el.scrollTop += 300) or extracting non-DOM data (e.g., window.__CONFIG__)state.page.url() + snapshot({ page: state.page }) + getLatestLogs({ page: state.page, sinceLastCall: true }). Always print URL — pages can redirect unexpectedly.getLatestLogs({ page: state.page, 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 = 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)
waitForPageLoad({ page: state.page, timeout: 3000 }) or you may have clicked the wrong element.// 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)
getLatestLogs({ page: state.page, sinceLastCall: true }) after every action, getLatestLogs({ page: state.page, search }) for targeted debugging, state.page.url() for navigation, screenshots only for visual layout issues.await state.page.keyboard.type('my text') await snapshot({ page: state.page, search: /my text/ }) // If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
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!
>> nth=) can change when the page updates. Main-frame navigation invalidates all refs and diff baselines for that page. 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
// Before deleting, verify it's the right item await screenshotWithAccessibilityLabels({ page: state.page }) // READ the screenshot to confirm, THEN proceed with delete
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')
$, 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 )"
await snapshot({ page: state.page, search: /expected text/i })
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 })
state.page = 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)
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
{ 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()
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()
page.evaluate() to read class names and bounding boxes. This wastes massive context. Instead:snapshot({ page: state.page }) — it shows every interactive element and what to clickclick() didn't work:mouse.down, move with steps, mouse.up (see drag section)snapshot({ page: state.page }) to see what changedawait snapshot({ page: state.page, search?, showDiffSinceLastCall? })
{ page: state.page }, or pass a locator/frame from that tab. snapshot() with no arguments throws. A locator is enough: the helper uses locator.page().await snapshot({ page: state.page, locator: state.page.locator('form') }) await snapshot({ locator: state.page.locator('form') })
search - string/regex to filter results (returns first 10 matching lines)showDiffSinceLastCall - returns diff since last snapshot (default: true, but false when search is provided). Pass false to get full snapshot.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.URL: <current page url> so you can see which tab was snapshotted. Diffs and "No changes" / "No matches found" include that line too.URL: https://example.com/ - banner: - link "Home" [id="nav-home"] - navigation: - link "Docs" [data-testid="docs-link"] - link "Blog" role=link[name="Blog"]
state.page.locator().
If multiple elements share the same locator, a >> nth=N suffix is added (0-based)
to make it unique.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()
heading "NODE.JS") but DOM may be "Node.js". Use case-insensitive regex: getByRole('heading', { name: /node\.js/i }).e3, resolve them using the last snapshot:const snap = await snapshot({ page: state.page }) const locator = refToLocator({ ref: 'e3', page: state.page }) await state.page.locator(locator!).click()
const snap = await snapshot({ page: state.page, search: /button|submit/i })
page and a locator 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({ page: state.page, locator: state.page.locator('main') }) // Scope to a specific form, dialog, or section await snapshot({ page: state.page, locator: state.page.locator('[role="dialog"]') }) await snapshot({ page: state.page, locator: state.page.locator('form#checkout') })
search isn't enough, filter the string directly: snap.split('\n').filter(l => l.includes('dialog') || l.includes('error')).join('\n')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.snapshot({ page: state.page }) - it shows what's actually interactive with stable locators.[data-testid="submit"] - explicit test attributes, never change accidentallygetByRole('button', { name: 'Save' }) - accessible, semanticgetByText('Sign in'), getByLabel('Email') - readable, user-facinginput[name="email"], button[type="submit"] - semantic HTML.btn-primary, #submit - classes/IDs change frequentlydiv.container > form > button - fragile, breaks easilystate.page.locator('tr').filter({ hasText: 'John' }).locator('button').click() state.page.locator('button').nth(2).click()
.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)
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.page in the sandbox. Reading page throws. Helpers that take page (snapshot, getLatestLogs, waitForPageLoad, recording, ghostCursor, refToLocator) need { page: state.page }. snapshot also accepts a locator or frame from your tab.state, and navigate it in the same execute call. Use state.page for all later operations:state.page = await context.newPage() await state.page.goto('https://example.com')
context.pages() only if the user asks you to control a tab they already opened (e.g., they're logged into an app, or they mention its URL or content). Find it by URL and take the last match. context.pages() is ordered oldest first, so the last match is the most recently opened tab:state.page = context.pages().findLast((p) => p.url().includes('myapp.com')) if (!state.page) throw new Error('No myapp.com tab found. Ask the user to enable playwriter on it.')
if (!state.page || state.page.isClosed()) state.page = await context.newPage() await state.page.goto('https://example.com')
context.pages().map((p) => p.url())
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 opened from current page (index N, initial url: ...) and can access it via context.pages()[N].domcontentloaded for page.goto():await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' }) await waitForPageLoad({ page: state.page, timeout: 5000 })
const data = await state.page.evaluate(async (url) => { const resp = await fetch(url) return await resp.text() }, 'https://example.com/protected/resource')
Network.getCookies on the page CDP session:const cdp = await getCDPSession({ page: state.page }) const { cookies } = await cdp.send('Network.getCookies', { urls: [state.page.url()] }) console.log(cookies)
Storage.getCookies is a root-session command and will fail in playwriter.Network.clearBrowserCookies or Network.clearBrowserCache — these CDP commands are profile-wide destructive operations that wipe ALL cookies/cache across every domain in the user's Chrome profile. They will log the user out of Gmail, GitHub, and every authenticated session.Network.getCookies to fetch cookies scoped to URLs, then delete them individually with Network.deleteCookies:const cdp = await getCDPSession({ page: state.page }) const { cookies } = await cdp.send('Network.getCookies', { urls: ['https://example.com', 'https://www.example.com'], }) for (const cookie of cookies) { await cdp.send('Network.deleteCookies', { name: cookie.name, domain: cookie.domain }) }
// Fetch protected data and trigger download to user's Downloads folder await state.page.evaluate(async (url) => { const resp = await fetch(url) const data = await resp.text() const blob = new Blob([data], { type: 'application/octet-stream' }) const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = 'data.json' a.click() }, 'https://example.com/protected/large-file') // File saves to ~/Downloads - read it from there
navigator.clipboard.writeText() - requires permissionwindow.showSaveFilePicker() - requires user gesturea.click(), store data in state, etc).const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')]) await download.saveAs(`/absolute/path/${download.suggestedFilename()}`)
// frameLocator: for chaining locator operations (click, fill, etc.) const frame = state.page.frameLocator('#my-iframe') await frame.locator('button').click() // contentFrame: returns a Frame object, needed for snapshot({ frame }) const frame2 = await state.page.locator('iframe').contentFrame() await snapshot({ page: state.page, frame: frame2 })
state.page.on('dialog', async (dialog) => { console.log(dialog.message()) await dialog.accept() }) await state.page.click('button.trigger-alert')
snapshot({ page: state.page }) right after navigation and dismiss them before doing anything else:// After navigating, check for common obstacles await waitForPageLoad({ page: state.page, timeout: 5000 }) const snap = await snapshot({ page: state.page, search: /cookie|consent|accept|reject|decline|allow|age|verify|login|sign.in/i, }) console.log(snap) // Look for dismiss/accept/decline buttons in the snapshot, then click them: // await state.page.locator('button:has-text("Accept")').click(); // await state.page.locator('button:has-text("Decline optional")').click(); // Then re-snapshot to confirm the modal is gone before proceeding
context.pages().page.evaluate() to extract URLs from the rendered DOM, then download via Node.js in the sandbox. This is far more reliable than parsing raw HTML:// Extract all image URLs from rendered DOM const images = await state.page.evaluate(() => Array.from(document.querySelectorAll('img[src]')).map((img) => ({ src: img.src, alt: img.alt, width: img.naturalWidth, })), ) console.log(JSON.stringify(images, null, 2)) // Download a specific image to disk const fs = require('node:fs') const resp = await fetch(images[0].src) const buf = Buffer.from(await resp.arrayBuffer()) fs.writeFileSync('./downloaded-image.jpg', buf) console.log('Saved', buf.length, 'bytes')
img.src thumbnails.page.evaluate() for element inspection:const info = await inspect({ locator: state.page.locator('.sidebar') }) console.log(info) // Element: div.sidebar // Box: x=1000 y=144 w=396 h=756 // Children: 4 Text length: 1832 // Scroll Y: size=1200 client=756 overflowing=true top=0 // Scroll X: size=396 client=396 overflowing=false left=0 // Styles: overflow-y=auto overflow-x=visible position=sticky max-height=756px display=flex flex-direction=column flex-shrink=0 // Visibility line only appears when something is non-default: // Visibility: opacity=0 pointer-events=none // Input line only appears for form elements: // Input: value="hello" checked=true disabled // Aria line only appears when aria attributes are set: // Aria: aria-expanded=true aria-selected=false // Custom CSS properties const info = await inspect({ locator: state.page.locator('.card'), properties: ['background-color', 'border-radius', 'gap', 'padding'] })
overflow-y, overflow-x, position, max-height, display, flex-direction, flex-shrink. Override with the properties array. Visibility, input state, and aria attributes are always checked regardless of properties.page.on('console') listeners for debugging because they only see future events and can miss logs emitted during page startup or hydration.state keys also appear automatically in execute output. getLatestLogs() keeps the full page log history for filtering and deeper diagnosis.sinceLastCall: true after every action to get only new logs since the previous call. The first call returns all buffered logs including pre-existing ones. Logs persist across navigations so you never miss errors from page transitions.await getLatestLogs({ page: state.page, count?, search?, sinceLastCall? }) // After every action: get only new logs const newLogs = await getLatestLogs({ page: state.page, sinceLastCall: true }) const errors = await getLatestLogs({ page: state.page, search: /error/i, count: 50 }) const pageLogs = await getLatestLogs({ page: state.page, count: 100 }) const hydrationErrors = await getLatestLogs({ page: state.page, search: /hydration|pageerror|React/i })
await getCleanHTML({ locator, search?, showDiffSinceLastCall?, includeStyles? }) // Examples: const html = await getCleanHTML({ locator: state.page.locator('body') }) const html = await getCleanHTML({ locator: state.page, search: /button/i }) const fullHtml = await getCleanHTML({ locator: state.page, showDiffSinceLastCall: false }) // disable diff
locator - Playwright Locator or Page to get HTML fromsearch - string/regex to filter results (returns first 10 matching lines with 5 lines context)showDiffSinceLastCall - returns diff since last call (default: true, but false when search is provided). Pass false to get full HTML.includeStyles - keep style and class attributes (default: false)href, name, type, aria-*, data-*).await getPageMarkdown({ page: state.page, search?, showDiffSinceLastCall? }) // Examples: const content = await getPageMarkdown({ page: state.page, showDiffSinceLastCall: false }) // full article const matches = await getPageMarkdown({ page: state.page, search: /API/i }) // search within content
# Article Title Author: John Doe | Site: example.com | Published: 2024-01-15 > Article excerpt or description The main article content as plain text, with paragraphs preserved...
page - Playwright Page to extract content fromsearch - string/regex to filter content (returns first 10 matching lines with 5 lines context)showDiffSinceLastCall - returns diff since last call (default: true, but false when search is provided). Pass false to get full content.await waitForPageLoad({ page: state.page, timeout?, pollInterval?, minWait? }) // Returns: { success, readyState, pendingRequests, waitTimeMs, timedOut }
const cdp = await getCDPSession({ page: state.page }) const metrics = await cdp.send('Page.getLayoutMetrics')
const selector = await getLocatorStringForElement(state.page.locator('[id="submit-btn"]')) // => "getByRole('button', { name: 'Save' })"
const source = await getReactSource({ locator: state.page.locator('[data-testid="submit-btn"]') }) // => { fileName, lineNumber, columnNumber, componentName }
null for non-React elements and never throws just because an element was not rendered by React. Source locations are usually only available in React dev builds. Props are sanitized and truncated so functions, DOM nodes, circular refs, and huge objects do not flood the output.const info = await getReactComponentInfo({ locator: state.page.locator('[data-testid="submit-btn"]') }) // => { componentName, source, hierarchy, props } | null
outerHTML plus React component info when available. Used by the in-page toolbar and right-click copy flow.await inspectPinnedElement('https://example.com', 'globalThis.playwriterPinnedElem1')
https://playwriter.dev/resources/styles-api.md first with curl or webfetch tool.const styles = await getStylesForLocator({ locator: state.page.locator('.btn'), cdp: await getCDPSession({ page: state.page }), }) console.log(formatStylesAsText(styles))
https://playwriter.dev/resources/debugger-api.md first.const cdp = await getCDPSession({ page: state.page }) const dbg = createDebugger({ cdp }) await dbg.enable() const scripts = await dbg.listScripts({ search: 'app' }) await dbg.setBreakpoint({ file: scripts[0].url, line: 42 }) // when paused: dbg.inspectLocalVariables(), dbg.stepOver(), dbg.resume()
https://playwriter.dev/resources/editor-api.md first.const cdp = await getCDPSession({ page: state.page }) const editor = createEditor({ cdp }) await editor.enable() const matches = await editor.grep({ regex: /console\.log/ }) await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' })
page.screenshot() + resizeImageForAgent() instead (see "taking screenshots" section below).snapshot with search is faster and uses fewer tokens.await screenshotWithAccessibilityLabels({ page: state.page }) // Image and accessibility snapshot are automatically included in response // Use refs from snapshot to interact with elements await state.page.locator('[id="submit-btn"]').click() // Can take multiple screenshots in one execution await screenshotWithAccessibilityLabels({ page: state.page }) await state.page.click('button') await screenshotWithAccessibilityLabels({ page: state.page }) // Both images are included in the response
await resizeImageForAgent({ input: '/absolute/path/to/screenshot.png' }). Also accepts width, height, maxDimension, quality, format (default: 'png'), output. Alias: resizeImage.chrome.tabCapture so recording survives page navigation. Auto-overlays a ghost cursor that follows mouse actions. Requires user to have clicked the Playwriter extension icon on the tab. Auto-resizes viewport to 16:9 (override with aspectRatio: null). Auto-stops after 15 min (override with maxDurationMs).locator.click(), page.mouse.move()) instead of goto() to show realistic cursor motion.await recording.start({ page: state.page, outputPath: '/absolute/path/to/recording.mp4', frameRate: 30, // default audio: false, // default (tab audio) videoBitsPerSecond: 2500000, aspectRatio: { width: 16, height: 9 }, // default, set null to skip maxDurationMs: 15 * 60 * 1000, // default, set 0 to disable }) // Recording survives navigation await state.page.click('a') await state.page.waitForLoadState('domcontentloaded') // Stop — save full result including executionTimestamps for createDemoVideo state.recordingResult = await recording.stop({ page: state.page }) // Other: recording.isRecording({ page: state.page }), recording.cancel({ page: state.page })
await ghostCursor.show({ page: state.page, style: 'screenstudio' }) // 'minimal' (default), 'dot', 'screenstudio' await ghostCursor.hide({ page: state.page }) // hide until next show() or hard navigation
ffmpeg/ffprobe. Timestamps are tracked automatically during recording and returned by recording.stop(). Timeout: can take 60–120+ seconds, always pass --timeout 120000 or higher.// After recording.stop(), save full result to state (executionTimestamps powers idle detection) state.recordingResult = await recording.stop({ page: state.page }) // In a SEPARATE execute call with --timeout 120000: const demoPath = await createDemoVideo({ recordingPath: state.recordingResult.path, durationMs: state.recordingResult.duration, executionTimestamps: state.recordingResult.executionTimestamps, speed: 6, // default 6x for idle sections })
globalThis.playwriterPinnedElem1 (increments for each pin). The reference is copied to clipboard:const el = await state.page.evaluateHandle(() => globalThis.playwriterPinnedElem1) await el.click()
scale: 'css' to avoid 2-4x larger images on high-DPI displays:await state.page.screenshot({ path: '/absolute/path/to/shot.png', scale: 'css' })
await resizeImageForAgent({ input: './shot.png' })
page.evaluate() runs in the browser - use plain JavaScript only, no TypeScript syntax. Return values and log outside (console.log inside evaluate runs in browser, not visible):const title = await state.page.evaluate(() => document.title) console.log('Title:', title) const info = await state.page.evaluate(() => ({ url: location.href, buttons: document.querySelectorAll('button').length, })) console.log(info)
const fs = require('node:fs') const content = fs.readFileSync('./data.txt', 'utf-8') await state.page.locator('textarea').fill(content)
state to analyze across calls:state.requests = [] state.responses = [] state.page.on('request', (req) => { if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() }) }) state.page.on('response', async (res) => { if (res.url().includes('/api/')) { try { state.responses.push({ url: res.url(), status: res.status(), body: await res.json() }) } catch {} } })
console.log('Captured', state.responses.length, 'API calls') state.responses.forEach((r) => console.log(r.status, r.url.slice(0, 80)))
const resp = state.responses.find((r) => r.url.includes('users')) console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
const { url, headers } = state.requests.find((r) => r.url.includes('feed')) const data = await state.page.evaluate( async ({ url, headers }) => { const res = await fetch(url, { headers }) return res.json() }, { url, headers }, ) console.log(data)
state.page.removeAllListeners('request'); state.page.removeAllListeners('response');// Preferred: by locator (stable, auto-waits, no coordinates needed) await state.page.locator('button[name="Submit"]').click() await state.page.locator('text=Login').click({ button: 'right' }) await state.page.locator('text=Login').dblclick() await state.page .locator('a') .first() .click({ modifiers: ['Meta'] }) // cmd+click opens link in new background tab // By coordinates (when locators aren't available, e.g. canvas, maps, custom widgets) await state.page.mouse.click(450, 320) // left click await state.page.mouse.click(450, 320, { button: 'right' }) // right click await state.page.mouse.dblclick(450, 320) // double click await state.page.mouse.click(450, 320, { clickCount: 3 }) // triple click await state.page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
await state.page.locator('.tooltip-trigger').hover() // by locator (preferred) await state.page.mouse.move(450, 320) // by coordinates
// By locator (preferred) await state.page.locator('#footer').scrollIntoViewIfNeeded() // By pixel (for canvas, maps, infinite scroll) await state.page.mouse.wheel(0, 300) // scroll down 300px await state.page.mouse.wheel(0, -300) // scroll up await state.page.mouse.wheel(300, 0) // scroll right await state.page.mouse.wheel(-300, 0) // scroll left // Scroll at a specific position await state.page.mouse.move(450, 320) await state.page.mouse.wheel(0, 500) // Scroll inside a container await state.page.locator('.scrollable-list').evaluate((el) => { el.scrollTop += 500 })
// By locator (preferred) await state.page.locator('#item').dragTo(state.page.locator('#target')) // By coordinates (for canvas, sliders, custom drag targets) await state.page.mouse.move(100, 200) await state.page.mouse.down() await state.page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag await state.page.mouse.up()
mouse.down → move → up pattern. If a widget expects a drawn stroke (paint tools, annotation overlays, range sliders, timeline scrubbers), always use held-mouse motion — not mouse.click():// Draw a stroke across a canvas or annotation layer await state.page.mouse.move(startX, startY) await state.page.mouse.down() await state.page.mouse.move(endX, endY, { steps: 15 }) // steps = smoother stroke await state.page.mouse.up() await state.page.waitForTimeout(500) // let the widget process the stroke
// Hold modifier while pressing another key await state.page.keyboard.down('Shift') await state.page.keyboard.press('ArrowDown') await state.page.keyboard.up('Shift') // Repeat a key for (let i = 0; i < 5; i++) await state.page.keyboard.press('ArrowDown')
await state.page.setViewportSize({ width: 1280, height: 720 })
await state.page.screenshot({ path: '/absolute/path/to/region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
chrome object exposes APIs for multi-identity automation (identities, proxies, sessions). See extension/src/ghost-browser-api.d.ts for full API reference. Only works in Ghost Browser — calls fail in regular Chrome.