feat(executor): add state-aware page-close warnings

Warn only when a closed page is stored in session state (for example state.page), include affected state key names in warning text, and preserve automatic fallback to another open page.

Also standardize skill guidance/examples on state.page, add integration coverage for warning and non-warning close paths, and simplify warning-scope bookkeeping by removing execution-id map state in favor of scope object tracking.
This commit is contained in:
Tommy D. Rossi
2026-02-20 14:40:05 +01:00
parent fdb19c5736
commit 3d6fcb845b
5 changed files with 437 additions and 177 deletions
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## 0.0.66
### Internal
- **Simplify warning scope tracking**: Replaced warning-scope map + execution ID counter with a direct set of scope objects, keeping the same concurrent warning behavior with less executor state.
## 0.0.65
### Improvements
- **State-aware page-close warnings**: Executor now emits page-close warnings only when the closed page is referenced in session state (for example `state.page`), and warning text includes the exact state key(s) that must be reassigned.
- **Safer active page fallback messaging**: When the active page closes and a replacement tab is available, warning text now includes both fallback index/URL and the affected state key(s).
### Docs
- **Standardize examples on `state.page`**: Updated skill examples and guidance to consistently initialize and use `state.page` at task start, reducing cross-agent tab confusion.
## 0.0.64
### Improvements
- **Warn when active page closes**: Executor now listens for page close events and emits explicit `[WARNING]` messages when the current page is closed, including the closed URL and automatic fallback behavior.
- **Automatic page fallback after close**: When possible, executor switches `page` to another open tab and reports which page index/URL it selected so agents understand context changes immediately.
- **Concurrency-safe warning delivery**: Warning buffering now tracks warning scopes per execute call so concurrent executions do not lose page-close or popup warnings.
### Tests
- **Add active-page-close integration test**: New extension connection test verifies warning emission and successful continuation on a replacement page after closing the active page.
## 0.0.63
### Security
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "playwriter",
"description": "",
"version": "0.0.63",
"version": "0.0.66",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+131 -13
View File
@@ -173,6 +173,15 @@ export interface ExecuteResult {
isError: boolean
}
interface WarningEvent {
id: number
message: string
}
interface WarningScope {
cursor: number
}
export interface ExecutorLogger {
log(...args: any[]): void
error(...args: any[]): void
@@ -219,7 +228,12 @@ export class PlaywrightExecutor {
private browserLogs: Map<string, string[]> = new Map()
private lastSnapshots: WeakMap<Page, string> = new WeakMap()
private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
private popupWarnings: string[] = []
private warningEvents: WarningEvent[] = []
private nextWarningEventId = 0
private lastDeliveredWarningEventId = 0
private activeWarningScopes = new Set<WarningScope>()
private pagesWithListeners = new WeakSet<Page>()
private suppressPageCloseWarnings = false
private scopedFs: ScopedFS
private sandboxedRequire: NodeRequire
@@ -295,6 +309,49 @@ export class PlaywrightExecutor {
this.context = null
}
private enqueueWarning(message: string) {
this.nextWarningEventId += 1
this.warningEvents.push({ id: this.nextWarningEventId, message })
}
private beginWarningScope(): WarningScope {
const scope: WarningScope = {
cursor: this.nextWarningEventId,
}
this.activeWarningScopes.add(scope)
return scope
}
private flushWarningsForScope(scope: WarningScope): string {
const relevantWarnings = this.warningEvents.filter((warning) => {
return warning.id > scope.cursor
})
const latestWarningId = relevantWarnings.at(-1)?.id
if (latestWarningId && latestWarningId > this.lastDeliveredWarningEventId) {
this.lastDeliveredWarningEventId = latestWarningId
}
this.activeWarningScopes.delete(scope)
this.pruneDeliveredWarnings()
if (relevantWarnings.length === 0) {
return ''
}
return `${relevantWarnings.map((warning) => `[WARNING] ${warning.message}`).join('\n')}\n`
}
private pruneDeliveredWarnings() {
const activeCursors = [...this.activeWarningScopes].map((scope) => {
return scope.cursor
})
const minActiveCursor = activeCursors.length > 0 ? Math.min(...activeCursors) : this.lastDeliveredWarningEventId
const pruneBeforeOrAt = Math.min(this.lastDeliveredWarningEventId, minActiveCursor)
this.warningEvents = this.warningEvents.filter((warning) => {
return warning.id > pruneBeforeOrAt
})
}
private warnIfExtensionOutdated(playwriterVersion: string | null) {
if (this.hasWarnedExtensionOutdated) {
return
@@ -307,10 +364,74 @@ export class PlaywrightExecutor {
}
private setupPageListeners(page: Page) {
if (this.pagesWithListeners.has(page)) {
return
}
this.pagesWithListeners.add(page)
this.setupPageCloseDetection(page)
this.setupPageConsoleListener(page)
this.setupPopupDetection(page)
}
private setupPageCloseDetection(page: Page) {
page.on('close', () => {
const stateKeysForClosedPage = Object.entries(this.userState)
.filter(([, value]) => {
return value === page
})
.map(([key]) => key)
const wasCurrentPage = this.page === page
let replacementPageInfo: { index: string; url: string } | null = null
if (wasCurrentPage) {
this.page = null
const context = this.context || page.context()
const openPages = context.pages().filter((candidate) => {
return !candidate.isClosed()
})
if (openPages.length > 0) {
const replacementPage = openPages[0]
this.page = replacementPage
const replacementIndex = context.pages().indexOf(replacementPage)
replacementPageInfo = {
index: replacementIndex >= 0 ? String(replacementIndex) : 'unknown',
url: replacementPage.url() || 'unknown',
}
}
}
if (!this.isConnected || this.suppressPageCloseWarnings || stateKeysForClosedPage.length === 0) {
return
}
const stateKeyLabel = stateKeysForClosedPage.map((key) => `state.${key}`).join(', ')
const closedUrl = page.url() || 'unknown'
if (!wasCurrentPage) {
this.enqueueWarning(
`Page closed (url: ${closedUrl}) for ${stateKeyLabel}. ` +
`Assign a new open page to ${stateKeyLabel} before reusing it.`,
)
return
}
if (replacementPageInfo) {
this.enqueueWarning(
`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
`Switched active page to index ${replacementPageInfo.index} (url: ${replacementPageInfo.url}). ` +
`Reassign ${stateKeyLabel} before using it again.`,
)
return
}
this.enqueueWarning(
`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
`No open pages remain. Open a tab with Playwriter enabled, then reassign ${stateKeyLabel}.`,
)
})
}
private setupPopupDetection(page: Page) {
// Listen for popup events (window.open, target=_blank) on each page.
// This is more reliable than checking page.opener() on context 'page' event,
@@ -321,7 +442,7 @@ export class PlaywrightExecutor {
const rawIndex = pages.indexOf(popup)
const pageIndex = rawIndex >= 0 ? String(rawIndex) : 'unknown'
const url = popup.url()
this.popupWarnings.push(
this.enqueueWarning(
`Popup window detected (page index ${pageIndex}, url: ${url}). ` +
`Popup windows cannot be controlled by playwriter. ` +
`Repeat the interaction in a way that does not open a popup, or navigate to the URL directly in a new tab.`,
@@ -479,10 +600,13 @@ export class PlaywrightExecutor {
async reset(): Promise<{ page: Page; context: BrowserContext }> {
if (this.browser) {
this.suppressPageCloseWarnings = true
try {
await this.browser.close()
} catch (e) {
this.logger.error('Error closing browser:', e)
} finally {
this.suppressPageCloseWarnings = false
}
}
@@ -528,6 +652,7 @@ export class PlaywrightExecutor {
async execute(code: string, timeout = 10000): Promise<ExecuteResult> {
const consoleLogs: Array<{ method: string; args: any[] }> = []
const warningScope = this.beginWarningScope()
const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => {
if (logs.length === 0) {
@@ -885,11 +1010,7 @@ export class PlaywrightExecutor {
}
}
// Drain any popup warnings that fired during execution
if (this.popupWarnings.length > 0) {
responseText += this.popupWarnings.map((w) => `[WARNING] ${w}`).join('\n') + '\n'
this.popupWarnings = []
}
responseText += this.flushWarningsForScope(warningScope)
if (!responseText.trim()) {
responseText = 'Code executed successfully (no output)'
@@ -919,16 +1040,13 @@ export class PlaywrightExecutor {
this.logger.error('Error in execute:', errorStack)
const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
const popupText = this.popupWarnings.length > 0
? this.popupWarnings.map((w) => `[WARNING] ${w}`).join('\n') + '\n'
: ''
this.popupWarnings = []
const warningText = this.flushWarningsForScope(warningScope)
const resetHint = isTimeoutError
? ''
: '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]'
return {
text: `${logsText}${popupText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`,
text: `${logsText}${warningText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`,
images: [],
isError: true,
}
@@ -962,7 +1080,7 @@ export class PlaywrightExecutor {
}
const page = await context.newPage()
this.setupPageConsoleListener(page)
this.setupPageListeners(page)
const pageUrl = page.url()
if (pageUrl === 'about:blank') {
return page
+113
View File
@@ -303,6 +303,119 @@ describe('Extension Connection Tests', () => {
await pageB.close()
})
it('should warn and switch page when the active page closes', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const pageA = await browserContext.newPage()
await pageA.goto('https://example.com/close-warning-a')
await pageA.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
const pageB = await browserContext.newPage()
await pageB.goto('https://example.com/close-warning-b')
await pageB.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
const closeResult = await client.callTool({
name: 'execute',
arguments: {
code: js`
state.page = page;
const closedUrl = state.page.url();
await state.page.close();
return { closedUrl, remainingPages: context.pages().length };
`,
},
})
const closeOutput = (closeResult as any).content[0].text
expect(closeOutput).toContain('[WARNING] The current page in state.page was closed')
expect(closeOutput).toContain('Switched active page to index')
expect((closeResult as any).isError).not.toBe(true)
const nextResult = await client.callTool({
name: 'execute',
arguments: {
code: js`
return { pageUrl: page.url(), pagesCount: context.pages().length };
`,
},
})
const nextOutput = (nextResult as any).content[0].text
expect(nextOutput).toContain('pagesCount')
expect(nextOutput).not.toContain('No Playwright pages are available')
expect(nextOutput).not.toContain('[WARNING] The current page was closed')
expect((nextResult as any).isError).not.toBe(true)
if (!pageA.isClosed()) {
await pageA.close()
}
if (!pageB.isClosed()) {
await pageB.close()
}
})
it('should switch page without warning when closed page is not stored in state', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const pageA = await browserContext.newPage()
await pageA.goto('https://example.com/close-no-state-warning-a')
await pageA.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
const pageB = await browserContext.newPage()
await pageB.goto('https://example.com/close-no-state-warning-b')
await pageB.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
const closeResult = await client.callTool({
name: 'execute',
arguments: {
code: js`
const closedUrl = page.url();
await page.close();
return { closedUrl, remainingPages: context.pages().length };
`,
},
})
const closeOutput = (closeResult as any).content[0].text
expect(closeOutput).not.toContain('[WARNING] The current page in state.page was closed')
expect(closeOutput).not.toContain('Switched active page to index')
expect((closeResult as any).isError).not.toBe(true)
const nextResult = await client.callTool({
name: 'execute',
arguments: {
code: js`
return { pageUrl: page.url(), pagesCount: context.pages().length };
`,
},
})
const nextOutput = (nextResult as any).content[0].text
expect(nextOutput).toContain('pagesCount')
expect((nextResult as any).isError).not.toBe(true)
if (!pageA.isClosed()) {
await pageA.close()
}
if (!pageB.isClosed()) {
await pageB.close()
}
})
it('should show correct url when enabling extension after navigation', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
+163 -163
View File
@@ -60,19 +60,19 @@ Default timeout is 10 seconds. you can increase the timeout with `--timeout <ms>
playwriter -s 1 -e "state.page = await context.newPage(); await state.page.goto('https://example.com')"
# Click a button
playwriter -s 1 -e "await page.click('button')"
playwriter -s 1 -e "await state.page.click('button')"
# Get page title
playwriter -s 1 -e "await page.title()"
playwriter -s 1 -e "await state.page.title()"
# Take a screenshot
playwriter -s 1 -e "await page.screenshot({ path: 'screenshot.png', scale: 'css' })"
playwriter -s 1 -e "await state.page.screenshot({ path: 'screenshot.png', scale: 'css' })"
# Get accessibility snapshot
playwriter -s 1 -e "await snapshot({ page })"
playwriter -s 1 -e "await snapshot({ page: state.page })"
# Get accessibility snapshot for a specific iframe
const frame = await page.locator('iframe').contentFrame()
const frame = await state.page.locator('iframe').contentFrame()
await snapshot({ frame })
```
@@ -81,14 +81,14 @@ await snapshot({ frame })
```bash
# Using $'...' syntax for multiline code
playwriter -s 1 -e $'
const title = await page.title();
const url = page.url();
const title = await state.page.title();
const url = state.page.url();
console.log({ title, url });
'
# Or use heredoc
playwriter -s 1 -e "$(cat <<'EOF'
const links = await page.$$eval('a', els => els.map(e => e.href));
const links = await state.page.$$eval('a', els => els.map(e => e.href));
console.log('Found', links.length, 'links');
EOF
)"
@@ -152,7 +152,7 @@ You can collaborate with the user - they can help with captchas, difficult eleme
## 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.myPage = await context.newPage()`)
- `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 sandbox
@@ -162,15 +162,15 @@ You can collaborate with the user - they can help with captchas, difficult eleme
## rules
- **Create your own page**: see "working with pages" — create and store your own page in `state`, don't use the default `page` for automation unless the user asks you to use a specific tab. Reusing an existing `about:blank` tab is fine. Examples below use bare `page` for brevity, but in real automation always use `state.myPage`
- **Initialize state.page first**: see "working with pages" — at the start of a task, assign `state.page` (reuse `about:blank` or create one) and use `state.page` for 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()` or `context.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
- **Check state after actions**: always verify page state after clicking/submitting (see next section)
- **Clean up listeners**: call `page.removeAllListeners()` at end of message to prevent leaks
- **CDP sessions**: use `getCDPSession({ page })` not `page.context().newCDPSession()` - NEVER use `newCDPSession()` method, it doesn't work through playwriter relay
- **Wait for load**: use `page.waitForLoadState('domcontentloaded')` not `page.waitForEvent('load')` - waitForEvent times out if already loaded
- **Minimize timeouts**: prefer proper waits (`waitForSelector`, `waitForPageLoad`) over `page.waitForTimeout()`. Short timeouts (1-2s) are acceptable for non-deterministic events like popups, animations, or tab opens where no specific selector is available
- **Clean up listeners**: call `state.page.removeAllListeners()` at end of message to prevent leaks
- **CDP sessions**: use `getCDPSession({ page: state.page })` not `state.page.context().newCDPSession()` - NEVER use `newCDPSession()` method, it doesn't work through playwriter relay
- **Wait for load**: use `state.page.waitForLoadState('domcontentloaded')` not `state.page.waitForEvent('load')` - waitForEvent times out if already loaded
- **Minimize timeouts**: prefer proper waits (`waitForSelector`, `waitForPageLoad`) over `state.page.waitForTimeout()`. Short timeouts (1-2s) are acceptable for non-deterministic events like popups, animations, or tab opens where no specific selector is available
## interaction feedback loop
@@ -179,7 +179,7 @@ Every browser interaction should follow a **observe → act → observe** loop.
**Core loop:**
1. **Open page** — get or create your page and navigate to the target URL
2. **Observe** — print `page.url()` and take an accessibility snapshot. Always print the URL so you know where you are — pages can redirect, and actions can trigger unexpected navigation.
2. **Observe** — print `state.page.url()` and take an accessibility snapshot. Always print the URL so you know where you are — pages can redirect, and actions can trigger unexpected navigation.
3. **Check** — read the snapshot and URL. If the page isn't ready (still loading, expected content missing, wrong URL), **wait and observe again** — don't act on stale or incomplete state. Only proceed when you can identify the element to interact with.
4. **Act** — perform one action (click, type, submit)
5. **Observe again** — print URL + snapshot to verify the action's effect. If the action didn't take effect (nothing changed, page still loading), wait and observe again before proceeding.
@@ -213,31 +213,31 @@ Each step is a separate execute call. Notice how every action is followed by a s
```js
// 1. Open page and observe — always print URL first
state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
await state.myPage.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' });
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage }).then(console.log)
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
await state.page.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' });
console.log('URL:', state.page.url()); await snapshot({ page: state.page }).then(console.log)
```
```js
// 2. Act: open command palette → observe result
await state.myPage.keyboard.press('Meta+k');
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /dialog|Search/ }).then(console.log)
await state.page.keyboard.press('Meta+k');
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /dialog|Search/ }).then(console.log)
// If dialog didn't appear, observe again before retrying
```
```js
// 3. Act: type search query → observe result
await state.myPage.keyboard.type('MCP');
console.log('URL:', state.myPage.url()); await snapshot({ page: state.myPage, search: /MCP/ }).then(console.log)
await state.page.keyboard.type('MCP');
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /MCP/ }).then(console.log)
```
```js
// 4. Act: press Enter → observe plugin loaded
await state.myPage.keyboard.press('Enter');
await state.myPage.waitForTimeout(1000);
console.log('URL:', state.myPage.url());
const frame = state.myPage.frames().find(f => f.url().includes('plugins.framercdn.com'));
await snapshot({ page: state.myPage, frame: frame || undefined }).then(console.log)
await state.page.keyboard.press('Enter');
await state.page.waitForTimeout(1000);
console.log('URL:', state.page.url());
const frame = state.page.frames().find(f => f.url().includes('plugins.framercdn.com'));
await snapshot({ page: state.page, frame: frame || undefined }).then(console.log)
// If frame not found, wait and observe again — plugin may still be loading
```
@@ -247,15 +247,15 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
- **Console logs** — check for errors or app state after an action:
```js
await getLatestLogs({ page, search: /error|fail/i, count: 20 })
await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 })
```
- **Network requests** — verify API calls were made after a form submit or button click:
```js
page.on('response', async res => { if (res.url().includes('/api/')) { console.log(res.status(), res.url()); } });
state.page.on('response', async res => { if (res.url().includes('/api/')) { console.log(res.status(), res.url()); } });
```
- **URL changes** — confirm navigation happened:
```js
console.log(page.url())
console.log(state.page.url())
```
- **Screenshots** — only for visual layout issues (see "choosing between snapshot methods" below).
@@ -264,8 +264,8 @@ Snapshots are the primary feedback mechanism, but some actions have side effects
**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:
```js
await page.keyboard.type('my text');
await snapshot({ page, search: /my text/ })
await state.page.keyboard.type('my text');
await snapshot({ page: state.page, search: /my text/ })
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
```
@@ -273,21 +273,21 @@ await snapshot({ page, search: /my text/ })
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
```js
// Reliable: use file input
const fileInput = page.locator('input[type="file"]').first();
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 page.keyboard.press('Meta+v'); // always verify with screenshot!
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:
```js
// BAD: using ref from minutes ago
await page.locator('[id="old-id"]').click(); // element may have changed
await state.page.locator('[id="old-id"]').click(); // element may have changed
// GOOD: get fresh snapshot, then immediately use locators from it
await snapshot({ page, showDiffSinceLastCall: true })
await snapshot({ page: state.page, showDiffSinceLastCall: true })
// Now use the NEW locators from this output
```
@@ -295,7 +295,7 @@ await snapshot({ page, showDiffSinceLastCall: true })
Before destructive actions (delete, submit), verify you're targeting the right thing:
```js
// Before deleting, verify it's the right item
await screenshotWithAccessibilityLabels({ page });
await screenshotWithAccessibilityLabels({ page: state.page });
// READ the screenshot to confirm, THEN proceed with delete
```
@@ -303,26 +303,26 @@ await screenshotWithAccessibilityLabels({ page });
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`:
```js
// BAD: newlines in string don't create line breaks
await page.keyboard.type('Line 1\nLine 2'); // becomes "Line 1Line 2"
await state.page.keyboard.type('Line 1\nLine 2'); // becomes "Line 1Line 2"
// GOOD: use Enter key for line breaks
await page.keyboard.type('Line 1');
await page.keyboard.press('Enter');
await page.keyboard.type('Line 2');
await state.page.keyboard.type('Line 1');
await state.page.keyboard.press('Enter');
await state.page.keyboard.type('Line 2');
```
**6. Quote escaping in $'...' syntax**
When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
```bash
# BAD: nested double quotes break $'...'
playwriter -s 1 -e $'await page.locator("[id=\"_r_a_\"]").click()'
playwriter -s 1 -e $'await state.page.locator("[id=\"_r_a_\"]").click()'
# GOOD: use single quotes inside, or template strings
playwriter -s 1 -e $'await page.locator(\'[id="_r_a_"]\').click()'
playwriter -s 1 -e $'await state.page.locator(\'[id="_r_a_"]\').click()'
# GOOD: use heredoc for complex quoting
playwriter -s 1 -e "$(cat <<'EOF'
await page.locator('[id="_r_a_"]').click()
await state.page.locator('[id="_r_a_"]').click()
EOF
)"
```
@@ -331,39 +331,39 @@ EOF
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
```js
// BAD: screenshot to check if text appeared (wastes tokens on image analysis)
await page.screenshot({ path: 'check.png', scale: 'css' });
await state.page.screenshot({ path: 'check.png', scale: 'css' });
// GOOD: snapshot is text — fast, cheap, searchable
await snapshot({ page, search: /expected text/i })
await snapshot({ page: state.page, search: /expected text/i })
// GOOD: evaluate DOM directly for content checks
const text = await page.evaluate(() => document.querySelector('.message')?.textContent);
const text = await state.page.evaluate(() => document.querySelector('.message')?.textContent);
```
**8. Assuming page content loaded**
Even after `goto()`, dynamic content may not be ready:
```js
await page.goto('https://example.com');
await state.page.goto('https://example.com');
// Content may still be loading via JavaScript!
await page.waitForSelector('article', { timeout: 10000 });
await state.page.waitForSelector('article', { timeout: 10000 });
// Or use waitForPageLoad utility
await waitForPageLoad({ page, timeout: 5000 });
await waitForPageLoad({ page: state.page, timeout: 5000 });
```
**9. Login buttons that open popups**
Playwriter extension cannot control popup windows. If a login button opens a popup (common with OAuth/SSO), use cmd+click to open in a new tab instead:
```js
// BAD: popup window is not controllable by playwriter
await page.click('button:has-text("Login with Google")');
await state.page.click('button:has-text("Login with Google")');
// GOOD: cmd+click opens in new tab that playwriter can control
await page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] });
await page.waitForTimeout(2000);
await state.page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] });
await state.page.waitForTimeout(2000);
// Verify new tab opened - last page should be the login page
const pages = context.pages();
const loginPage = pages[pages.length - 1];
if (loginPage.url() === page.url()) {
if (loginPage.url() === state.page.url()) {
throw new Error('Cmd+click did not open new tab - login may have opened as popup');
}
@@ -379,18 +379,18 @@ After any action (click, submit, navigate), verify what happened. Always print U
```js
// Always print URL first, then snapshot
console.log('URL:', page.url()); await snapshot({ page }).then(console.log)
console.log('URL:', state.page.url()); await snapshot({ page: state.page }).then(console.log)
// Filter for specific content when snapshot is large
console.log('URL:', page.url()); await snapshot({ page, search: /dialog|button|error/i }).then(console.log)
console.log('URL:', state.page.url()); await snapshot({ page: state.page, search: /dialog|button|error/i }).then(console.log)
```
If nothing changed, try `await waitForPageLoad({ page, timeout: 3000 })` or you may have clicked the wrong element.
If nothing changed, try `await waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.
## accessibility snapshots
```js
await snapshot({ page, search?, showDiffSinceLastCall? })
await snapshot({ page: state.page, search?, showDiffSinceLastCall? })
```
`accessibilitySnapshot` is still available as an alias for backward compatibility.
@@ -410,34 +410,34 @@ Example output:
- link "Blog" role=link[name="Blog"]
```
Each interactive line ends with a Playwright locator you can pass to `page.locator()`.
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.
If a screenshot shows ref labels like `e3`, resolve them using the last snapshot:
```js
const snap = await snapshot({ page })
const snap = await snapshot({ page: state.page })
const locator = refToLocator({ ref: 'e3' })
await page.locator(locator!).click()
await state.page.locator(locator!).click()
```
```js
await page.locator('[id="nav-home"]').click()
await page.locator('[data-testid="docs-link"]').click()
await page.locator('role=link[name="Blog"]').click()
await state.page.locator('[id="nav-home"]').click()
await state.page.locator('[data-testid="docs-link"]').click()
await state.page.locator('role=link[name="Blog"]').click()
```
Search for specific elements:
```js
const snap = await snapshot({ page, search: /button|submit/i })
const snap = await snapshot({ page: state.page, search: /button|submit/i })
```
**Filtering large snapshots in JS** — when the built-in `search` isn't enough (e.g., you need multiple patterns or custom logic), filter the snapshot string directly:
```js
const snap = await snapshot({ page, showDiffSinceLastCall: false });
const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false });
const relevant = snap.split('\n').filter(l =>
l.includes('dialog') || l.includes('error') || l.includes('button')
).join('\n');
@@ -480,16 +480,16 @@ Both `snapshot` and `screenshotWithAccessibilityLabels` use the same ref system,
Combine locators for precision:
```js
page.locator('tr').filter({ hasText: 'John' }).locator('button').click()
page.locator('button').nth(2).click()
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)`:
```js
await page.locator('button').first().click() // first match
await page.locator('.item').last().click() // last match
await page.locator('li').nth(3).click() // 4th item (0-indexed)
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
@@ -498,15 +498,15 @@ await page.locator('li').nth(3).click() // 4th item (0-indexed)
**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.myPage` for all subsequent operations instead of the default `page` variable:
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:
```js
// 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.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
await state.myPage.goto('https://example.com');
// Use state.myPage for ALL subsequent operations
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:**
@@ -514,10 +514,10 @@ await state.myPage.goto('https://example.com');
The user may close your page by accident (e.g., closing a tab in Chrome). Always check before using it and recreate if needed:
```js
if (!state.myPage || state.myPage.isClosed()) {
state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
if (!state.page || state.page.isClosed()) {
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
}
await state.myPage.goto('https://example.com');
await state.page.goto('https://example.com');
```
**Use an existing page only when the user asks:**
@@ -542,8 +542,8 @@ context.pages().map(p => p.url())
**Use `domcontentloaded`** for `page.goto()`:
```js
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await waitForPageLoad({ page, timeout: 5000 });
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await waitForPageLoad({ page: state.page, timeout: 5000 });
```
## common patterns
@@ -554,8 +554,8 @@ await waitForPageLoad({ page, timeout: 5000 });
// BAD: curl/external requests don't have session cookies
// curl -H "Cookie: ..." often fails due to missing cookies or CSRF
// GOOD: fetch inside page.evaluate uses browser's full session
const data = await page.evaluate(async (url) => {
// GOOD: fetch inside state.page.evaluate uses browser's full session
const data = await state.page.evaluate(async (url) => {
const resp = await fetch(url);
return await resp.text();
}, 'https://example.com/protected/resource');
@@ -565,7 +565,7 @@ const data = await page.evaluate(async (url) => {
```js
// Fetch protected data and trigger download to user's Downloads folder
await page.evaluate(async (url) => {
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' });
@@ -588,8 +588,8 @@ Instead, use simpler alternatives (single download via `a.click()`, store data i
**Links that open new tabs** - playwriter cannot control popup windows opened via `window.open`. Use cmd+click to open in a controllable new tab instead (see mistake #9 above for a full example):
```js
await page.locator('a[target=_blank]').click({ modifiers: ['Meta'] });
await page.waitForTimeout(1000);
await state.page.locator('a[target=_blank]').click({ modifiers: ['Meta'] });
await state.page.waitForTimeout(1000);
const pages = context.pages();
const newTab = pages[pages.length - 1];
console.log('New tab URL:', newTab.url());
@@ -598,7 +598,7 @@ console.log('New tab URL:', newTab.url());
**Downloads** - capture and save:
```js
const [download] = await Promise.all([page.waitForEvent('download'), page.click('button.download')]);
const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')]);
await download.saveAs(`/tmp/${download.suggestedFilename()}`);
```
@@ -606,19 +606,19 @@ await download.saveAs(`/tmp/${download.suggestedFilename()}`);
```js
// frameLocator: for chaining locator operations (click, fill, etc.)
const frame = page.frameLocator('#my-iframe');
const frame = state.page.frameLocator('#my-iframe');
await frame.locator('button').click();
// contentFrame: returns a Frame object, needed for snapshot({ frame })
const frame2 = await page.locator('iframe').contentFrame();
const frame2 = await state.page.locator('iframe').contentFrame();
await snapshot({ frame: frame2 })
```
**Dialogs** - handle alerts/confirms/prompts:
```js
page.on('dialog', async dialog => { console.log(dialog.message()); await dialog.accept(); });
await page.click('button.trigger-alert');
state.page.on('dialog', async dialog => { console.log(dialog.message()); await dialog.accept(); });
await state.page.click('button.trigger-alert');
```
## utility functions
@@ -629,19 +629,19 @@ await page.click('button.trigger-alert');
await getLatestLogs({ page?, count?, search? })
// Examples:
const errors = await getLatestLogs({ search: /error/i, count: 50 })
const pageLogs = await getLatestLogs({ page })
const pageLogs = await getLatestLogs({ page: state.page })
```
For custom log collection across runs, store in state: `state.logs = []; page.on('console', m => state.logs.push(m.text()))`
For custom log collection across runs, store in state: `state.logs = []; state.page.on('console', m => state.logs.push(m.text()))`
**getCleanHTML** - get cleaned HTML from a locator or page, with search and diffing:
```js
await getCleanHTML({ locator, search?, showDiffSinceLastCall?, includeStyles? })
// Examples:
const html = await getCleanHTML({ locator: page.locator('body') })
const html = await getCleanHTML({ locator: page, search: /button/i })
const fullHtml = await getCleanHTML({ locator: page, showDiffSinceLastCall: false }) // disable diff
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
```
**Parameters:**
@@ -665,10 +665,10 @@ The function cleans HTML for compact, readable output:
**getPageMarkdown** - extract main page content as plain text using Mozilla Readability (same algorithm as Firefox Reader View). Strips navigation, ads, sidebars, and other clutter. Returns formatted text with title, author, and content:
```js
await getPageMarkdown({ page, search?, showDiffSinceLastCall? })
await getPageMarkdown({ page: state.page, search?, showDiffSinceLastCall? })
// Examples:
const content = await getPageMarkdown({ page, showDiffSinceLastCall: false }) // full article
const matches = await getPageMarkdown({ page, search: /API/i }) // search within content
const content = await getPageMarkdown({ page: state.page, showDiffSinceLastCall: false }) // full article
const matches = await getPageMarkdown({ page: state.page, search: /API/i }) // search within content
```
**Output format:**
@@ -695,42 +695,42 @@ The main article content as plain text, with paragraphs preserved...
**waitForPageLoad** - smart load detection that ignores analytics/ads:
```js
await waitForPageLoad({ page, timeout?, pollInterval?, minWait? })
await waitForPageLoad({ page: state.page, timeout?, pollInterval?, minWait? })
// Returns: { success, readyState, pendingRequests, waitTimeMs, timedOut }
```
**getCDPSession** - send raw CDP commands:
```js
const cdp = await getCDPSession({ page });
const cdp = await getCDPSession({ page: state.page });
const metrics = await cdp.send('Page.getLayoutMetrics');
```
**getLocatorStringForElement** - get stable Playwright selector from an element:
```js
const selector = await getLocatorStringForElement(page.locator('[id="submit-btn"]'));
const selector = await getLocatorStringForElement(state.page.locator('[id="submit-btn"]'));
// => "getByRole('button', { name: 'Save' })"
```
**getReactSource** - get React component source location (dev mode only):
```js
const source = await getReactSource({ locator: page.locator('[data-testid="submit-btn"]') });
const source = await getReactSource({ locator: state.page.locator('[data-testid="submit-btn"]') });
// => { fileName, lineNumber, columnNumber, componentName }
```
**getStylesForLocator** - inspect CSS styles applied to an element, like browser DevTools "Styles" panel. Useful for debugging styling issues, finding where a CSS property is defined (file:line), and checking inherited styles. Returns selector, source location, and declarations for each matching rule. ALWAYS fetch `https://playwriter.dev/resources/styles-api.md` first with curl or webfetch tool.
```js
const styles = await getStylesForLocator({ locator: page.locator('.btn'), cdp: await getCDPSession({ page }) });
const styles = await getStylesForLocator({ locator: state.page.locator('.btn'), cdp: await getCDPSession({ page: state.page }) });
console.log(formatStylesAsText(styles));
```
**createDebugger** - set breakpoints, step through code, inspect variables at runtime. Useful for debugging issues that only reproduce in browser, understanding code flow, and inspecting state at specific points. Can pause on exceptions, evaluate expressions in scope, and blackbox framework code. ALWAYS fetch `https://playwriter.dev/resources/debugger-api.md` first.
```js
const cdp = await getCDPSession({ page }); const dbg = createDebugger({ cdp }); await dbg.enable();
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()
@@ -739,7 +739,7 @@ await dbg.setBreakpoint({ file: scripts[0].url, line: 42 });
**createEditor** - view and live-edit page scripts and CSS at runtime. Edits are in-memory (persist until reload). Useful for testing quick fixes, searching page scripts with grep, and toggling debug flags. ALWAYS read `https://playwriter.dev/resources/editor-api.md` first.
```js
const cdp = await getCDPSession({ page }); const editor = createEditor({ cdp }); await editor.enable();
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' });
```
@@ -749,15 +749,15 @@ await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString:
Prefer this for pages with grids, image galleries, maps, or complex visual layouts where spatial position matters. For simple text-heavy pages, `snapshot` with search is faster and uses fewer tokens.
```js
await screenshotWithAccessibilityLabels({ page });
await screenshotWithAccessibilityLabels({ page: state.page });
// Image and accessibility snapshot are automatically included in response
// Use refs from snapshot to interact with elements
await page.locator('[id="submit-btn"]').click();
await state.page.locator('[id="submit-btn"]').click();
// Can take multiple screenshots in one execution
await screenshotWithAccessibilityLabels({ page });
await page.click('button');
await screenshotWithAccessibilityLabels({ page });
await screenshotWithAccessibilityLabels({ page: state.page });
await state.page.click('button');
await screenshotWithAccessibilityLabels({ page: state.page });
// Both images are included in the response
```
@@ -770,7 +770,7 @@ Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkbo
```js
// Start recording - outputPath must be specified upfront
await startRecording({
page,
page: state.page,
outputPath: './recording.mp4',
frameRate: 30, // default: 30
audio: false, // default: false (tab audio)
@@ -778,22 +778,22 @@ await startRecording({
});
// Navigate around - recording continues!
await page.click('a');
await page.waitForLoadState('domcontentloaded');
await page.goBack();
await state.page.click('a');
await state.page.waitForLoadState('domcontentloaded');
await state.page.goBack();
// Stop and get result
const { path, duration, size } = await stopRecording({ page });
const { path, duration, size } = await stopRecording({ page: state.page });
console.log(`Saved ${size} bytes, duration: ${duration}ms`);
```
Additional recording utilities:
```js
// Check if recording is active
const { isRecording, startedAt } = await isRecording({ page });
const { isRecording, startedAt } = await isRecording({ page: state.page });
// Cancel recording without saving
await cancelRecording({ page });
await cancelRecording({ page: state.page });
```
**Key difference from getDisplayMedia**: This approach uses `chrome.tabCapture` which runs in the extension context, not the page. The recording persists across navigations because the extension holds the `MediaRecorder`, not the page's JavaScript context.
@@ -803,7 +803,7 @@ await cancelRecording({ page });
Users can right-click → "Copy Playwriter Element Reference" to store elements in `globalThis.playwriterPinnedElem1` (increments for each pin). The reference is copied to clipboard:
```js
const el = await page.evaluateHandle(() => globalThis.playwriterPinnedElem1);
const el = await state.page.evaluateHandle(() => globalThis.playwriterPinnedElem1);
await el.click();
```
@@ -812,7 +812,7 @@ await el.click();
Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
```js
await page.screenshot({ path: 'shot.png', scale: 'css' });
await state.page.screenshot({ path: 'shot.png', scale: 'css' });
```
If you want to read back the image file into context make sure to resize it first, scaling down the image to make sure max size is 1500px. for example with `sips --resampleHeightWidthMax 1500 input.png --out output.png` on macOS.
@@ -822,10 +822,10 @@ If you want to read back the image file into context make sure to resize it firs
Code inside `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):
```js
const title = await page.evaluate(() => document.title);
const title = await state.page.evaluate(() => document.title);
console.log('Title:', title);
const info = await page.evaluate(() => ({
const info = await state.page.evaluate(() => ({
url: location.href,
buttons: document.querySelectorAll('button').length,
}));
@@ -837,7 +837,7 @@ console.log(info);
Fill inputs with file content:
```js
const fs = require('node:fs'); const content = fs.readFileSync('./data.txt', 'utf-8'); await page.locator('textarea').fill(content);
const fs = require('node:fs'); const content = fs.readFileSync('./data.txt', 'utf-8'); await state.page.locator('textarea').fill(content);
```
## network interception
@@ -846,8 +846,8 @@ For scraping or reverse-engineering APIs, intercept network requests instead of
```js
state.requests = []; state.responses = [];
page.on('request', req => { if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() }); });
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 {} } });
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 {} } });
```
Then trigger actions (scroll, click, navigate) and analyze captured data:
@@ -868,11 +868,11 @@ Replay API directly (useful for pagination):
```js
const { url, headers } = state.requests.find(r => r.url.includes('feed'));
const data = await page.evaluate(async ({ url, headers }) => { const res = await fetch(url, { headers }); return res.json(); }, { url, headers });
const data = await state.page.evaluate(async ({ url, headers }) => { const res = await fetch(url, { headers }); return res.json(); }, { url, headers });
console.log(data);
```
Clean up listeners when done: `page.removeAllListeners('request'); page.removeAllListeners('response');`
Clean up listeners when done: `state.page.removeAllListeners('request'); state.page.removeAllListeners('response');`
## debugging web apps
@@ -881,14 +881,14 @@ When debugging why a web app isn't working (e.g., content not rendering, API err
**1. Console logs** — use `getLatestLogs` to check for errors:
```js
const errors = await getLatestLogs({ page, search: /error|fail/i, count: 20 });
const appLogs = await getLatestLogs({ page, search: /myComponent|state/i });
const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 });
const appLogs = await getLatestLogs({ page: state.page, search: /myComponent|state/i });
```
**2. DOM inspection via evaluate** — check content directly without screenshots:
```js
const info = await page.evaluate(() => {
const info = await state.page.evaluate(() => {
const msgs = document.querySelectorAll('.message');
return Array.from(msgs).map(m => ({
text: m.textContent?.slice(0, 200),
@@ -901,11 +901,11 @@ console.log(JSON.stringify(info, null, 2));
**3. Combine snapshot + logs for full picture:**
```js
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
await state.page.keyboard.press('Enter');
await state.page.waitForTimeout(2000);
const snap = await snapshot({ page, search: /dialog|error|message/ });
const logs = await getLatestLogs({ page, search: /error/i, count: 10 });
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);
```
@@ -933,81 +933,81 @@ This section covers low-level mouse/keyboard APIs not documented elsewhere. For
```js
// Preferred: by locator (stable, auto-waits, no coordinates needed)
await page.locator('button[name="Submit"]').click()
await page.locator('text=Login').click({ button: 'right' })
await page.locator('text=Login').dblclick()
await page.locator('a').first().click({ modifiers: ['Meta'] }) // cmd+click opens new tab
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 new tab
// By coordinates (when locators aren't available, e.g. canvas, maps, custom widgets)
await page.mouse.click(450, 320) // left click
await page.mouse.click(450, 320, { button: 'right' }) // right click
await page.mouse.dblclick(450, 320) // double click
await page.mouse.click(450, 320, { clickCount: 3 }) // triple click
await page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
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
```
### hover
```js
await page.locator('.tooltip-trigger').hover() // by locator (preferred)
await page.mouse.move(450, 320) // by coordinates
await state.page.locator('.tooltip-trigger').hover() // by locator (preferred)
await state.page.mouse.move(450, 320) // by coordinates
```
### scroll
```js
// By locator (preferred)
await page.locator('#footer').scrollIntoViewIfNeeded()
await state.page.locator('#footer').scrollIntoViewIfNeeded()
// By pixel (for canvas, maps, infinite scroll)
await page.mouse.wheel(0, 300) // scroll down 300px
await page.mouse.wheel(0, -300) // scroll up
await page.mouse.wheel(300, 0) // scroll right
await page.mouse.wheel(-300, 0) // scroll left
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 page.mouse.move(450, 320)
await page.mouse.wheel(0, 500)
await state.page.mouse.move(450, 320)
await state.page.mouse.wheel(0, 500)
// Scroll inside a container
await page.locator('.scrollable-list').evaluate(el => { el.scrollTop += 500 })
await state.page.locator('.scrollable-list').evaluate(el => { el.scrollTop += 500 })
```
### drag
```js
// By locator (preferred)
await page.locator('#item').dragTo(page.locator('#target'))
await state.page.locator('#item').dragTo(state.page.locator('#target'))
// By coordinates (for canvas, sliders, custom drag targets)
await page.mouse.move(100, 200)
await page.mouse.down()
await page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag
await page.mouse.up()
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()
```
### key hold / release / repeat
```js
// Hold modifier while pressing another key
await page.keyboard.down('Shift')
await page.keyboard.press('ArrowDown')
await page.keyboard.up('Shift')
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 page.keyboard.press('ArrowDown')
for (let i = 0; i < 5; i++) await state.page.keyboard.press('ArrowDown')
```
### resize viewport
```js
await page.setViewportSize({ width: 1280, height: 720 })
await state.page.setViewportSize({ width: 1280, height: 720 })
```
### region screenshot (zoom equivalent)
```js
await page.screenshot({ path: 'region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
await state.page.screenshot({ path: 'region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
```
Prefer locator-based actions over coordinates — locators are stable across scroll/resize, auto-wait for elements, and don't require screenshot round-trips that burn ~800 image tokens per cycle.