1npx -y skills add remorses/playwriter
playwriter command is not found, install globally or use npx/bunx:1234npm install -g playwriter@latest # or use without installing: npx playwriter@latest session new bunx playwriter@latest session new
state object. Use sessions to:12playwriter 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.12345playwriter session list # ID State Keys # -------------- # 1 myPage, userData # 2 -
1playwriter session reset <sessionId>
playwriter serve with a traforo tunnel, and the remote machine connects through the tunnel URL.12345678# 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')"
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)123456789101112# 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')"
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:1234567891011{ "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.123456789# 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 }))"
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.123456# 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
1234567891011121314# 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.1playwriter -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.1234567891011121314151617# 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 })'
-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.123456789101112131415# 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:1playwriter -s 1 -f script.js
-e. All context variables (state, page, context, etc.) are available. -e and -f cannot be used together.12playwriter 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.1jq -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.1234567891011# 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:12345678# 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:1234567891011{ "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())page - a default page (may be shared with other agents). Prefer creating your own page and storing it in state (see "working with pages")context - browser context, access all pages via context.pages()require - load Node.js modules (e.g., const fs = require('node:fs')). ESM import is not available in the sandboxsetTimeout, setInterval, fetch, URL, Buffer, crypto, process, etc.__dirname, __filename, import.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.state.page (reuse about:blank or create one) and use state.page for all automation steps.browser.close() or context.close(). Only close pages you created or if user asksstate.page.removeAllListeners() at end of message to prevent leaksgetLatestLogs({ 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.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() 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.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 writing document.querySelector or getBoundingClientRect inside evaluate — stop and use snapshot() instead. Reserve page.evaluate() for actions that modify page state (e.g., localStorage.clear(), scroll manipulation) or extract non-DOM data (e.g., window.__CONFIG__)state.page.url() + snapshot() + getLatestLogs({ sinceLastCall: true }). Always print URL — pages can redirect unexpectedly.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.1234567// 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)
12345// 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.12345678// 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({ sinceLastCall: true }) after every action, getLatestLogs({ search }) for targeted debugging, state.page.url() for navigation, screenshots only for visual layout issues.123await 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:123456// 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. Always get a fresh snapshot before clicking, then immediately use locators from that output:12await snapshot({ page: state.page, showDiffSinceLastCall: true }) // Now use the NEW locators from this output
123// 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:123await 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:123456789# 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 )"
1await snapshot({ page: state.page, search: /expected text/i })
goto(), dynamic content may not be ready:12345await 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 })
1234state.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)
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:1234567891011await 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:1234// 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:1await state.page.getByRole('radio', { name: 'Node.js' }).click()
page.evaluate() to read class names and bounding boxes. This wastes massive context. Instead:snapshot() — it shows every interactive element and what to clickclick() didn't work:mouse.down, move with steps, mouse.up (see drag section)snapshot() to see what changed1await 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, 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.12345- 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:1234// 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:123const snap = await snapshot({ page: state.page }) const locator = refToLocator({ ref: 'e3' }) await state.page.locator(locator!).click()
1const snap = await snapshot({ page: state.page, search: /button|submit/i })
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):123456789// 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') })
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() - 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 easily12state.page.locator('tr').filter({ hasText: 'John' }).locator('button').click() state.page.locator('button').nth(2).click()
.first(), .last(), or .nth(n):123await 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.state and use state.page for all subsequent operations instead of the default page variable:123456// 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
1234if (!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')
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:1234const 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]
1context.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():12await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' }) await waitForPageLoad({ page: state.page, timeout: 5000 })
1234const 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:123const 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:1234567const 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 }) }
1234567891011// 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).12const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')]) await download.saveAs(`/absolute/path/${download.suggestedFilename()}`)
1234567// 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({ frame: frame2 })
12345state.page.on('dialog', async (dialog) => { console.log(dialog.message()) await dialog.accept() }) await state.page.click('button.trigger-alert')
snapshot() right after navigation and dismiss them before doing anything else:1234567891011// 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:12345678910111213141516// 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.on('console') listeners for debugging because they only see future events and can miss logs emitted during page startup or hydration.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.1234567await getLatestLogs({ page?, count?, search?, sinceLastCall? }) // After every action: get only new logs const newLogs = await getLatestLogs({ page: state.page, sinceLastCall: true }) // Search all logs (ignores cursor): const errors = await getLatestLogs({ 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 })
12345await 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-*).1234await 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
1234567# 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.12await waitForPageLoad({ page: state.page, timeout?, pollInterval?, minWait? }) // Returns: { success, readyState, pendingRequests, waitTimeMs, timedOut }
12const cdp = await getCDPSession({ page: state.page }) const metrics = await cdp.send('Page.getLayoutMetrics')
12const selector = await getLocatorStringForElement(state.page.locator('[id="submit-btn"]')) // => "getByRole('button', { name: 'Save' })"
12const 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.12const 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.1await inspectPinnedElement('https://example.com', 'globalThis.playwriterPinnedElem1')
https://playwriter.dev/resources/styles-api.md first with curl or webfetch tool.12345const 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.123456const 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.12345const 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.12345678910await 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.123456789101112131415161718await 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 }), recording.cancel({ page })
12await 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.12345678910// 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:12const el = await state.page.evaluateHandle(() => globalThis.playwriterPinnedElem1) await el.click()
scale: 'css' to avoid 2-4x larger images on high-DPI displays:1await state.page.screenshot({ path: '/absolute/path/to/shot.png', scale: 'css' })
1await 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):12345678const 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)
123const fs = require('node:fs') const content = fs.readFileSync('./data.txt', 'utf-8') await state.page.locator('textarea').fill(content)
state to analyze across calls:123456789101112state.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 {} } })
12console.log('Captured', state.responses.length, 'API calls') state.responses.forEach((r) => console.log(r.status, r.url.slice(0, 80)))
12const resp = state.responses.find((r) => r.url.includes('users')) console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
123456789const { 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');123456789101112131415// 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
12await state.page.locator('.tooltip-trigger').hover() // by locator (preferred) await state.page.mouse.move(450, 320) // by coordinates
1234567891011121314151617// 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 })
12345678// 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():123456// 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
1234567// 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')
1await state.page.setViewportSize({ width: 1280, height: 720 })
1await 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.