playwriter 0.0.63 → 0.0.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/a11y-client.js +18 -8
- package/dist/aria-snapshot.d.ts +41 -3
- package/dist/aria-snapshot.d.ts.map +1 -1
- package/dist/aria-snapshot.js +134 -55
- package/dist/aria-snapshot.js.map +1 -1
- package/dist/aria-snapshot.test.js +5 -2
- package/dist/aria-snapshot.test.js.map +1 -1
- package/dist/aria-snapshot.unit.test.js +83 -41
- package/dist/aria-snapshot.unit.test.js.map +1 -1
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
- package/dist/bippy.js +1 -1
- package/dist/cdp-log.d.ts +1 -1
- package/dist/cdp-log.d.ts.map +1 -1
- package/dist/cdp-log.js +1 -1
- package/dist/cdp-log.js.map +1 -1
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +492 -298
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cdp-session.d.ts.map +1 -1
- package/dist/cdp-session.js.map +1 -1
- package/dist/cdp-types.d.ts.map +1 -1
- package/dist/cdp-types.js +7 -7
- package/dist/cdp-types.js.map +1 -1
- package/dist/clean-html.d.ts.map +1 -1
- package/dist/clean-html.js +4 -5
- package/dist/clean-html.js.map +1 -1
- package/dist/cli.js +45 -27
- package/dist/cli.js.map +1 -1
- package/dist/create-logger.d.ts.map +1 -1
- package/dist/create-logger.js +3 -1
- package/dist/create-logger.js.map +1 -1
- package/dist/debugger-examples-types.d.ts.map +1 -1
- package/dist/debugger.d.ts.map +1 -1
- package/dist/debugger.js +1 -3
- package/dist/debugger.js.map +1 -1
- package/dist/diff-utils.d.ts.map +1 -1
- package/dist/diff-utils.js +1 -4
- package/dist/diff-utils.js.map +1 -1
- package/dist/editor-api.md +12 -2
- package/dist/editor-examples.d.ts +1 -1
- package/dist/editor-examples.d.ts.map +1 -1
- package/dist/editor-examples.js +1 -1
- package/dist/editor-examples.js.map +1 -1
- package/dist/editor.d.ts +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1 -1
- package/dist/editor.js.map +1 -1
- package/dist/executor.d.ts +26 -3
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +297 -64
- package/dist/executor.js.map +1 -1
- package/dist/executor.unit.test.js +38 -1
- package/dist/executor.unit.test.js.map +1 -1
- package/dist/extension-connection.test.js +139 -36
- package/dist/extension-connection.test.js.map +1 -1
- package/dist/ffmpeg.d.ts +148 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +523 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/ghost-browser.d.ts.map +1 -1
- package/dist/ghost-browser.js.map +1 -1
- package/dist/ghost-cursor-client.js +287 -0
- package/dist/ghost-cursor.d.ts +27 -0
- package/dist/ghost-cursor.d.ts.map +1 -0
- package/dist/ghost-cursor.js +63 -0
- package/dist/ghost-cursor.js.map +1 -0
- package/dist/htmlrewrite.d.ts.map +1 -1
- package/dist/htmlrewrite.js +17 -55
- package/dist/htmlrewrite.js.map +1 -1
- package/dist/htmlrewrite.test.js.map +1 -1
- package/dist/kill-port.d.ts.map +1 -1
- package/dist/kill-port.js +1 -3
- package/dist/kill-port.js.map +1 -1
- package/dist/locator-selector.test.d.ts +2 -0
- package/dist/locator-selector.test.d.ts.map +1 -0
- package/dist/locator-selector.test.js +96 -0
- package/dist/locator-selector.test.js.map +1 -0
- package/dist/mcp-client.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -3
- package/dist/mcp.js.map +1 -1
- package/dist/on-mouse-action.test.d.ts +2 -0
- package/dist/on-mouse-action.test.d.ts.map +1 -0
- package/dist/on-mouse-action.test.js +155 -0
- package/dist/on-mouse-action.test.js.map +1 -0
- package/dist/page-markdown.js +4 -4
- package/dist/page-markdown.js.map +1 -1
- package/dist/prompt.md +450 -377
- package/dist/protocol.d.ts +4 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/readability.js +16 -2
- package/dist/recording-ghost-cursor.d.ts +41 -0
- package/dist/recording-ghost-cursor.d.ts.map +1 -0
- package/dist/recording-ghost-cursor.js +79 -0
- package/dist/recording-ghost-cursor.js.map +1 -0
- package/dist/recording-relay.d.ts.map +1 -1
- package/dist/recording-relay.js +8 -8
- package/dist/recording-relay.js.map +1 -1
- package/dist/relay-client.d.ts +17 -4
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +45 -11
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-core.test.d.ts.map +1 -1
- package/dist/relay-core.test.js +515 -26
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-navigation.test.d.ts.map +1 -1
- package/dist/relay-navigation.test.js +169 -31
- package/dist/relay-navigation.test.js.map +1 -1
- package/dist/relay-session.test.d.ts.map +1 -1
- package/dist/relay-session.test.js +113 -65
- package/dist/relay-session.test.js.map +1 -1
- package/dist/relay-state.d.ts +158 -0
- package/dist/relay-state.d.ts.map +1 -0
- package/dist/relay-state.js +306 -0
- package/dist/relay-state.js.map +1 -0
- package/dist/relay-state.test.d.ts +2 -0
- package/dist/relay-state.test.d.ts.map +1 -0
- package/dist/relay-state.test.js +472 -0
- package/dist/relay-state.test.js.map +1 -0
- package/dist/scoped-fs.d.ts.map +1 -1
- package/dist/scoped-fs.js.map +1 -1
- package/dist/screen-recording.d.ts +66 -4
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +150 -13
- package/dist/screen-recording.js.map +1 -1
- package/dist/screen-recording.test.d.ts +2 -0
- package/dist/screen-recording.test.d.ts.map +1 -0
- package/dist/screen-recording.test.js +102 -0
- package/dist/screen-recording.test.js.map +1 -0
- package/dist/selector-generator.js +1 -1
- package/dist/snapshot-tools.test.js +71 -28
- package/dist/snapshot-tools.test.js.map +1 -1
- package/dist/start-relay-server.d.ts +1 -1
- package/dist/start-relay-server.d.ts.map +1 -1
- package/dist/start-relay-server.js +1 -1
- package/dist/start-relay-server.js.map +1 -1
- package/dist/styles-api.md +8 -1
- package/dist/styles-examples.d.ts +1 -1
- package/dist/styles-examples.d.ts.map +1 -1
- package/dist/styles-examples.js +1 -1
- package/dist/styles-examples.js.map +1 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +1 -3
- package/dist/styles.js.map +1 -1
- package/dist/test-declarations.d.ts.map +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/dist/test-utils.d.ts.map +1 -1
- package/dist/test-utils.js +7 -5
- package/dist/test-utils.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/wait-for-page-load.d.ts.map +1 -1
- package/dist/wait-for-page-load.js +1 -1
- package/dist/wait-for-page-load.js.map +1 -1
- package/package.json +4 -3
- package/src/a11y-client.ts +5 -4
- package/src/aria-snapshot.test.ts +5 -2
- package/src/aria-snapshot.ts +306 -117
- package/src/aria-snapshot.unit.test.ts +199 -141
- package/src/aria-snapshots/github-interactive.txt +2 -0
- package/src/aria-snapshots/github-raw.txt +5 -1
- package/src/aria-snapshots/hackernews-interactive.txt +238 -241
- package/src/aria-snapshots/hackernews-raw.txt +265 -269
- package/src/assets/aria-labels-example.png +0 -0
- package/src/assets/aria-labels-github.png +0 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/assets/aria-labels-old-reddit.png +0 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
- package/src/cdp-log.ts +4 -1
- package/src/cdp-relay.ts +1059 -737
- package/src/cdp-session.ts +12 -3
- package/src/cdp-types.ts +51 -51
- package/src/clean-html.ts +4 -5
- package/src/cli.ts +82 -55
- package/src/create-logger.ts +5 -3
- package/src/debugger-examples-types.ts +4 -1
- package/src/debugger.ts +1 -5
- package/src/diff-utils.ts +2 -5
- package/src/editor-examples.ts +11 -1
- package/src/editor.ts +10 -2
- package/src/executor.ts +374 -73
- package/src/executor.unit.test.ts +48 -1
- package/src/extension-connection.test.ts +612 -488
- package/src/ffmpeg.ts +769 -0
- package/src/ghost-browser.ts +4 -6
- package/src/ghost-cursor-client.ts +369 -0
- package/src/ghost-cursor.ts +110 -0
- package/src/htmlrewrite.test.ts +6 -2
- package/src/htmlrewrite.ts +348 -386
- package/src/kill-port.ts +1 -3
- package/src/locator-selector.test.ts +115 -0
- package/src/mcp-client.ts +1 -1
- package/src/mcp.ts +21 -15
- package/src/on-mouse-action.test.ts +196 -0
- package/src/page-markdown.ts +7 -7
- package/src/protocol.ts +73 -57
- package/src/recording-ghost-cursor.ts +113 -0
- package/src/recording-relay.ts +20 -12
- package/src/relay-client.ts +85 -18
- package/src/relay-core.test.ts +1117 -578
- package/src/relay-navigation.test.ts +648 -483
- package/src/relay-session.test.ts +984 -929
- package/src/relay-state.test.ts +570 -0
- package/src/relay-state.ts +497 -0
- package/src/resource.md +21 -49
- package/src/scoped-fs.ts +9 -3
- package/src/screen-recording.test.ts +111 -0
- package/src/screen-recording.ts +256 -31
- package/src/skill.md +476 -396
- package/src/snapshot-tools.test.ts +580 -528
- package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
- package/src/start-relay-server.ts +14 -11
- package/src/styles-examples.ts +8 -1
- package/src/styles.ts +20 -21
- package/src/test-declarations.ts +6 -6
- package/src/test-utils.ts +104 -91
- package/src/utils.ts +2 -1
- package/src/wait-for-page-load.ts +6 -1
package/src/skill.md
CHANGED
|
@@ -14,6 +14,7 @@ If using npx or bunx always use @latest for the first session command. so we are
|
|
|
14
14
|
### Session management
|
|
15
15
|
|
|
16
16
|
Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:
|
|
17
|
+
|
|
17
18
|
- Keep state separate between different tasks or agents
|
|
18
19
|
- Persist data (pages, variables) across multiple execute calls
|
|
19
20
|
- Avoid interference when multiple agents use playwriter simultaneously
|
|
@@ -51,49 +52,55 @@ playwriter -s <sessionId> -e "<code>"
|
|
|
51
52
|
|
|
52
53
|
The `-s` flag specifies a session ID (required). Get one with `playwriter session new`. Use the same session to persist state across commands.
|
|
53
54
|
|
|
54
|
-
Default timeout is 10 seconds. you can increase the timeout with `--timeout <ms>`
|
|
55
|
-
|
|
56
55
|
**Examples:**
|
|
57
56
|
|
|
58
57
|
```bash
|
|
59
58
|
# Navigate to a page
|
|
60
|
-
playwriter -s 1 -e
|
|
59
|
+
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
|
|
61
60
|
|
|
62
61
|
# Click a button
|
|
63
|
-
playwriter -s 1 -e
|
|
62
|
+
playwriter -s 1 -e 'await state.page.click("button")'
|
|
64
63
|
|
|
65
64
|
# Get page title
|
|
66
|
-
playwriter -s 1 -e
|
|
65
|
+
playwriter -s 1 -e 'await state.page.title()'
|
|
67
66
|
|
|
68
67
|
# Take a screenshot
|
|
69
|
-
playwriter -s 1 -e
|
|
68
|
+
playwriter -s 1 -e 'await state.page.screenshot({ path: "screenshot.png", scale: "css" })'
|
|
70
69
|
|
|
71
70
|
# Get accessibility snapshot
|
|
72
|
-
playwriter -s 1 -e
|
|
71
|
+
playwriter -s 1 -e 'await snapshot({ page: state.page })'
|
|
73
72
|
|
|
74
73
|
# Get accessibility snapshot for a specific iframe
|
|
75
|
-
const frame = await page.locator(
|
|
76
|
-
await accessibilitySnapshot({ frame })
|
|
74
|
+
playwriter -s 1 -e 'const frame = await state.page.locator("iframe").contentFrame(); await snapshot({ frame })'
|
|
77
75
|
```
|
|
78
76
|
|
|
77
|
+
**Why single quotes?** Always wrap `-e` code in single quotes (`'...'`) to prevent bash from interpreting `$`, backticks, and other special characters inside your JS code. Use double quotes or backtick template literals for strings inside the JS code.
|
|
78
|
+
|
|
79
79
|
**Multiline code:**
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
|
-
#
|
|
83
|
-
playwriter -s 1 -e $'
|
|
84
|
-
const title = await page.title();
|
|
85
|
-
const url = page.url();
|
|
86
|
-
console.log({ title, url });
|
|
87
|
-
'
|
|
88
|
-
|
|
89
|
-
# Or use heredoc
|
|
82
|
+
# Preferred: use heredoc with quoted delimiter (disables all bash expansion)
|
|
90
83
|
playwriter -s 1 -e "$(cat <<'EOF'
|
|
91
|
-
const links = await page.$$eval('a', els => els.map(e => e.href));
|
|
84
|
+
const links = await state.page.$$eval('a', els => els.map(e => e.href));
|
|
92
85
|
console.log('Found', links.length, 'links');
|
|
86
|
+
const price = text.match(/\$[\d.]+/);
|
|
93
87
|
EOF
|
|
94
88
|
)"
|
|
89
|
+
|
|
90
|
+
# Alternative: $'...' syntax (but beware: \n and \t become special, and
|
|
91
|
+
# single quotes inside must be escaped as \')
|
|
92
|
+
playwriter -s 1 -e $'
|
|
93
|
+
const title = await state.page.title();
|
|
94
|
+
const url = state.page.url();
|
|
95
|
+
console.log({ title, url });
|
|
96
|
+
'
|
|
95
97
|
```
|
|
96
98
|
|
|
99
|
+
**Quoting rules summary:**
|
|
100
|
+
- **Single quotes** (`'...'`): best for one-liners. No bash expansion at all. But you cannot include a literal single quote inside — use double quotes for JS strings instead.
|
|
101
|
+
- **Heredoc** (`<<'EOF'`): best for multiline code. The quoted `'EOF'` delimiter disables all bash expansion. Any character works inside, including `$`, backticks, and single quotes.
|
|
102
|
+
- **`$'...'`**: allows `\'` escaping but `\n`, `\t`, `\\` become special — conflicts with JS regex patterns.
|
|
103
|
+
|
|
97
104
|
### Debugging playwriter issues
|
|
98
105
|
|
|
99
106
|
If some internal critical error happens you can read the relay server logs to understand the issue. The log file is located in the user home directory:
|
|
@@ -119,350 +126,323 @@ If you find a bug, you can create a gh issue using `gh issue create -R remorses/
|
|
|
119
126
|
|
|
120
127
|
Control user's Chrome browser via playwright code snippets. Prefer single-line code with semicolons between statements. Use playwriter immediately without waiting for user actions; only if you get "extension is not connected" or "no browser tabs have Playwriter enabled" should you ask the user to click the playwriter extension icon on the target tab.
|
|
121
128
|
|
|
129
|
+
**When to use playwriter instead of webfetch/curl:** If a website is JS-heavy (SPAs like Instagram, Twitter, Facebook, etc.), has cookie consent modals, login walls, lazy-loaded content, carousels, or infinite scroll — **always use playwriter**. Simple fetch/webfetch will return an empty HTML shell with no content. Do NOT waste time trying curl, webfetch, or parsing raw HTML from JS-rendered sites. Go straight to playwriter: navigate with a real browser, dismiss modals, then extract what you need via `page.evaluate()` or network interception.
|
|
130
|
+
|
|
122
131
|
**If Chrome is not running**, the extension can't connect. Start Chrome from the command line before retrying:
|
|
123
132
|
|
|
124
133
|
```bash
|
|
125
134
|
# macOS
|
|
126
|
-
open -a "Google Chrome"
|
|
135
|
+
open -a "Google Chrome" --args --profile-directory=Default
|
|
127
136
|
|
|
128
137
|
# Linux
|
|
129
|
-
google-chrome &
|
|
138
|
+
google-chrome --profile-directory=Default &
|
|
130
139
|
|
|
131
140
|
# Windows (cmd)
|
|
132
|
-
start chrome.exe
|
|
141
|
+
start chrome.exe --profile-directory=Default
|
|
133
142
|
|
|
134
143
|
# Windows (PowerShell)
|
|
135
|
-
Start-Process chrome.exe
|
|
144
|
+
Start-Process chrome.exe -ArgumentList '--profile-directory=Default'
|
|
136
145
|
```
|
|
137
146
|
|
|
138
147
|
To also enable automatic tab capture for screen recording (no manual extension click needed), add the `--allowlisted-extension-id` and `--auto-accept-this-tab-capture` flags:
|
|
139
148
|
|
|
140
149
|
```bash
|
|
141
150
|
# macOS
|
|
142
|
-
open -a "Google Chrome" --args --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture
|
|
151
|
+
open -a "Google Chrome" --args --profile-directory=Default --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture
|
|
143
152
|
|
|
144
153
|
# Linux
|
|
145
|
-
google-chrome --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture &
|
|
154
|
+
google-chrome --profile-directory=Default --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture &
|
|
146
155
|
|
|
147
156
|
# Windows
|
|
148
|
-
start chrome.exe --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture
|
|
157
|
+
start chrome.exe --profile-directory=Default --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture
|
|
149
158
|
```
|
|
150
159
|
|
|
151
160
|
You can collaborate with the user - they can help with captchas, difficult elements, or reproducing bugs.
|
|
152
161
|
|
|
153
162
|
## context variables
|
|
154
163
|
|
|
155
|
-
- `state` - object persisted between calls **within your session**. Each session has its own isolated state. Use to store pages, data, listeners (e.g., `state.
|
|
164
|
+
- `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()`)
|
|
156
165
|
- `page` - a default page (may be shared with other agents). Prefer creating your own page and storing it in `state` (see "working with pages")
|
|
157
166
|
- `context` - browser context, access all pages via `context.pages()`
|
|
158
|
-
- `require` - load Node.js modules
|
|
167
|
+
- `require` - load Node.js modules (e.g., `const fs = require('node:fs')`). ESM `import` is not available in the sandbox
|
|
159
168
|
- Node.js globals: `setTimeout`, `setInterval`, `fetch`, `URL`, `Buffer`, `crypto`, etc.
|
|
160
169
|
|
|
161
170
|
**Important:** `state` is **session-isolated** but pages are **shared** across all sessions. See "working with pages" for how to avoid interference.
|
|
162
171
|
|
|
163
172
|
## rules
|
|
164
173
|
|
|
165
|
-
- **
|
|
174
|
+
- **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.
|
|
166
175
|
- **Multiple calls**: use multiple execute calls for complex logic - helps understand intermediate state and isolate which action failed
|
|
167
176
|
- **Never close**: never call `browser.close()` or `context.close()`. Only close pages you created or if user asks
|
|
168
177
|
- **No bringToFront**: never call unless user asks - it's disruptive and unnecessary, you can interact with background pages
|
|
169
178
|
- **Check state after actions**: always verify page state after clicking/submitting (see next section)
|
|
170
|
-
- **Clean up listeners**: call `page.removeAllListeners()` at end of message to prevent leaks
|
|
171
|
-
- **CDP sessions**: use `getCDPSession({ page })` not `page.context().newCDPSession()` - NEVER use `newCDPSession()` method, it doesn't work through playwriter relay
|
|
172
|
-
- **Wait for load**: use `page.waitForLoadState('domcontentloaded')` not `page.waitForEvent('load')` - waitForEvent times out if already loaded
|
|
173
|
-
- **
|
|
179
|
+
- **Clean up listeners**: call `state.page.removeAllListeners()` at end of message to prevent leaks
|
|
180
|
+
- **CDP sessions**: use `getCDPSession({ page: state.page })` not `state.page.context().newCDPSession()` - NEVER use `newCDPSession()` method, it doesn't work through playwriter relay
|
|
181
|
+
- **Wait for load**: use `state.page.waitForLoadState('domcontentloaded')` not `state.page.waitForEvent('load')` - waitForEvent times out if already loaded
|
|
182
|
+
- **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
|
|
183
|
+
- **Snapshot before screenshot**: always use `snapshot()` 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 tokens
|
|
184
|
+
- **Snapshot replaces page.evaluate() for inspection**: do NOT write `page.evaluate()` calls to manually query class names, bounding boxes, child counts, or visibility flags. `snapshot()` already shows every interactive element with its text, role, and a ready-to-use locator. If you catch yourself 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__`)
|
|
174
185
|
|
|
175
186
|
## interaction feedback loop
|
|
176
187
|
|
|
177
|
-
Every browser interaction
|
|
178
|
-
|
|
179
|
-
**Core loop:**
|
|
188
|
+
Every browser interaction must follow **observe → act → observe**. Never chain multiple actions blindly.
|
|
180
189
|
|
|
181
|
-
1. **Open page** — get or create your page
|
|
182
|
-
2. **Observe** —
|
|
183
|
-
3. **
|
|
190
|
+
1. **Open page** — get or create your page, navigate to URL
|
|
191
|
+
2. **Observe** — print `state.page.url()` + `snapshot()`. Always print URL — pages can redirect unexpectedly.
|
|
192
|
+
3. **Check** — if page isn't ready (loading, wrong URL, content missing), wait and observe again
|
|
184
193
|
4. **Act** — perform one action (click, type, submit)
|
|
185
|
-
5. **Observe again** —
|
|
186
|
-
6. **Repeat**
|
|
187
|
-
|
|
188
|
-
```
|
|
189
|
-
┌─────────────────────────────────────────────┐
|
|
190
|
-
│ open page + goto URL │
|
|
191
|
-
└──────────────────┬──────────────────────────┘
|
|
192
|
-
▼
|
|
193
|
-
┌────────────────┐
|
|
194
|
-
│ observe │◄─────────────────┐
|
|
195
|
-
│ (snapshot) │ │
|
|
196
|
-
└───────┬────────┘ │
|
|
197
|
-
▼ │
|
|
198
|
-
┌────────────────┐ │
|
|
199
|
-
│ update priors │ │
|
|
200
|
-
│ (read result) │ │
|
|
201
|
-
└───────┬────────┘ │
|
|
202
|
-
▼ │
|
|
203
|
-
┌────────────────┐ │
|
|
204
|
-
│ act │ │
|
|
205
|
-
│ (click/type) │──────────────────┘
|
|
206
|
-
└────────────────┘
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
**Example: opening a Framer plugin via the command palette**
|
|
210
|
-
|
|
211
|
-
Each step is a separate execute call. Notice how every action is followed by a snapshot to verify what happened:
|
|
194
|
+
5. **Observe again** — print URL + snapshot to verify the action's effect
|
|
195
|
+
6. **Repeat** from step 3 until task is complete
|
|
212
196
|
|
|
213
197
|
```js
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
await
|
|
198
|
+
// Each step should be a separate execute call:
|
|
199
|
+
// Step 1: navigate + observe
|
|
200
|
+
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
|
201
|
+
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
|
|
202
|
+
console.log('URL:', state.page.url())
|
|
203
|
+
await snapshot({ page: state.page }).then(console.log)
|
|
218
204
|
```
|
|
219
205
|
|
|
220
206
|
```js
|
|
221
|
-
// 2
|
|
222
|
-
await state.
|
|
223
|
-
|
|
207
|
+
// Step 2: act + observe
|
|
208
|
+
await state.page.locator('button:has-text("Submit")').click()
|
|
209
|
+
console.log('URL:', state.page.url())
|
|
210
|
+
await snapshot({ page: state.page }).then(console.log)
|
|
224
211
|
```
|
|
225
212
|
|
|
226
|
-
|
|
227
|
-
// 3. Act: type search query → observe result
|
|
228
|
-
await state.myPage.keyboard.type('MCP');
|
|
229
|
-
await accessibilitySnapshot({ page: state.myPage, search: /MCP/ }).then(console.log)
|
|
230
|
-
```
|
|
213
|
+
If nothing changed after an action, try `waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.
|
|
231
214
|
|
|
232
|
-
|
|
233
|
-
// 4. Act: press Enter → observe plugin loaded
|
|
234
|
-
await state.myPage.keyboard.press('Enter');
|
|
235
|
-
await state.myPage.waitForTimeout(1000);
|
|
236
|
-
const frame = state.myPage.frames().find(f => f.url().includes('plugins.framercdn.com'));
|
|
237
|
-
await accessibilitySnapshot({ page: state.myPage, frame: frame || undefined }).then(console.log)
|
|
238
|
-
```
|
|
215
|
+
**Deeper observation** — when snapshots aren't enough to understand what happened, combine multiple channels:
|
|
239
216
|
|
|
240
|
-
|
|
217
|
+
```js
|
|
218
|
+
// Check console for errors after an action
|
|
219
|
+
const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 })
|
|
241
220
|
|
|
242
|
-
|
|
221
|
+
// Combine snapshot + logs for full picture
|
|
222
|
+
const snap = await snapshot({ page: state.page, search: /dialog|error|message/ })
|
|
223
|
+
const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 })
|
|
224
|
+
console.log('UI:', snap)
|
|
225
|
+
console.log('Logs:', logs)
|
|
226
|
+
```
|
|
243
227
|
|
|
244
|
-
|
|
245
|
-
```js
|
|
246
|
-
await getLatestLogs({ page, search: /error|fail/i, count: 20 })
|
|
247
|
-
```
|
|
248
|
-
- **Network requests** — verify API calls were made after a form submit or button click:
|
|
249
|
-
```js
|
|
250
|
-
page.on('response', async res => { if (res.url().includes('/api/')) { console.log(res.status(), res.url()); } });
|
|
251
|
-
```
|
|
252
|
-
- **URL changes** — confirm navigation happened:
|
|
253
|
-
```js
|
|
254
|
-
console.log(page.url())
|
|
255
|
-
```
|
|
256
|
-
- **Screenshots** — only when you need to verify visual layout (CSS, spatial positioning, colors). Snapshots are always preferred for content verification.
|
|
228
|
+
Use `getLatestLogs()` for console errors, `state.page.url()` for navigation, screenshots only for visual layout issues.
|
|
257
229
|
|
|
258
230
|
## common mistakes to avoid
|
|
259
231
|
|
|
260
232
|
**1. Not verifying actions succeeded**
|
|
261
233
|
Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:
|
|
234
|
+
|
|
262
235
|
```js
|
|
263
|
-
await page.keyboard.type('my text')
|
|
264
|
-
await
|
|
236
|
+
await state.page.keyboard.type('my text')
|
|
237
|
+
await snapshot({ page: state.page, search: /my text/ })
|
|
265
238
|
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
|
|
266
239
|
```
|
|
267
240
|
|
|
268
241
|
**2. Assuming paste/upload worked**
|
|
269
242
|
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:
|
|
243
|
+
|
|
270
244
|
```js
|
|
271
245
|
// Reliable: use file input
|
|
272
|
-
const fileInput = page.locator('input[type="file"]').first()
|
|
273
|
-
await fileInput.setInputFiles('/path/to/image.png')
|
|
246
|
+
const fileInput = state.page.locator('input[type="file"]').first()
|
|
247
|
+
await fileInput.setInputFiles('/path/to/image.png')
|
|
274
248
|
|
|
275
249
|
// Unreliable: clipboard paste may silently fail, need to focus textarea first for example
|
|
276
|
-
await page.keyboard.press('Meta+v')
|
|
250
|
+
await state.page.keyboard.press('Meta+v') // always verify with screenshot!
|
|
277
251
|
```
|
|
278
252
|
|
|
279
253
|
**3. Using stale locators from old snapshots**
|
|
280
|
-
Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking:
|
|
281
|
-
```js
|
|
282
|
-
// BAD: using ref from minutes ago
|
|
283
|
-
await page.locator('[id="old-id"]').click(); // element may have changed
|
|
254
|
+
Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking, then immediately use locators from that output:
|
|
284
255
|
|
|
285
|
-
|
|
286
|
-
await
|
|
256
|
+
```js
|
|
257
|
+
await snapshot({ page: state.page, showDiffSinceLastCall: true })
|
|
287
258
|
// Now use the NEW locators from this output
|
|
288
259
|
```
|
|
289
260
|
|
|
290
261
|
**4. Wrong assumptions about current page/element**
|
|
291
262
|
Before destructive actions (delete, submit), verify you're targeting the right thing:
|
|
263
|
+
|
|
292
264
|
```js
|
|
293
265
|
// Before deleting, verify it's the right item
|
|
294
|
-
await
|
|
266
|
+
await screenshotWithAccessibilityLabels({ page: state.page })
|
|
295
267
|
// READ the screenshot to confirm, THEN proceed with delete
|
|
296
268
|
```
|
|
297
269
|
|
|
298
270
|
**5. Text concatenation without line breaks**
|
|
299
|
-
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')
|
|
300
|
-
```js
|
|
301
|
-
// BAD: newlines in string don't create line breaks
|
|
302
|
-
await page.keyboard.type('Line 1\nLine 2'); // becomes "Line 1Line 2"
|
|
271
|
+
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')` between lines:
|
|
303
272
|
|
|
304
|
-
|
|
305
|
-
await page.keyboard.type('Line 1')
|
|
306
|
-
await page.keyboard.press('Enter')
|
|
307
|
-
await page.keyboard.type('Line 2')
|
|
273
|
+
```js
|
|
274
|
+
await state.page.keyboard.type('Line 1')
|
|
275
|
+
await state.page.keyboard.press('Enter')
|
|
276
|
+
await state.page.keyboard.type('Line 2')
|
|
308
277
|
```
|
|
309
278
|
|
|
310
|
-
**6. Quote escaping in
|
|
311
|
-
|
|
312
|
-
```bash
|
|
313
|
-
# BAD: nested double quotes break $'...'
|
|
314
|
-
playwriter -s 1 -e $'await page.locator("[id=\"_r_a_\"]").click()'
|
|
279
|
+
**6. Quote escaping in bash**
|
|
280
|
+
Bash parses `$`, backticks, and `\` inside double-quoted strings. This silently corrupts JS code. Always use single quotes or heredoc:
|
|
315
281
|
|
|
316
|
-
|
|
317
|
-
|
|
282
|
+
```bash
|
|
283
|
+
# single quotes — bash passes everything through literally
|
|
284
|
+
playwriter -s 1 -e 'await state.page.locator(`[id="_r_a_"]`).click()'
|
|
318
285
|
|
|
319
|
-
#
|
|
286
|
+
# heredoc for complex code with mixed quotes
|
|
320
287
|
playwriter -s 1 -e "$(cat <<'EOF'
|
|
321
|
-
await page.locator('[id="_r_a_"]').click()
|
|
288
|
+
await state.page.locator('[id="_r_a_"]').click()
|
|
289
|
+
const match = html.match(/\$[\d.]+/g)
|
|
322
290
|
EOF
|
|
323
291
|
)"
|
|
324
292
|
```
|
|
325
293
|
|
|
326
294
|
**7. Using screenshots when snapshots suffice**
|
|
327
|
-
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues:
|
|
328
|
-
```js
|
|
329
|
-
// BAD: screenshot to check if text appeared (wastes tokens on image analysis)
|
|
330
|
-
await page.screenshot({ path: 'check.png', scale: 'css' });
|
|
331
|
-
|
|
332
|
-
// GOOD: snapshot is text — fast, cheap, searchable
|
|
333
|
-
await accessibilitySnapshot({ page, search: /expected text/i })
|
|
295
|
+
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues. Use snapshot for text checks:
|
|
334
296
|
|
|
335
|
-
|
|
336
|
-
|
|
297
|
+
```js
|
|
298
|
+
await snapshot({ page: state.page, search: /expected text/i })
|
|
337
299
|
```
|
|
338
300
|
|
|
339
301
|
**8. Assuming page content loaded**
|
|
340
302
|
Even after `goto()`, dynamic content may not be ready:
|
|
303
|
+
|
|
341
304
|
```js
|
|
342
|
-
await page.goto('https://example.com')
|
|
305
|
+
await state.page.goto('https://example.com')
|
|
343
306
|
// Content may still be loading via JavaScript!
|
|
344
|
-
await page.waitForSelector('article', { timeout: 10000 })
|
|
307
|
+
await state.page.waitForSelector('article', { timeout: 10000 })
|
|
345
308
|
// Or use waitForPageLoad utility
|
|
346
|
-
await waitForPageLoad({ page, timeout: 5000 })
|
|
309
|
+
await waitForPageLoad({ page: state.page, timeout: 5000 })
|
|
347
310
|
```
|
|
348
311
|
|
|
349
|
-
**9.
|
|
350
|
-
|
|
312
|
+
**9. Not using playwriter for JS-rendered sites**
|
|
313
|
+
Do NOT waste context trying webfetch, curl, or Playwright CLI screenshots on SPAs (Instagram, Twitter, etc.). These return empty HTML shells. Use playwriter directly:
|
|
314
|
+
|
|
351
315
|
```js
|
|
352
|
-
|
|
353
|
-
await page.
|
|
316
|
+
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
|
317
|
+
await state.page.goto('https://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' })
|
|
318
|
+
await waitForPageLoad({ page: state.page, timeout: 8000 })
|
|
319
|
+
await snapshot({ page: state.page, search: /cookie|consent|accept/i }).then(console.log)
|
|
320
|
+
```
|
|
354
321
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
322
|
+
**10. Login buttons that open popups**
|
|
323
|
+
Playwriter cannot control popup windows. Use cmd+click to open in a new tab instead:
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
await state.page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] })
|
|
327
|
+
await state.page.waitForTimeout(2000)
|
|
358
328
|
|
|
359
329
|
// Verify new tab opened - last page should be the login page
|
|
360
|
-
const pages = context.pages()
|
|
361
|
-
const loginPage = pages[pages.length - 1]
|
|
362
|
-
if (loginPage.url() === page.url()) {
|
|
363
|
-
throw new Error('Cmd+click did not open new tab - login may have opened as popup')
|
|
330
|
+
const pages = context.pages()
|
|
331
|
+
const loginPage = pages[pages.length - 1]
|
|
332
|
+
if (loginPage.url() === state.page.url()) {
|
|
333
|
+
throw new Error('Cmd+click did not open new tab - login may have opened as popup')
|
|
364
334
|
}
|
|
365
335
|
|
|
366
336
|
// Complete login flow in loginPage, cookies are shared with original page
|
|
367
|
-
await loginPage.locator('[data-email]').first().click()
|
|
368
|
-
await loginPage.waitForURL('**/callback**')
|
|
337
|
+
await loginPage.locator('[data-email]').first().click()
|
|
338
|
+
await loginPage.waitForURL('**/callback**')
|
|
369
339
|
// Original page should now be authenticated
|
|
370
340
|
```
|
|
371
341
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
After any action (click, submit, navigate), verify what happened. **Always prefer accessibility snapshots over screenshots** — snapshots are text (cheap, fast, searchable), screenshots require image analysis (expensive, slow).
|
|
342
|
+
**11. Click times out or does nothing — snapshot to find the blocker**
|
|
343
|
+
When a click times out, a **modal or overlay** is likely intercepting pointer events. Do not retry with different selectors or `{ force: true }` — snapshot to find the blocker:
|
|
375
344
|
|
|
376
345
|
```js
|
|
377
|
-
//
|
|
378
|
-
|
|
346
|
+
// click timed out → don't retry blindly, find what's blocking
|
|
347
|
+
await snapshot({ page: state.page, search: /dialog|modal/i })
|
|
348
|
+
// Found modal → interact with it properly (don't just close via X, it may reappear)
|
|
349
|
+
await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click()
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**12. Never use `dispatchEvent` or `{ force: true }` to bypass blockers**
|
|
353
|
+
`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:
|
|
379
354
|
|
|
380
|
-
|
|
381
|
-
await
|
|
355
|
+
```js
|
|
356
|
+
await state.page.getByRole('radio', { name: 'Node.js' }).click()
|
|
382
357
|
```
|
|
383
358
|
|
|
384
|
-
|
|
359
|
+
**13. Over-investigating instead of just interacting**
|
|
360
|
+
When something doesn't respond to a click, do NOT start inspecting CDP event listeners, React fibers, canvas pixel data, or writing `page.evaluate()` to read class names and bounding boxes. This wastes massive context. Instead:
|
|
385
361
|
|
|
386
|
-
|
|
362
|
+
1. Take a `snapshot()` — it shows every interactive element and what to click
|
|
363
|
+
2. Try a different interaction pattern if `click()` didn't work:
|
|
364
|
+
- **Drawing/annotation tools, canvas paint** → `mouse.down`, move with steps, `mouse.up` (see drag section)
|
|
365
|
+
- **Keyboard-activated modes** → press the shortcut key (snapshot shows tooltip text like "Draw mode D")
|
|
366
|
+
- **Sliders, timeline scrubbers** → drag pattern
|
|
367
|
+
- **Collapsed/toggled toolbars** → click the toggle first, wait, then interact
|
|
368
|
+
3. Take another `snapshot()` to see what changed
|
|
369
|
+
4. Only investigate DOM internals if correct interaction patterns produce zero response after 2–3 attempts
|
|
387
370
|
|
|
388
371
|
## accessibility snapshots
|
|
389
372
|
|
|
390
373
|
```js
|
|
391
|
-
await
|
|
374
|
+
await snapshot({ page: state.page, search?, showDiffSinceLastCall? })
|
|
392
375
|
```
|
|
393
376
|
|
|
394
377
|
- `search` - string/regex to filter results (returns first 10 matching lines)
|
|
395
|
-
- `showDiffSinceLastCall` - returns diff since last snapshot (default: `true`). Pass `false` to get full snapshot.
|
|
378
|
+
- `showDiffSinceLastCall` - returns diff since last snapshot (default: `true`, but `false` when `search` is provided). Pass `false` to get full snapshot.
|
|
396
379
|
|
|
397
|
-
Snapshots return full content on first call, then diffs on subsequent calls. If nothing changed, returns "No changes since last snapshot" message. Use `showDiffSinceLastCall: false` to always get full content.
|
|
380
|
+
Snapshots return full content on first call, then diffs on subsequent calls. Diff is only returned when shorter than full content. If nothing changed, returns "No changes since last snapshot" message. Use `showDiffSinceLastCall: false` to always get full content. When `search` is provided, diffing is disabled by default so the search filters the full content — pass `showDiffSinceLastCall: true` explicitly to combine both. This diffing behavior also applies to `getCleanHTML` and `getPageMarkdown`.
|
|
398
381
|
|
|
399
382
|
Example output:
|
|
400
383
|
|
|
401
384
|
```md
|
|
402
385
|
- banner:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
386
|
+
- link "Home" [id="nav-home"]
|
|
387
|
+
- navigation:
|
|
388
|
+
- link "Docs" [data-testid="docs-link"]
|
|
389
|
+
- link "Blog" role=link[name="Blog"]
|
|
407
390
|
```
|
|
408
391
|
|
|
409
|
-
Each interactive line ends with a Playwright locator you can pass to `page.locator()`.
|
|
392
|
+
Each interactive line ends with a Playwright locator you can pass to `state.page.locator()`.
|
|
410
393
|
If multiple elements share the same locator, a `>> nth=N` suffix is added (0-based)
|
|
411
394
|
to make it unique.
|
|
412
395
|
|
|
413
|
-
|
|
396
|
+
**Use snapshot locators directly — never invent selectors.** The snapshot output IS the selector. Do not guess CSS selectors or `getByText` when the snapshot already gives you the exact match:
|
|
414
397
|
|
|
415
398
|
```js
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
399
|
+
// Snapshot shows: role=radio[name="Nope, Vanilla"] → use it directly
|
|
400
|
+
await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click()
|
|
401
|
+
// Snapshot shows: role=link[name="SIGN IN"] → or pass raw string to locator()
|
|
402
|
+
await state.page.locator('role=link[name="SIGN IN"]').click()
|
|
419
403
|
```
|
|
420
404
|
|
|
405
|
+
**Beware CSS text-transform**: snapshots show visual text (`heading "NODE.JS"`) but DOM may be `"Node.js"`. Use case-insensitive regex: `getByRole('heading', { name: /node\.js/i })`.
|
|
406
|
+
|
|
407
|
+
If a screenshot shows ref labels like `e3`, resolve them using the last snapshot:
|
|
408
|
+
|
|
421
409
|
```js
|
|
422
|
-
await page.
|
|
423
|
-
|
|
424
|
-
await page.locator(
|
|
410
|
+
const snap = await snapshot({ page: state.page })
|
|
411
|
+
const locator = refToLocator({ ref: 'e3' })
|
|
412
|
+
await state.page.locator(locator!).click()
|
|
425
413
|
```
|
|
426
414
|
|
|
427
415
|
Search for specific elements:
|
|
428
416
|
|
|
429
417
|
```js
|
|
430
|
-
const
|
|
418
|
+
const snap = await snapshot({ page: state.page, search: /button|submit/i })
|
|
431
419
|
```
|
|
432
420
|
|
|
433
|
-
**
|
|
421
|
+
**Scoping snapshots to a specific element** — pass a `locator` instead of `page` to snapshot only a subtree. This dramatically reduces output size when you only care about one section of the page (e.g., the main content area, ignoring the sidebar/header/footer):
|
|
434
422
|
|
|
435
423
|
```js
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
l.includes('dialog') || l.includes('error') || l.includes('button')
|
|
439
|
-
).join('\n');
|
|
440
|
-
console.log(relevant);
|
|
441
|
-
```
|
|
424
|
+
// Full page snapshot: ~150 lines (sidebar, nav, header, footer, everything)
|
|
425
|
+
await snapshot({ page: state.page })
|
|
442
426
|
|
|
443
|
-
|
|
427
|
+
// Scoped to main: ~20 lines (just the content you care about)
|
|
428
|
+
await snapshot({ locator: state.page.locator('main') })
|
|
444
429
|
|
|
445
|
-
|
|
430
|
+
// Scope to a specific form, dialog, or section
|
|
431
|
+
await snapshot({ locator: state.page.locator('[role="dialog"]') })
|
|
432
|
+
await snapshot({ locator: state.page.locator('form#checkout') })
|
|
433
|
+
```
|
|
446
434
|
|
|
447
|
-
|
|
435
|
+
Use this whenever the full page snapshot is dominated by navigation or layout elements you don't need. It saves significant tokens and makes the output much easier to parse.
|
|
448
436
|
|
|
449
|
-
**
|
|
450
|
-
- Page has simple, semantic structure (articles, forms, lists)
|
|
451
|
-
- You need to search for specific text or patterns
|
|
452
|
-
- Token usage matters (text is smaller than images)
|
|
453
|
-
- You need to process the output programmatically
|
|
437
|
+
**Filtering large snapshots in JS** — when `search` isn't enough, filter the string directly: `snap.split('\n').filter(l => l.includes('dialog') || l.includes('error')).join('\n')`
|
|
454
438
|
|
|
455
|
-
|
|
456
|
-
- Page has complex visual layout (grids, galleries, dashboards, maps)
|
|
457
|
-
- Spatial position matters (e.g., "first image", "top-left button")
|
|
458
|
-
- DOM order doesn't match visual order
|
|
459
|
-
- You need to understand the visual hierarchy
|
|
439
|
+
## choosing between snapshot methods
|
|
460
440
|
|
|
461
|
-
|
|
441
|
+
Use `snapshot` for text-heavy pages (forms, articles) — fast, cheap, searchable. Use `screenshotWithAccessibilityLabels` for complex visual layouts (grids, galleries, dashboards) where spatial position matters. Both share the same ref system and can be combined.
|
|
462
442
|
|
|
463
443
|
## selector best practices
|
|
464
444
|
|
|
465
|
-
**For unknown websites**: use `
|
|
445
|
+
**For unknown websites**: use `snapshot()` - it shows what's actually interactive with stable locators.
|
|
466
446
|
|
|
467
447
|
**For development** (when you have source code access), prefer stable selectors in this order:
|
|
468
448
|
|
|
@@ -476,16 +456,16 @@ Both `accessibilitySnapshot` and `screenshotWithAccessibilityLabels` use the sam
|
|
|
476
456
|
Combine locators for precision:
|
|
477
457
|
|
|
478
458
|
```js
|
|
479
|
-
page.locator('tr').filter({ hasText: 'John' }).locator('button').click()
|
|
480
|
-
page.locator('button').nth(2).click()
|
|
459
|
+
state.page.locator('tr').filter({ hasText: 'John' }).locator('button').click()
|
|
460
|
+
state.page.locator('button').nth(2).click()
|
|
481
461
|
```
|
|
482
462
|
|
|
483
463
|
If a locator matches multiple elements, Playwright throws "strict mode violation". Use `.first()`, `.last()`, or `.nth(n)`:
|
|
484
464
|
|
|
485
465
|
```js
|
|
486
|
-
await page.locator('button').first().click()
|
|
487
|
-
await page.locator('.item').last().click()
|
|
488
|
-
await page.locator('li').nth(3).click()
|
|
466
|
+
await state.page.locator('button').first().click() // first match
|
|
467
|
+
await state.page.locator('.item').last().click() // last match
|
|
468
|
+
await state.page.locator('li').nth(3).click() // 4th item (0-indexed)
|
|
489
469
|
```
|
|
490
470
|
|
|
491
471
|
## working with pages
|
|
@@ -494,15 +474,15 @@ await page.locator('li').nth(3).click() // 4th item (0-indexed)
|
|
|
494
474
|
|
|
495
475
|
**Get or create your page (first call):**
|
|
496
476
|
|
|
497
|
-
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.
|
|
477
|
+
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:
|
|
498
478
|
|
|
499
479
|
```js
|
|
500
480
|
// Reuse an empty about:blank tab if available, otherwise create a new one.
|
|
501
481
|
// IMPORTANT: always navigate immediately in the same call to avoid another
|
|
502
482
|
// agent grabbing the same about:blank tab between execute calls.
|
|
503
|
-
state.
|
|
504
|
-
await state.
|
|
505
|
-
// Use state.
|
|
483
|
+
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
|
484
|
+
await state.page.goto('https://example.com')
|
|
485
|
+
// Use state.page for ALL subsequent operations
|
|
506
486
|
```
|
|
507
487
|
|
|
508
488
|
**Handle page closures gracefully:**
|
|
@@ -510,10 +490,10 @@ await state.myPage.goto('https://example.com');
|
|
|
510
490
|
The user may close your page by accident (e.g., closing a tab in Chrome). Always check before using it and recreate if needed:
|
|
511
491
|
|
|
512
492
|
```js
|
|
513
|
-
if (!state.
|
|
514
|
-
state.
|
|
493
|
+
if (!state.page || state.page.isClosed()) {
|
|
494
|
+
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
|
515
495
|
}
|
|
516
|
-
await state.
|
|
496
|
+
await state.page.goto('https://example.com')
|
|
517
497
|
```
|
|
518
498
|
|
|
519
499
|
**Use an existing page only when the user asks:**
|
|
@@ -521,16 +501,16 @@ await state.myPage.goto('https://example.com');
|
|
|
521
501
|
Only use a page from `context.pages()` if the user explicitly asks you to control a specific tab they already opened (e.g., they're logged into an app). Find it by URL pattern and store it in state:
|
|
522
502
|
|
|
523
503
|
```js
|
|
524
|
-
const pages = context.pages().filter(x => x.url().includes('myapp.com'))
|
|
525
|
-
if (pages.length === 0) throw new Error('No myapp.com page found. Ask user to enable playwriter on it.')
|
|
526
|
-
if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`)
|
|
527
|
-
state.targetPage = pages[0]
|
|
504
|
+
const pages = context.pages().filter((x) => x.url().includes('myapp.com'))
|
|
505
|
+
if (pages.length === 0) throw new Error('No myapp.com page found. Ask user to enable playwriter on it.')
|
|
506
|
+
if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`)
|
|
507
|
+
state.targetPage = pages[0]
|
|
528
508
|
```
|
|
529
509
|
|
|
530
510
|
**List all available pages:**
|
|
531
511
|
|
|
532
512
|
```js
|
|
533
|
-
context.pages().map(p => p.url())
|
|
513
|
+
context.pages().map((p) => p.url())
|
|
534
514
|
```
|
|
535
515
|
|
|
536
516
|
## navigation
|
|
@@ -538,42 +518,49 @@ context.pages().map(p => p.url())
|
|
|
538
518
|
**Use `domcontentloaded`** for `page.goto()`:
|
|
539
519
|
|
|
540
520
|
```js
|
|
541
|
-
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
|
|
542
|
-
await waitForPageLoad({ page, timeout: 5000 })
|
|
521
|
+
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
|
|
522
|
+
await waitForPageLoad({ page: state.page, timeout: 5000 })
|
|
543
523
|
```
|
|
544
524
|
|
|
545
525
|
## common patterns
|
|
546
526
|
|
|
547
|
-
**Authenticated fetches** -
|
|
527
|
+
**Authenticated fetches** - fetch from within page context to include session cookies automatically:
|
|
548
528
|
|
|
549
529
|
```js
|
|
550
|
-
|
|
551
|
-
|
|
530
|
+
const data = await state.page.evaluate(async (url) => {
|
|
531
|
+
const resp = await fetch(url)
|
|
532
|
+
return await resp.text()
|
|
533
|
+
}, 'https://example.com/protected/resource')
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**Read page cookies via CDP** - use `Network.getCookies` on the page CDP session:
|
|
552
537
|
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}, 'https://example.com/protected/resource');
|
|
538
|
+
```js
|
|
539
|
+
const cdp = await getCDPSession({ page: state.page })
|
|
540
|
+
const { cookies } = await cdp.send('Network.getCookies', { urls: [state.page.url()] })
|
|
541
|
+
console.log(cookies)
|
|
558
542
|
```
|
|
559
543
|
|
|
544
|
+
MUST use this for page-scoped cookies in extension mode. `Storage.getCookies` is a root-session command and will fail in playwriter.
|
|
545
|
+
|
|
560
546
|
**Downloading large data** - console output truncates large strings. Trigger a browser download instead:
|
|
561
547
|
|
|
562
548
|
```js
|
|
563
549
|
// Fetch protected data and trigger download to user's Downloads folder
|
|
564
|
-
await page.evaluate(async (url) => {
|
|
565
|
-
const resp = await fetch(url)
|
|
566
|
-
const data = await resp.text()
|
|
567
|
-
const blob = new Blob([data], { type: 'application/octet-stream' })
|
|
568
|
-
const a = document.createElement('a')
|
|
569
|
-
a.href = URL.createObjectURL(blob)
|
|
570
|
-
a.download = 'data.json'
|
|
571
|
-
a.click()
|
|
572
|
-
}, 'https://example.com/protected/large-file')
|
|
550
|
+
await state.page.evaluate(async (url) => {
|
|
551
|
+
const resp = await fetch(url)
|
|
552
|
+
const data = await resp.text()
|
|
553
|
+
const blob = new Blob([data], { type: 'application/octet-stream' })
|
|
554
|
+
const a = document.createElement('a')
|
|
555
|
+
a.href = URL.createObjectURL(blob)
|
|
556
|
+
a.download = 'data.json'
|
|
557
|
+
a.click()
|
|
558
|
+
}, 'https://example.com/protected/large-file')
|
|
573
559
|
// File saves to ~/Downloads - read it from there
|
|
574
560
|
```
|
|
575
561
|
|
|
576
562
|
**Avoid permission-gated browser APIs** - some APIs require user permission prompts or special browser flags. These often fail silently or hang. Examples to avoid:
|
|
563
|
+
|
|
577
564
|
- `navigator.clipboard.writeText()` - requires permission
|
|
578
565
|
- Multiple concurrent downloads - browser may block
|
|
579
566
|
- `window.showSaveFilePicker()` - requires user gesture
|
|
@@ -581,42 +568,76 @@ await page.evaluate(async (url) => {
|
|
|
581
568
|
|
|
582
569
|
Instead, use simpler alternatives (single download via `a.click()`, store data in `state`, etc).
|
|
583
570
|
|
|
584
|
-
**
|
|
571
|
+
**Downloads** - capture and save:
|
|
585
572
|
|
|
586
573
|
```js
|
|
587
|
-
|
|
588
|
-
await
|
|
589
|
-
await page.waitForTimeout(1000);
|
|
590
|
-
|
|
591
|
-
// New tab is last in context.pages()
|
|
592
|
-
const pages = context.pages();
|
|
593
|
-
const newTab = pages[pages.length - 1];
|
|
594
|
-
console.log('New tab URL:', newTab.url());
|
|
574
|
+
const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')])
|
|
575
|
+
await download.saveAs(`/tmp/${download.suggestedFilename()}`)
|
|
595
576
|
```
|
|
596
577
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
**Downloads** - capture and save:
|
|
578
|
+
**iFrames** - two approaches depending on what you need:
|
|
600
579
|
|
|
601
580
|
```js
|
|
602
|
-
|
|
603
|
-
|
|
581
|
+
// frameLocator: for chaining locator operations (click, fill, etc.)
|
|
582
|
+
const frame = state.page.frameLocator('#my-iframe')
|
|
583
|
+
await frame.locator('button').click()
|
|
584
|
+
|
|
585
|
+
// contentFrame: returns a Frame object, needed for snapshot({ frame })
|
|
586
|
+
const frame2 = await state.page.locator('iframe').contentFrame()
|
|
587
|
+
await snapshot({ frame: frame2 })
|
|
604
588
|
```
|
|
605
589
|
|
|
606
|
-
**
|
|
590
|
+
**Dialogs** - handle alerts/confirms/prompts:
|
|
607
591
|
|
|
608
592
|
```js
|
|
609
|
-
|
|
610
|
-
|
|
593
|
+
state.page.on('dialog', async (dialog) => {
|
|
594
|
+
console.log(dialog.message())
|
|
595
|
+
await dialog.accept()
|
|
596
|
+
})
|
|
597
|
+
await state.page.click('button.trigger-alert')
|
|
611
598
|
```
|
|
612
599
|
|
|
613
|
-
**
|
|
600
|
+
**Handling page obstacles (cookie modals, login walls, age gates)** - most major websites show blocking overlays. Always check for these with `snapshot()` right after navigation and dismiss them before doing anything else:
|
|
614
601
|
|
|
615
602
|
```js
|
|
616
|
-
|
|
617
|
-
await page.
|
|
603
|
+
// After navigating, check for common obstacles
|
|
604
|
+
await waitForPageLoad({ page: state.page, timeout: 5000 })
|
|
605
|
+
const snap = await snapshot({
|
|
606
|
+
page: state.page,
|
|
607
|
+
search: /cookie|consent|accept|reject|decline|allow|age|verify|login|sign.in/i,
|
|
608
|
+
})
|
|
609
|
+
console.log(snap)
|
|
610
|
+
// Look for dismiss/accept/decline buttons in the snapshot, then click them:
|
|
611
|
+
// await state.page.locator('button:has-text("Accept")').click();
|
|
612
|
+
// await state.page.locator('button:has-text("Decline optional")').click();
|
|
613
|
+
// Then re-snapshot to confirm the modal is gone before proceeding
|
|
618
614
|
```
|
|
619
615
|
|
|
616
|
+
If the page requires login and the user is already logged into Chrome, their session cookies are available — just navigate and the page should load authenticated. If not, ask the user for help or use their existing logged-in tab via `context.pages()`.
|
|
617
|
+
|
|
618
|
+
**Extracting and downloading media (images, videos)** - use `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:
|
|
619
|
+
|
|
620
|
+
```js
|
|
621
|
+
// Extract all image URLs from rendered DOM
|
|
622
|
+
const images = await state.page.evaluate(() =>
|
|
623
|
+
Array.from(document.querySelectorAll('img[src]')).map((img) => ({
|
|
624
|
+
src: img.src,
|
|
625
|
+
alt: img.alt,
|
|
626
|
+
width: img.naturalWidth,
|
|
627
|
+
})),
|
|
628
|
+
)
|
|
629
|
+
console.log(JSON.stringify(images, null, 2))
|
|
630
|
+
|
|
631
|
+
// Download a specific image to disk
|
|
632
|
+
const fs = require('node:fs')
|
|
633
|
+
const resp = await fetch(images[0].src)
|
|
634
|
+
const buf = Buffer.from(await resp.arrayBuffer())
|
|
635
|
+
fs.writeFileSync('./downloaded-image.jpg', buf)
|
|
636
|
+
console.log('Saved', buf.length, 'bytes')
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
For carousels or lazy-loaded galleries, you may need to click navigation arrows or scroll first, then re-extract. Use network interception (see "network interception" section) to capture high-resolution CDN URLs that may differ from the `img.src` thumbnails.
|
|
640
|
+
|
|
620
641
|
## utility functions
|
|
621
642
|
|
|
622
643
|
**getLatestLogs** - retrieve captured browser console logs (up to 5000 per page, cleared on navigation):
|
|
@@ -625,51 +646,41 @@ await page.click('button.trigger-alert');
|
|
|
625
646
|
await getLatestLogs({ page?, count?, search? })
|
|
626
647
|
// Examples:
|
|
627
648
|
const errors = await getLatestLogs({ search: /error/i, count: 50 })
|
|
628
|
-
const pageLogs = await getLatestLogs({ page })
|
|
649
|
+
const pageLogs = await getLatestLogs({ page: state.page })
|
|
629
650
|
```
|
|
630
651
|
|
|
631
|
-
For custom log collection across runs, store in state: `state.logs = []; page.on('console', m => state.logs.push(m.text()))`
|
|
652
|
+
For custom log collection across runs, store in state: `state.logs = []; state.page.on('console', m => state.logs.push(m.text()))`
|
|
632
653
|
|
|
633
654
|
**getCleanHTML** - get cleaned HTML from a locator or page, with search and diffing:
|
|
634
655
|
|
|
635
656
|
```js
|
|
636
657
|
await getCleanHTML({ locator, search?, showDiffSinceLastCall?, includeStyles? })
|
|
637
658
|
// Examples:
|
|
638
|
-
const html = await getCleanHTML({ locator: page.locator('body') })
|
|
639
|
-
const html = await getCleanHTML({ locator: page, search: /button/i })
|
|
640
|
-
const fullHtml = await getCleanHTML({ locator: page, showDiffSinceLastCall: false }) // disable diff
|
|
659
|
+
const html = await getCleanHTML({ locator: state.page.locator('body') })
|
|
660
|
+
const html = await getCleanHTML({ locator: state.page, search: /button/i })
|
|
661
|
+
const fullHtml = await getCleanHTML({ locator: state.page, showDiffSinceLastCall: false }) // disable diff
|
|
641
662
|
```
|
|
642
663
|
|
|
643
664
|
**Parameters:**
|
|
665
|
+
|
|
644
666
|
- `locator` - Playwright Locator or Page to get HTML from
|
|
645
667
|
- `search` - string/regex to filter results (returns first 10 matching lines with 5 lines context)
|
|
646
|
-
- `showDiffSinceLastCall` - returns diff since last call (default: `true`). Pass `false` to get full HTML.
|
|
668
|
+
- `showDiffSinceLastCall` - returns diff since last call (default: `true`, but `false` when `search` is provided). Pass `false` to get full HTML.
|
|
647
669
|
- `includeStyles` - keep style and class attributes (default: false)
|
|
648
670
|
|
|
649
|
-
|
|
650
|
-
The function cleans HTML for compact, readable output:
|
|
651
|
-
- **Removes tags**: script, style, link, meta, noscript, svg, head
|
|
652
|
-
- **Unwraps nested wrappers**: Empty divs/spans with no attributes that only wrap a single child are collapsed (e.g., `<div><div><div><p>text</p></div></div></div>` → `<div><p>text</p></div>`)
|
|
653
|
-
- **Removes empty elements**: Elements with no attributes and no content are removed
|
|
654
|
-
- **Truncates long values**: Attribute values >200 chars and text content >500 chars are truncated
|
|
655
|
-
|
|
656
|
-
**Attributes kept (summary):**
|
|
657
|
-
- Common semantic and ARIA attributes (e.g., `href`, `name`, `type`, `aria-*`)
|
|
658
|
-
- All `data-*` test attributes
|
|
659
|
-
- Frequently used test IDs and special attributes (e.g., `testid`, `qa`, `e2e`, `vimium-label`)
|
|
660
|
-
|
|
661
|
-
Snapshots return full content on first call, then diffs on subsequent calls. Diff is only returned when shorter than full content.
|
|
671
|
+
Cleans HTML automatically: removes script/style/svg/head tags, unwraps empty wrappers, removes empty elements, truncates long values. Keeps semantic attributes (`href`, `name`, `type`, `aria-*`, `data-*`).
|
|
662
672
|
|
|
663
673
|
**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:
|
|
664
674
|
|
|
665
675
|
```js
|
|
666
|
-
await getPageMarkdown({ page, search?, showDiffSinceLastCall? })
|
|
676
|
+
await getPageMarkdown({ page: state.page, search?, showDiffSinceLastCall? })
|
|
667
677
|
// Examples:
|
|
668
|
-
const content = await getPageMarkdown({ page, showDiffSinceLastCall: false }) // full article
|
|
669
|
-
const matches = await getPageMarkdown({ page, search: /API/i }) // search within content
|
|
678
|
+
const content = await getPageMarkdown({ page: state.page, showDiffSinceLastCall: false }) // full article
|
|
679
|
+
const matches = await getPageMarkdown({ page: state.page, search: /API/i }) // search within content
|
|
670
680
|
```
|
|
671
681
|
|
|
672
682
|
**Output format:**
|
|
683
|
+
|
|
673
684
|
```
|
|
674
685
|
# Article Title
|
|
675
686
|
|
|
@@ -681,130 +692,145 @@ The main article content as plain text, with paragraphs preserved...
|
|
|
681
692
|
```
|
|
682
693
|
|
|
683
694
|
**Parameters:**
|
|
695
|
+
|
|
684
696
|
- `page` - Playwright Page to extract content from
|
|
685
697
|
- `search` - string/regex to filter content (returns first 10 matching lines with 5 lines context)
|
|
686
|
-
- `showDiffSinceLastCall` - returns diff since last call (default: `true`). Pass `false` to get full content.
|
|
687
|
-
|
|
688
|
-
Snapshots return full content on first call, then diffs on subsequent calls. Diff is only returned when shorter than full content.
|
|
689
|
-
|
|
690
|
-
**Use cases:**
|
|
691
|
-
- Extract article text for LLM processing without HTML noise
|
|
692
|
-
- Get readable content from news sites, blogs, documentation
|
|
693
|
-
- Compare content changes after interactions
|
|
698
|
+
- `showDiffSinceLastCall` - returns diff since last call (default: `true`, but `false` when `search` is provided). Pass `false` to get full content.
|
|
694
699
|
|
|
695
700
|
**waitForPageLoad** - smart load detection that ignores analytics/ads:
|
|
696
701
|
|
|
697
702
|
```js
|
|
698
|
-
await waitForPageLoad({ page, timeout?, pollInterval?, minWait? })
|
|
703
|
+
await waitForPageLoad({ page: state.page, timeout?, pollInterval?, minWait? })
|
|
699
704
|
// Returns: { success, readyState, pendingRequests, waitTimeMs, timedOut }
|
|
700
705
|
```
|
|
701
706
|
|
|
702
707
|
**getCDPSession** - send raw CDP commands:
|
|
703
708
|
|
|
704
709
|
```js
|
|
705
|
-
const cdp = await getCDPSession({ page })
|
|
706
|
-
const metrics = await cdp.send('Page.getLayoutMetrics')
|
|
710
|
+
const cdp = await getCDPSession({ page: state.page })
|
|
711
|
+
const metrics = await cdp.send('Page.getLayoutMetrics')
|
|
707
712
|
```
|
|
708
713
|
|
|
709
714
|
**getLocatorStringForElement** - get stable Playwright selector from an element:
|
|
710
715
|
|
|
711
716
|
```js
|
|
712
|
-
const selector = await getLocatorStringForElement(page.locator('[id="submit-btn"]'))
|
|
717
|
+
const selector = await getLocatorStringForElement(state.page.locator('[id="submit-btn"]'))
|
|
713
718
|
// => "getByRole('button', { name: 'Save' })"
|
|
714
719
|
```
|
|
715
720
|
|
|
716
721
|
**getReactSource** - get React component source location (dev mode only):
|
|
717
722
|
|
|
718
723
|
```js
|
|
719
|
-
const source = await getReactSource({ locator: page.locator('[data-testid="submit-btn"]') })
|
|
724
|
+
const source = await getReactSource({ locator: state.page.locator('[data-testid="submit-btn"]') })
|
|
720
725
|
// => { fileName, lineNumber, columnNumber, componentName }
|
|
721
726
|
```
|
|
722
727
|
|
|
723
728
|
**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.
|
|
724
729
|
|
|
725
730
|
```js
|
|
726
|
-
const styles = await getStylesForLocator({
|
|
727
|
-
|
|
731
|
+
const styles = await getStylesForLocator({
|
|
732
|
+
locator: state.page.locator('.btn'),
|
|
733
|
+
cdp: await getCDPSession({ page: state.page }),
|
|
734
|
+
})
|
|
735
|
+
console.log(formatStylesAsText(styles))
|
|
728
736
|
```
|
|
729
737
|
|
|
730
738
|
**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.
|
|
731
739
|
|
|
732
740
|
```js
|
|
733
|
-
const cdp = await getCDPSession({ page
|
|
734
|
-
const
|
|
735
|
-
await dbg.
|
|
741
|
+
const cdp = await getCDPSession({ page: state.page })
|
|
742
|
+
const dbg = createDebugger({ cdp })
|
|
743
|
+
await dbg.enable()
|
|
744
|
+
const scripts = await dbg.listScripts({ search: 'app' })
|
|
745
|
+
await dbg.setBreakpoint({ file: scripts[0].url, line: 42 })
|
|
736
746
|
// when paused: dbg.inspectLocalVariables(), dbg.stepOver(), dbg.resume()
|
|
737
747
|
```
|
|
738
748
|
|
|
739
749
|
**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.
|
|
740
750
|
|
|
741
751
|
```js
|
|
742
|
-
const cdp = await getCDPSession({ page
|
|
743
|
-
const
|
|
744
|
-
await editor.
|
|
752
|
+
const cdp = await getCDPSession({ page: state.page })
|
|
753
|
+
const editor = createEditor({ cdp })
|
|
754
|
+
await editor.enable()
|
|
755
|
+
const matches = await editor.grep({ regex: /console\.log/ })
|
|
756
|
+
await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' })
|
|
745
757
|
```
|
|
746
758
|
|
|
747
759
|
**screenshotWithAccessibilityLabels** - take a screenshot with Vimium-style visual labels overlaid on interactive elements. Shows labels, captures screenshot, then removes labels. The image and accessibility snapshot are automatically included in the response. Can be called multiple times to capture multiple screenshots. Use a timeout of **20 seconds** for complex pages.
|
|
748
760
|
|
|
749
|
-
Prefer this for pages with grids, image galleries, maps, or complex visual layouts where spatial position matters. For simple text-heavy pages, `
|
|
761
|
+
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.
|
|
750
762
|
|
|
751
763
|
```js
|
|
752
|
-
await screenshotWithAccessibilityLabels({ page })
|
|
764
|
+
await screenshotWithAccessibilityLabels({ page: state.page })
|
|
753
765
|
// Image and accessibility snapshot are automatically included in response
|
|
754
766
|
// Use refs from snapshot to interact with elements
|
|
755
|
-
await page.locator('[id="submit-btn"]').click()
|
|
767
|
+
await state.page.locator('[id="submit-btn"]').click()
|
|
756
768
|
|
|
757
769
|
// Can take multiple screenshots in one execution
|
|
758
|
-
await screenshotWithAccessibilityLabels({ page })
|
|
759
|
-
await page.click('button')
|
|
760
|
-
await screenshotWithAccessibilityLabels({ page })
|
|
770
|
+
await screenshotWithAccessibilityLabels({ page: state.page })
|
|
771
|
+
await state.page.click('button')
|
|
772
|
+
await screenshotWithAccessibilityLabels({ page: state.page })
|
|
761
773
|
// Both images are included in the response
|
|
762
774
|
```
|
|
763
775
|
|
|
764
776
|
Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.
|
|
765
777
|
|
|
766
|
-
**
|
|
778
|
+
**resizeImage** - shrink an image in-place so it consumes fewer tokens when read back into context. `await resizeImage({ input: './screenshot.png' })`. Also accepts `width`, `height`, `maxDimension`, `quality`, `output`.
|
|
779
|
+
|
|
780
|
+
**recording.start / recording.stop** - record the page as a video at native FPS (30-60fps). Uses `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`).
|
|
767
781
|
|
|
768
|
-
|
|
782
|
+
For demos, use interaction methods (`locator.click()`, `page.mouse.move()`) instead of `goto()` to show realistic cursor motion.
|
|
769
783
|
|
|
770
784
|
```js
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
page,
|
|
785
|
+
await recording.start({
|
|
786
|
+
page: state.page,
|
|
774
787
|
outputPath: './recording.mp4',
|
|
775
|
-
frameRate: 30,
|
|
776
|
-
audio: false,
|
|
777
|
-
videoBitsPerSecond: 2500000
|
|
778
|
-
}
|
|
788
|
+
frameRate: 30, // default
|
|
789
|
+
audio: false, // default (tab audio)
|
|
790
|
+
videoBitsPerSecond: 2500000,
|
|
791
|
+
aspectRatio: { width: 16, height: 9 }, // default, set null to skip
|
|
792
|
+
maxDurationMs: 15 * 60 * 1000, // default, set 0 to disable
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
// Recording survives navigation
|
|
796
|
+
await state.page.click('a')
|
|
797
|
+
await state.page.waitForLoadState('domcontentloaded')
|
|
779
798
|
|
|
780
|
-
//
|
|
781
|
-
await page.
|
|
782
|
-
await page.waitForLoadState('domcontentloaded');
|
|
783
|
-
await page.goBack();
|
|
799
|
+
// Stop — save full result including executionTimestamps for createDemoVideo
|
|
800
|
+
state.recordingResult = await recording.stop({ page: state.page })
|
|
784
801
|
|
|
785
|
-
//
|
|
786
|
-
const { path, duration, size } = await stopRecording({ page });
|
|
787
|
-
console.log(`Saved ${size} bytes, duration: ${duration}ms`);
|
|
802
|
+
// Other: recording.isRecording({ page }), recording.cancel({ page })
|
|
788
803
|
```
|
|
789
804
|
|
|
790
|
-
|
|
791
|
-
```js
|
|
792
|
-
// Check if recording is active
|
|
793
|
-
const { isRecording, startedAt } = await isRecording({ page });
|
|
805
|
+
**ghostCursor.show / ghostCursor.hide** - show/hide cursor overlay for screenshots and demos:
|
|
794
806
|
|
|
795
|
-
|
|
796
|
-
await
|
|
807
|
+
```js
|
|
808
|
+
await ghostCursor.show({ page: state.page, style: 'minimal' }) // 'minimal', 'dot', 'screenstudio'
|
|
809
|
+
await ghostCursor.hide({ page: state.page })
|
|
797
810
|
```
|
|
798
811
|
|
|
799
|
-
**
|
|
812
|
+
**createDemoVideo** - speeds up idle sections (time between execute() calls) while keeping interactions at normal speed. Requires `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.
|
|
813
|
+
|
|
814
|
+
```js
|
|
815
|
+
// After recording.stop(), save full result to state (executionTimestamps powers idle detection)
|
|
816
|
+
state.recordingResult = await recording.stop({ page: state.page })
|
|
817
|
+
|
|
818
|
+
// In a SEPARATE execute call with --timeout 120000:
|
|
819
|
+
const demoPath = await createDemoVideo({
|
|
820
|
+
recordingPath: state.recordingResult.path,
|
|
821
|
+
durationMs: state.recordingResult.duration,
|
|
822
|
+
executionTimestamps: state.recordingResult.executionTimestamps,
|
|
823
|
+
speed: 6, // default 6x for idle sections
|
|
824
|
+
})
|
|
825
|
+
```
|
|
800
826
|
|
|
801
827
|
## pinned elements
|
|
802
828
|
|
|
803
829
|
Users can right-click → "Copy Playwriter Element Reference" to store elements in `globalThis.playwriterPinnedElem1` (increments for each pin). The reference is copied to clipboard:
|
|
804
830
|
|
|
805
831
|
```js
|
|
806
|
-
const el = await page.evaluateHandle(() => globalThis.playwriterPinnedElem1)
|
|
807
|
-
await el.click()
|
|
832
|
+
const el = await state.page.evaluateHandle(() => globalThis.playwriterPinnedElem1)
|
|
833
|
+
await el.click()
|
|
808
834
|
```
|
|
809
835
|
|
|
810
836
|
## taking screenshots
|
|
@@ -812,24 +838,28 @@ await el.click();
|
|
|
812
838
|
Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
|
|
813
839
|
|
|
814
840
|
```js
|
|
815
|
-
await page.screenshot({ path: 'shot.png', scale: 'css' })
|
|
841
|
+
await state.page.screenshot({ path: 'shot.png', scale: 'css' })
|
|
816
842
|
```
|
|
817
843
|
|
|
818
|
-
If you want to read back the image file into context
|
|
844
|
+
If you want to read back the image file into context, resize it first so it consumes fewer tokens:
|
|
845
|
+
|
|
846
|
+
```js
|
|
847
|
+
await resizeImage({ input: './shot.png' })
|
|
848
|
+
```
|
|
819
849
|
|
|
820
850
|
## page.evaluate
|
|
821
851
|
|
|
822
852
|
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):
|
|
823
853
|
|
|
824
854
|
```js
|
|
825
|
-
const title = await page.evaluate(() => document.title)
|
|
826
|
-
console.log('Title:', title)
|
|
855
|
+
const title = await state.page.evaluate(() => document.title)
|
|
856
|
+
console.log('Title:', title)
|
|
827
857
|
|
|
828
|
-
const info = await page.evaluate(() => ({
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}))
|
|
832
|
-
console.log(info)
|
|
858
|
+
const info = await state.page.evaluate(() => ({
|
|
859
|
+
url: location.href,
|
|
860
|
+
buttons: document.querySelectorAll('button').length,
|
|
861
|
+
}))
|
|
862
|
+
console.log(info)
|
|
833
863
|
```
|
|
834
864
|
|
|
835
865
|
## loading files
|
|
@@ -837,7 +867,9 @@ console.log(info);
|
|
|
837
867
|
Fill inputs with file content:
|
|
838
868
|
|
|
839
869
|
```js
|
|
840
|
-
const fs = require('node:fs')
|
|
870
|
+
const fs = require('node:fs')
|
|
871
|
+
const content = fs.readFileSync('./data.txt', 'utf-8')
|
|
872
|
+
await state.page.locator('textarea').fill(content)
|
|
841
873
|
```
|
|
842
874
|
|
|
843
875
|
## network interception
|
|
@@ -845,103 +877,151 @@ const fs = require('node:fs'); const content = fs.readFileSync('./data.txt', 'ut
|
|
|
845
877
|
For scraping or reverse-engineering APIs, intercept network requests instead of scrolling DOM. Store in `state` to analyze across calls:
|
|
846
878
|
|
|
847
879
|
```js
|
|
848
|
-
state.requests = []
|
|
849
|
-
|
|
850
|
-
page.on('
|
|
880
|
+
state.requests = []
|
|
881
|
+
state.responses = []
|
|
882
|
+
state.page.on('request', (req) => {
|
|
883
|
+
if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() })
|
|
884
|
+
})
|
|
885
|
+
state.page.on('response', async (res) => {
|
|
886
|
+
if (res.url().includes('/api/')) {
|
|
887
|
+
try {
|
|
888
|
+
state.responses.push({ url: res.url(), status: res.status(), body: await res.json() })
|
|
889
|
+
} catch {}
|
|
890
|
+
}
|
|
891
|
+
})
|
|
851
892
|
```
|
|
852
893
|
|
|
853
894
|
Then trigger actions (scroll, click, navigate) and analyze captured data:
|
|
854
895
|
|
|
855
896
|
```js
|
|
856
|
-
console.log('Captured', state.responses.length, 'API calls')
|
|
857
|
-
state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80)))
|
|
897
|
+
console.log('Captured', state.responses.length, 'API calls')
|
|
898
|
+
state.responses.forEach((r) => console.log(r.status, r.url.slice(0, 80)))
|
|
858
899
|
```
|
|
859
900
|
|
|
860
901
|
Inspect a specific response to understand schema:
|
|
861
902
|
|
|
862
903
|
```js
|
|
863
|
-
const resp = state.responses.find(r => r.url.includes('users'))
|
|
864
|
-
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
|
|
904
|
+
const resp = state.responses.find((r) => r.url.includes('users'))
|
|
905
|
+
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
|
|
865
906
|
```
|
|
866
907
|
|
|
867
908
|
Replay API directly (useful for pagination):
|
|
868
909
|
|
|
869
910
|
```js
|
|
870
|
-
const { url, headers } = state.requests.find(r => r.url.includes('feed'))
|
|
871
|
-
const data = await page.evaluate(
|
|
872
|
-
|
|
911
|
+
const { url, headers } = state.requests.find((r) => r.url.includes('feed'))
|
|
912
|
+
const data = await state.page.evaluate(
|
|
913
|
+
async ({ url, headers }) => {
|
|
914
|
+
const res = await fetch(url, { headers })
|
|
915
|
+
return res.json()
|
|
916
|
+
},
|
|
917
|
+
{ url, headers },
|
|
918
|
+
)
|
|
919
|
+
console.log(data)
|
|
873
920
|
```
|
|
874
921
|
|
|
875
|
-
Clean up listeners when done: `page.removeAllListeners('request'); page.removeAllListeners('response');`
|
|
922
|
+
Clean up listeners when done: `state.page.removeAllListeners('request'); state.page.removeAllListeners('response');`
|
|
876
923
|
|
|
877
|
-
##
|
|
924
|
+
## computer use (low-level mouse/keyboard)
|
|
878
925
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
**1. Console logs** — use `getLatestLogs` to check for errors:
|
|
926
|
+
### clicking
|
|
882
927
|
|
|
883
928
|
```js
|
|
884
|
-
|
|
885
|
-
|
|
929
|
+
// Preferred: by locator (stable, auto-waits, no coordinates needed)
|
|
930
|
+
await state.page.locator('button[name="Submit"]').click()
|
|
931
|
+
await state.page.locator('text=Login').click({ button: 'right' })
|
|
932
|
+
await state.page.locator('text=Login').dblclick()
|
|
933
|
+
await state.page
|
|
934
|
+
.locator('a')
|
|
935
|
+
.first()
|
|
936
|
+
.click({ modifiers: ['Meta'] }) // cmd+click opens new tab
|
|
937
|
+
|
|
938
|
+
// By coordinates (when locators aren't available, e.g. canvas, maps, custom widgets)
|
|
939
|
+
await state.page.mouse.click(450, 320) // left click
|
|
940
|
+
await state.page.mouse.click(450, 320, { button: 'right' }) // right click
|
|
941
|
+
await state.page.mouse.dblclick(450, 320) // double click
|
|
942
|
+
await state.page.mouse.click(450, 320, { clickCount: 3 }) // triple click
|
|
943
|
+
await state.page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
|
|
886
944
|
```
|
|
887
945
|
|
|
888
|
-
|
|
946
|
+
### hover
|
|
889
947
|
|
|
890
948
|
```js
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
return Array.from(msgs).map(m => ({
|
|
894
|
-
text: m.textContent?.slice(0, 200),
|
|
895
|
-
visible: m.offsetHeight > 0,
|
|
896
|
-
}));
|
|
897
|
-
});
|
|
898
|
-
console.log(JSON.stringify(info, null, 2));
|
|
949
|
+
await state.page.locator('.tooltip-trigger').hover() // by locator (preferred)
|
|
950
|
+
await state.page.mouse.move(450, 320) // by coordinates
|
|
899
951
|
```
|
|
900
952
|
|
|
901
|
-
|
|
953
|
+
### scroll
|
|
902
954
|
|
|
903
955
|
```js
|
|
904
|
-
|
|
905
|
-
await page.
|
|
956
|
+
// By locator (preferred)
|
|
957
|
+
await state.page.locator('#footer').scrollIntoViewIfNeeded()
|
|
906
958
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
959
|
+
// By pixel (for canvas, maps, infinite scroll)
|
|
960
|
+
await state.page.mouse.wheel(0, 300) // scroll down 300px
|
|
961
|
+
await state.page.mouse.wheel(0, -300) // scroll up
|
|
962
|
+
await state.page.mouse.wheel(300, 0) // scroll right
|
|
963
|
+
await state.page.mouse.wheel(-300, 0) // scroll left
|
|
964
|
+
|
|
965
|
+
// Scroll at a specific position
|
|
966
|
+
await state.page.mouse.move(450, 320)
|
|
967
|
+
await state.page.mouse.wheel(0, 500)
|
|
968
|
+
|
|
969
|
+
// Scroll inside a container
|
|
970
|
+
await state.page.locator('.scrollable-list').evaluate((el) => {
|
|
971
|
+
el.scrollTop += 500
|
|
972
|
+
})
|
|
911
973
|
```
|
|
912
974
|
|
|
913
|
-
|
|
975
|
+
### drag
|
|
914
976
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
- Scrape data by replaying paginated API calls instead of scrolling DOM
|
|
919
|
-
- Get accessibility snapshot to find elements, then automate interactions
|
|
920
|
-
- Use visual screenshots to understand complex layouts like image grids, dashboards, or maps
|
|
921
|
-
- Debug issues by collecting logs and controlling the page simultaneously
|
|
922
|
-
- Handle popups, downloads, iframes, and dialog boxes
|
|
923
|
-
- Record videos of browser sessions that survive page navigation
|
|
977
|
+
```js
|
|
978
|
+
// By locator (preferred)
|
|
979
|
+
await state.page.locator('#item').dragTo(state.page.locator('#target'))
|
|
924
980
|
|
|
981
|
+
// By coordinates (for canvas, sliders, custom drag targets)
|
|
982
|
+
await state.page.mouse.move(100, 200)
|
|
983
|
+
await state.page.mouse.down()
|
|
984
|
+
await state.page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag
|
|
985
|
+
await state.page.mouse.up()
|
|
986
|
+
```
|
|
925
987
|
|
|
926
|
-
|
|
988
|
+
**Freehand drawing, annotation widgets, and canvas tools** use this same `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()`:
|
|
927
989
|
|
|
928
|
-
|
|
990
|
+
```js
|
|
991
|
+
// Draw a stroke across a canvas or annotation layer
|
|
992
|
+
await state.page.mouse.move(startX, startY)
|
|
993
|
+
await state.page.mouse.down()
|
|
994
|
+
await state.page.mouse.move(endX, endY, { steps: 15 }) // steps = smoother stroke
|
|
995
|
+
await state.page.mouse.up()
|
|
996
|
+
await state.page.waitForTimeout(500) // let the widget process the stroke
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
### key hold / release / repeat
|
|
929
1000
|
|
|
930
1001
|
```js
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
await
|
|
1002
|
+
// Hold modifier while pressing another key
|
|
1003
|
+
await state.page.keyboard.down('Shift')
|
|
1004
|
+
await state.page.keyboard.press('ArrowDown')
|
|
1005
|
+
await state.page.keyboard.up('Shift')
|
|
934
1006
|
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
await chrome.ghostProxies.setTabProxy(tabId, proxies[0].id);
|
|
1007
|
+
// Repeat a key
|
|
1008
|
+
for (let i = 0; i < 5; i++) await state.page.keyboard.press('ArrowDown')
|
|
938
1009
|
```
|
|
939
1010
|
|
|
940
|
-
|
|
941
|
-
`extension/src/ghost-browser-api.d.ts`
|
|
1011
|
+
### resize viewport
|
|
942
1012
|
|
|
943
|
-
|
|
1013
|
+
```js
|
|
1014
|
+
await state.page.setViewportSize({ width: 1280, height: 720 })
|
|
1015
|
+
```
|
|
944
1016
|
|
|
945
|
-
|
|
1017
|
+
### region screenshot (zoom equivalent)
|
|
1018
|
+
|
|
1019
|
+
```js
|
|
1020
|
+
await state.page.screenshot({ path: 'region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
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.
|
|
1024
|
+
|
|
1025
|
+
## Ghost Browser integration
|
|
946
1026
|
|
|
947
|
-
|
|
1027
|
+
When running in [Ghost Browser](https://ghostbrowser.com/), the `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.
|