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.
Files changed (223) hide show
  1. package/dist/a11y-client.js +18 -8
  2. package/dist/aria-snapshot.d.ts +41 -3
  3. package/dist/aria-snapshot.d.ts.map +1 -1
  4. package/dist/aria-snapshot.js +134 -55
  5. package/dist/aria-snapshot.js.map +1 -1
  6. package/dist/aria-snapshot.test.js +5 -2
  7. package/dist/aria-snapshot.test.js.map +1 -1
  8. package/dist/aria-snapshot.unit.test.js +83 -41
  9. package/dist/aria-snapshot.unit.test.js.map +1 -1
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  13. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  14. package/dist/bippy.js +1 -1
  15. package/dist/cdp-log.d.ts +1 -1
  16. package/dist/cdp-log.d.ts.map +1 -1
  17. package/dist/cdp-log.js +1 -1
  18. package/dist/cdp-log.js.map +1 -1
  19. package/dist/cdp-relay.d.ts.map +1 -1
  20. package/dist/cdp-relay.js +492 -298
  21. package/dist/cdp-relay.js.map +1 -1
  22. package/dist/cdp-session.d.ts.map +1 -1
  23. package/dist/cdp-session.js.map +1 -1
  24. package/dist/cdp-types.d.ts.map +1 -1
  25. package/dist/cdp-types.js +7 -7
  26. package/dist/cdp-types.js.map +1 -1
  27. package/dist/clean-html.d.ts.map +1 -1
  28. package/dist/clean-html.js +4 -5
  29. package/dist/clean-html.js.map +1 -1
  30. package/dist/cli.js +45 -27
  31. package/dist/cli.js.map +1 -1
  32. package/dist/create-logger.d.ts.map +1 -1
  33. package/dist/create-logger.js +3 -1
  34. package/dist/create-logger.js.map +1 -1
  35. package/dist/debugger-examples-types.d.ts.map +1 -1
  36. package/dist/debugger.d.ts.map +1 -1
  37. package/dist/debugger.js +1 -3
  38. package/dist/debugger.js.map +1 -1
  39. package/dist/diff-utils.d.ts.map +1 -1
  40. package/dist/diff-utils.js +1 -4
  41. package/dist/diff-utils.js.map +1 -1
  42. package/dist/editor-api.md +12 -2
  43. package/dist/editor-examples.d.ts +1 -1
  44. package/dist/editor-examples.d.ts.map +1 -1
  45. package/dist/editor-examples.js +1 -1
  46. package/dist/editor-examples.js.map +1 -1
  47. package/dist/editor.d.ts +1 -1
  48. package/dist/editor.d.ts.map +1 -1
  49. package/dist/editor.js +1 -1
  50. package/dist/editor.js.map +1 -1
  51. package/dist/executor.d.ts +26 -3
  52. package/dist/executor.d.ts.map +1 -1
  53. package/dist/executor.js +297 -64
  54. package/dist/executor.js.map +1 -1
  55. package/dist/executor.unit.test.js +38 -1
  56. package/dist/executor.unit.test.js.map +1 -1
  57. package/dist/extension-connection.test.js +139 -36
  58. package/dist/extension-connection.test.js.map +1 -1
  59. package/dist/ffmpeg.d.ts +148 -0
  60. package/dist/ffmpeg.d.ts.map +1 -0
  61. package/dist/ffmpeg.js +523 -0
  62. package/dist/ffmpeg.js.map +1 -0
  63. package/dist/ghost-browser.d.ts.map +1 -1
  64. package/dist/ghost-browser.js.map +1 -1
  65. package/dist/ghost-cursor-client.js +287 -0
  66. package/dist/ghost-cursor.d.ts +27 -0
  67. package/dist/ghost-cursor.d.ts.map +1 -0
  68. package/dist/ghost-cursor.js +63 -0
  69. package/dist/ghost-cursor.js.map +1 -0
  70. package/dist/htmlrewrite.d.ts.map +1 -1
  71. package/dist/htmlrewrite.js +17 -55
  72. package/dist/htmlrewrite.js.map +1 -1
  73. package/dist/htmlrewrite.test.js.map +1 -1
  74. package/dist/kill-port.d.ts.map +1 -1
  75. package/dist/kill-port.js +1 -3
  76. package/dist/kill-port.js.map +1 -1
  77. package/dist/locator-selector.test.d.ts +2 -0
  78. package/dist/locator-selector.test.d.ts.map +1 -0
  79. package/dist/locator-selector.test.js +96 -0
  80. package/dist/locator-selector.test.js.map +1 -0
  81. package/dist/mcp-client.js.map +1 -1
  82. package/dist/mcp.d.ts.map +1 -1
  83. package/dist/mcp.js +8 -3
  84. package/dist/mcp.js.map +1 -1
  85. package/dist/on-mouse-action.test.d.ts +2 -0
  86. package/dist/on-mouse-action.test.d.ts.map +1 -0
  87. package/dist/on-mouse-action.test.js +155 -0
  88. package/dist/on-mouse-action.test.js.map +1 -0
  89. package/dist/page-markdown.js +4 -4
  90. package/dist/page-markdown.js.map +1 -1
  91. package/dist/prompt.md +450 -377
  92. package/dist/protocol.d.ts +4 -0
  93. package/dist/protocol.d.ts.map +1 -1
  94. package/dist/readability.js +16 -2
  95. package/dist/recording-ghost-cursor.d.ts +41 -0
  96. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  97. package/dist/recording-ghost-cursor.js +79 -0
  98. package/dist/recording-ghost-cursor.js.map +1 -0
  99. package/dist/recording-relay.d.ts.map +1 -1
  100. package/dist/recording-relay.js +8 -8
  101. package/dist/recording-relay.js.map +1 -1
  102. package/dist/relay-client.d.ts +17 -4
  103. package/dist/relay-client.d.ts.map +1 -1
  104. package/dist/relay-client.js +45 -11
  105. package/dist/relay-client.js.map +1 -1
  106. package/dist/relay-core.test.d.ts.map +1 -1
  107. package/dist/relay-core.test.js +515 -26
  108. package/dist/relay-core.test.js.map +1 -1
  109. package/dist/relay-navigation.test.d.ts.map +1 -1
  110. package/dist/relay-navigation.test.js +169 -31
  111. package/dist/relay-navigation.test.js.map +1 -1
  112. package/dist/relay-session.test.d.ts.map +1 -1
  113. package/dist/relay-session.test.js +113 -65
  114. package/dist/relay-session.test.js.map +1 -1
  115. package/dist/relay-state.d.ts +158 -0
  116. package/dist/relay-state.d.ts.map +1 -0
  117. package/dist/relay-state.js +306 -0
  118. package/dist/relay-state.js.map +1 -0
  119. package/dist/relay-state.test.d.ts +2 -0
  120. package/dist/relay-state.test.d.ts.map +1 -0
  121. package/dist/relay-state.test.js +472 -0
  122. package/dist/relay-state.test.js.map +1 -0
  123. package/dist/scoped-fs.d.ts.map +1 -1
  124. package/dist/scoped-fs.js.map +1 -1
  125. package/dist/screen-recording.d.ts +66 -4
  126. package/dist/screen-recording.d.ts.map +1 -1
  127. package/dist/screen-recording.js +150 -13
  128. package/dist/screen-recording.js.map +1 -1
  129. package/dist/screen-recording.test.d.ts +2 -0
  130. package/dist/screen-recording.test.d.ts.map +1 -0
  131. package/dist/screen-recording.test.js +102 -0
  132. package/dist/screen-recording.test.js.map +1 -0
  133. package/dist/selector-generator.js +1 -1
  134. package/dist/snapshot-tools.test.js +71 -28
  135. package/dist/snapshot-tools.test.js.map +1 -1
  136. package/dist/start-relay-server.d.ts +1 -1
  137. package/dist/start-relay-server.d.ts.map +1 -1
  138. package/dist/start-relay-server.js +1 -1
  139. package/dist/start-relay-server.js.map +1 -1
  140. package/dist/styles-api.md +8 -1
  141. package/dist/styles-examples.d.ts +1 -1
  142. package/dist/styles-examples.d.ts.map +1 -1
  143. package/dist/styles-examples.js +1 -1
  144. package/dist/styles-examples.js.map +1 -1
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +1 -3
  147. package/dist/styles.js.map +1 -1
  148. package/dist/test-declarations.d.ts.map +1 -1
  149. package/dist/test-utils.d.ts +1 -1
  150. package/dist/test-utils.d.ts.map +1 -1
  151. package/dist/test-utils.js +7 -5
  152. package/dist/test-utils.js.map +1 -1
  153. package/dist/utils.d.ts.map +1 -1
  154. package/dist/utils.js.map +1 -1
  155. package/dist/wait-for-page-load.d.ts.map +1 -1
  156. package/dist/wait-for-page-load.js +1 -1
  157. package/dist/wait-for-page-load.js.map +1 -1
  158. package/package.json +4 -3
  159. package/src/a11y-client.ts +5 -4
  160. package/src/aria-snapshot.test.ts +5 -2
  161. package/src/aria-snapshot.ts +306 -117
  162. package/src/aria-snapshot.unit.test.ts +199 -141
  163. package/src/aria-snapshots/github-interactive.txt +2 -0
  164. package/src/aria-snapshots/github-raw.txt +5 -1
  165. package/src/aria-snapshots/hackernews-interactive.txt +238 -241
  166. package/src/aria-snapshots/hackernews-raw.txt +265 -269
  167. package/src/assets/aria-labels-example.png +0 -0
  168. package/src/assets/aria-labels-github.png +0 -0
  169. package/src/assets/aria-labels-hacker-news.png +0 -0
  170. package/src/assets/aria-labels-old-reddit.png +0 -0
  171. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  172. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  173. package/src/cdp-log.ts +4 -1
  174. package/src/cdp-relay.ts +1059 -737
  175. package/src/cdp-session.ts +12 -3
  176. package/src/cdp-types.ts +51 -51
  177. package/src/clean-html.ts +4 -5
  178. package/src/cli.ts +82 -55
  179. package/src/create-logger.ts +5 -3
  180. package/src/debugger-examples-types.ts +4 -1
  181. package/src/debugger.ts +1 -5
  182. package/src/diff-utils.ts +2 -5
  183. package/src/editor-examples.ts +11 -1
  184. package/src/editor.ts +10 -2
  185. package/src/executor.ts +374 -73
  186. package/src/executor.unit.test.ts +48 -1
  187. package/src/extension-connection.test.ts +612 -488
  188. package/src/ffmpeg.ts +769 -0
  189. package/src/ghost-browser.ts +4 -6
  190. package/src/ghost-cursor-client.ts +369 -0
  191. package/src/ghost-cursor.ts +110 -0
  192. package/src/htmlrewrite.test.ts +6 -2
  193. package/src/htmlrewrite.ts +348 -386
  194. package/src/kill-port.ts +1 -3
  195. package/src/locator-selector.test.ts +115 -0
  196. package/src/mcp-client.ts +1 -1
  197. package/src/mcp.ts +21 -15
  198. package/src/on-mouse-action.test.ts +196 -0
  199. package/src/page-markdown.ts +7 -7
  200. package/src/protocol.ts +73 -57
  201. package/src/recording-ghost-cursor.ts +113 -0
  202. package/src/recording-relay.ts +20 -12
  203. package/src/relay-client.ts +85 -18
  204. package/src/relay-core.test.ts +1117 -578
  205. package/src/relay-navigation.test.ts +648 -483
  206. package/src/relay-session.test.ts +984 -929
  207. package/src/relay-state.test.ts +570 -0
  208. package/src/relay-state.ts +497 -0
  209. package/src/resource.md +21 -49
  210. package/src/scoped-fs.ts +9 -3
  211. package/src/screen-recording.test.ts +111 -0
  212. package/src/screen-recording.ts +256 -31
  213. package/src/skill.md +476 -396
  214. package/src/snapshot-tools.test.ts +580 -528
  215. package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
  216. package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
  217. package/src/start-relay-server.ts +14 -11
  218. package/src/styles-examples.ts +8 -1
  219. package/src/styles.ts +20 -21
  220. package/src/test-declarations.ts +6 -6
  221. package/src/test-utils.ts +104 -91
  222. package/src/utils.ts +2 -1
  223. 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 "state.page = await context.newPage(); await state.page.goto('https://example.com')"
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 "await page.click('button')"
62
+ playwriter -s 1 -e 'await state.page.click("button")'
64
63
 
65
64
  # Get page title
66
- playwriter -s 1 -e "await page.title()"
65
+ playwriter -s 1 -e 'await state.page.title()'
67
66
 
68
67
  # Take a screenshot
69
- playwriter -s 1 -e "await page.screenshot({ path: 'screenshot.png', scale: 'css' })"
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 "await accessibilitySnapshot({ page })"
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('iframe').contentFrame()
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
- # Using $'...' syntax for multiline code
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.myPage = await context.newPage()`)
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 like fs
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
- - **Create your own page**: see "working with pages" — always create and store your own page in `state`, never use the default `page` for automation
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
- - **Avoid timeouts**: prefer proper waits over `page.waitForTimeout()` - there are better ways to wait for elements
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 should follow a **observe → act → observe** loop. After every action, you must check its result before proceeding. Never chain multiple actions blindly — the page may not have responded as expected.
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 and navigate to the target URL
182
- 2. **Observe** — take an accessibility snapshot to understand the current state
183
- 3. **Update priors** — read the snapshot, identify the element to interact with
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** — take another snapshot to verify the action's effect
186
- 6. **Repeat** — continue from step 3 until the task is complete
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
- // 1. Open page and observe
215
- state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
216
- await state.myPage.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' });
217
- await accessibilitySnapshot({ page: state.myPage }).then(console.log)
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. Act: open command palette → observe result
222
- await state.myPage.keyboard.press('Meta+k');
223
- await accessibilitySnapshot({ page: state.myPage, search: /dialog|Search/ }).then(console.log)
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
- ```js
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
- ```js
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
- **Other ways to observe action results:**
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
- Snapshots are the primary feedback mechanism, but some actions have side effects that are better observed through other channels:
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
- - **Console logs** check for errors or app state after an action:
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 accessibilitySnapshot({ page, search: /my text/ })
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'); // always verify with screenshot!
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
- // GOOD: get fresh snapshot, then immediately use locators from it
286
- await accessibilitySnapshot({ page, showDiffSinceLastCall: true })
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 page.screenshotWithAccessibilityLabels({ page });
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
- // GOOD: use Enter key for line breaks
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 $'...' syntax**
311
- When using `$'...'` for multiline code, nested quotes break parsing. Use different quote styles or escape them:
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
- # GOOD: use single quotes inside, or template strings
317
- playwriter -s 1 -e $'await page.locator(\'[id="_r_a_"]\').click()'
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
- # GOOD: use heredoc for complex quoting
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
- // GOOD: evaluate DOM directly for content checks
336
- const text = await page.evaluate(() => document.querySelector('.message')?.textContent);
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. Login buttons that open popups**
350
- 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:
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
- // BAD: popup window is not controllable by playwriter
353
- await page.click('button:has-text("Login with Google")');
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
- // GOOD: cmd+click opens in new tab that playwriter can control
356
- await page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] });
357
- await page.waitForTimeout(2000);
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
- ## checking page state
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
- // Default: use snapshot with optional filtering
378
- page.url() + '\n' + await accessibilitySnapshot({ page })
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
- // Filter for specific content when snapshot is large
381
- await accessibilitySnapshot({ page, search: /dialog|button|error/i })
355
+ ```js
356
+ await state.page.getByRole('radio', { name: 'Node.js' }).click()
382
357
  ```
383
358
 
384
- Only use `screenshotWithAccessibilityLabels({ page })` for **visual layout issues** (CSS bugs, spatial positioning, colors). For verifying text content, button states, or form values, snapshots are always sufficient.
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
- If nothing changed, try `await waitForPageLoad({ page, timeout: 3000 })` or you may have clicked the wrong element.
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 accessibilitySnapshot({ page, search?, showDiffSinceLastCall? })
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
- - link "Home" [id="nav-home"]
404
- - navigation:
405
- - link "Docs" [data-testid="docs-link"]
406
- - link "Blog" role=link[name="Blog"]
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
- If a screenshot shows ref labels like `e3`, resolve them using the last snapshot:
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
- const snapshot = await accessibilitySnapshot({ page })
417
- const locator = refToLocator({ ref: 'e3' })
418
- await page.locator(locator!).click()
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.locator('[id="nav-home"]').click()
423
- await page.locator('[data-testid="docs-link"]').click()
424
- await page.locator('role=link[name="Blog"]').click()
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 snapshot = await accessibilitySnapshot({ page, search: /button|submit/i })
418
+ const snap = await snapshot({ page: state.page, search: /button|submit/i })
431
419
  ```
432
420
 
433
- **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:
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
- const snap = await accessibilitySnapshot({ page, showDiffSinceLastCall: false });
437
- const relevant = snap.split('\n').filter(l =>
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
- This is much cheaper than taking a screenshot — use it as your primary debugging tool for verifying text content, checking if elements exist, or confirming state changes.
427
+ // Scoped to main: ~20 lines (just the content you care about)
428
+ await snapshot({ locator: state.page.locator('main') })
444
429
 
445
- ## choosing between snapshot methods
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
- Both `accessibilitySnapshot` and `screenshotWithAccessibilityLabels` use the same ref system, so you can combine them effectively.
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
- **Use `accessibilitySnapshot` when:**
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
- **Use `screenshotWithAccessibilityLabels` when:**
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
- **Combining both:** Use screenshot first to understand layout and identify target elements visually, then use `accessibilitySnapshot({ search: /pattern/ })` for efficient searching in subsequent calls.
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 `accessibilitySnapshot()` - it shows what's actually interactive with stable locators.
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() // first match
487
- await page.locator('.item').last().click() // last match
488
- await page.locator('li').nth(3).click() // 4th item (0-indexed)
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.myPage` for all subsequent operations instead of the default `page` variable:
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.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
504
- await state.myPage.goto('https://example.com');
505
- // Use state.myPage for ALL subsequent operations
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.myPage || state.myPage.isClosed()) {
514
- state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage();
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.myPage.goto('https://example.com');
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** - to access protected resources, fetch from within page context (includes session cookies automatically):
527
+ **Authenticated fetches** - fetch from within page context to include session cookies automatically:
548
528
 
549
529
  ```js
550
- // BAD: curl/external requests don't have session cookies
551
- // curl -H "Cookie: ..." often fails due to missing cookies or CSRF
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
- // GOOD: fetch inside page.evaluate uses browser's full session
554
- const data = await page.evaluate(async (url) => {
555
- const resp = await fetch(url);
556
- return await resp.text();
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
- **Links that open new tabs** - use cmd+click to open in a controllable new tab:
571
+ **Downloads** - capture and save:
585
572
 
586
573
  ```js
587
- // For links with target=_blank or buttons that open popups
588
- await page.locator('a[target=_blank]').click({ modifiers: ['Meta'] });
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
- Note: `page.waitForEvent('popup')` is unreliable - playwriter cannot control popup windows opened via `window.open`. Use cmd+click instead.
598
-
599
- **Downloads** - capture and save:
578
+ **iFrames** - two approaches depending on what you need:
600
579
 
601
580
  ```js
602
- const [download] = await Promise.all([page.waitForEvent('download'), page.click('button.download')]);
603
- await download.saveAs(`/tmp/${download.suggestedFilename()}`);
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
- **iFrames** - use frameLocator:
590
+ **Dialogs** - handle alerts/confirms/prompts:
607
591
 
608
592
  ```js
609
- const frame = page.frameLocator('#my-iframe');
610
- await frame.locator('button').click();
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
- **Dialogs** - handle alerts/confirms/prompts:
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
- page.on('dialog', async dialog => { console.log(dialog.message()); await dialog.accept(); });
617
- await page.click('button.trigger-alert');
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
- **HTML processing:**
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({ locator: page.locator('.btn'), cdp: await getCDPSession({ page }) });
727
- console.log(formatStylesAsText(styles));
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 }); const dbg = createDebugger({ cdp }); await dbg.enable();
734
- const scripts = await dbg.listScripts({ search: 'app' });
735
- await dbg.setBreakpoint({ file: scripts[0].url, line: 42 });
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 }); const editor = createEditor({ cdp }); await editor.enable();
743
- const matches = await editor.grep({ regex: /console\.log/ });
744
- await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' });
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, `accessibilitySnapshot` with search is faster and uses fewer tokens.
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
- **startRecording / stopRecording** - record the page as a video at native FPS (30-60fps). Uses `chrome.tabCapture` in the extension context, so **recording survives page navigation**. Video is saved as mp4.
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
- **Note**: Recording requires the user to have clicked the Playwriter extension icon on the tab. This grants `activeTab` permission needed for `chrome.tabCapture`. Recording works on tabs where the icon was clicked - if you need to record a new tab, ask the user to click the icon on it first.
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
- // Start recording - outputPath must be specified upfront
772
- await startRecording({
773
- page,
785
+ await recording.start({
786
+ page: state.page,
774
787
  outputPath: './recording.mp4',
775
- frameRate: 30, // default: 30
776
- audio: false, // default: false (tab audio)
777
- videoBitsPerSecond: 2500000 // 2.5 Mbps
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
- // Navigate around - recording continues!
781
- await page.click('a');
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
- // Stop and get result
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
- Additional recording utilities:
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
- // Cancel recording without saving
796
- await cancelRecording({ page });
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
- **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.
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 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.
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
- url: location.href,
830
- buttons: document.querySelectorAll('button').length,
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'); const content = fs.readFileSync('./data.txt', 'utf-8'); await page.locator('textarea').fill(content);
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 = []; state.responses = [];
849
- page.on('request', req => { if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() }); });
850
- 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 {} } });
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(async ({ url, headers }) => { const res = await fetch(url, { headers }); return res.json(); }, { url, headers });
872
- console.log(data);
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
- ## debugging web apps
924
+ ## computer use (low-level mouse/keyboard)
878
925
 
879
- When debugging why a web app isn't working (e.g., content not rendering, API errors, state issues), use these techniques **before** resorting to screenshots:
880
-
881
- **1. Console logs** — use `getLatestLogs` to check for errors:
926
+ ### clicking
882
927
 
883
928
  ```js
884
- const errors = await getLatestLogs({ page, search: /error|fail/i, count: 20 });
885
- const appLogs = await getLatestLogs({ page, search: /myComponent|state/i });
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
- **2. DOM inspection via evaluate** — check content directly without screenshots:
946
+ ### hover
889
947
 
890
948
  ```js
891
- const info = await page.evaluate(() => {
892
- const msgs = document.querySelectorAll('.message');
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
- **3. Combine snapshot + logs for full picture:**
953
+ ### scroll
902
954
 
903
955
  ```js
904
- await page.keyboard.press('Enter');
905
- await page.waitForTimeout(2000);
956
+ // By locator (preferred)
957
+ await state.page.locator('#footer').scrollIntoViewIfNeeded()
906
958
 
907
- const snap = await accessibilitySnapshot({ page, search: /dialog|error|message/ });
908
- const logs = await getLatestLogs({ page, search: /error/i, count: 10 });
909
- console.log('UI:', snap);
910
- console.log('Logs:', logs);
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
- ## capabilities
975
+ ### drag
914
976
 
915
- Examples of what playwriter can do:
916
- - Monitor console logs while user reproduces a bug
917
- - Intercept network requests to reverse-engineer APIs and build SDKs
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
- ## Ghost Browser integration
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
- Playwriter supports [Ghost Browser](https://ghostbrowser.com/) for multi-identity automation. When running in Ghost Browser, the `chrome` object exposes APIs to control identities, proxies, and sessions - useful for managing multiple accounts, rotating proxies, or isolated cookie sessions.
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
- // List identities and open tabs in different ones
932
- const identities = await chrome.projects.getIdentitiesList();
933
- await chrome.ghostPublicAPI.openTab({ url: 'https://reddit.com', identity: identities[0].id });
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
- // Assign proxies per tab or identity
936
- const proxies = await chrome.ghostProxies.getList();
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
- For complete API reference with all methods, types, and examples, read:
941
- `extension/src/ghost-browser-api.d.ts`
1011
+ ### resize viewport
942
1012
 
943
- Note: Only works in Ghost Browser. In regular Chrome, calls fail with "not available".
1013
+ ```js
1014
+ await state.page.setViewportSize({ width: 1280, height: 720 })
1015
+ ```
944
1016
 
945
- ## debugging playwriter issues
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
- if some internal critical error happens you can read your own relay ws logs to understand the issue, it will show logs from extension, mcp and ws server together. then you can create a gh issue using `gh issue create -R remorses/playwriter --title title --body body`. ask for user confirmation before doing this.
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.