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/kill-port.ts CHANGED
@@ -148,9 +148,7 @@ export async function getListeningPidsForPort({ port }: { port: number }): Promi
148
148
  throw new Error(`Invalid port: ${port}`)
149
149
  }
150
150
 
151
- return os.platform() === 'win32'
152
- ? await getPidsForPortWindows(port)
153
- : await getPidsForPortUnix(port)
151
+ return os.platform() === 'win32' ? await getPidsForPortWindows(port) : await getPidsForPortUnix(port)
154
152
  }
155
153
 
156
154
  function toError(value: unknown): Error {
@@ -0,0 +1,115 @@
1
+ // Tests for Locator.selector() — verifies the raw selector strings returned
2
+ // by various locator creation methods (getByRole, locator, getByText, etc.)
3
+
4
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
5
+ import { chromium, type Page, type Browser } from '@xmorse/playwright-core'
6
+
7
+ const HTML = `<!DOCTYPE html>
8
+ <html>
9
+ <body>
10
+ <div id="main">
11
+ <button>Submit</button>
12
+ <button>Cancel</button>
13
+ <button class="primary">Save</button>
14
+ <input type="text" placeholder="Enter name" />
15
+ <input type="email" placeholder="Enter email" />
16
+ <a href="/about">About Us</a>
17
+ <h1>Page Title</h1>
18
+ <p>Some paragraph text</p>
19
+ <div class="card">
20
+ <span>Card content</span>
21
+ </div>
22
+ <div class="card">
23
+ <span>Another card</span>
24
+ </div>
25
+ <div class="card">
26
+ <span>Third card</span>
27
+ </div>
28
+ <label for="age">Age</label>
29
+ <input id="age" type="number" />
30
+ <img alt="Logo" src="/logo.png" />
31
+ <div data-testid="dashboard">Dashboard</div>
32
+ </div>
33
+ </body>
34
+ </html>`
35
+
36
+ describe('Locator.selector()', () => {
37
+ let browser: Browser
38
+ let page: Page
39
+
40
+ beforeAll(async () => {
41
+ browser = await chromium.launch({ headless: true })
42
+ const context = await browser.newContext()
43
+ page = await context.newPage()
44
+ await page.setContent(HTML)
45
+ })
46
+
47
+ afterAll(async () => {
48
+ await browser.close()
49
+ })
50
+
51
+ it('CSS selectors', () => {
52
+ expect(page.locator('#main').selector()).toMatchInlineSnapshot(`"#main"`)
53
+ expect(page.locator('.card').selector()).toMatchInlineSnapshot(`".card"`)
54
+ expect(page.locator('button').selector()).toMatchInlineSnapshot(`"button"`)
55
+ expect(page.locator('div.card > span').selector()).toMatchInlineSnapshot(`"div.card > span"`)
56
+ expect(page.locator('#main .card:first-child').selector()).toMatchInlineSnapshot(`"#main .card:first-child"`)
57
+ })
58
+
59
+ it('getByRole', () => {
60
+ expect(page.getByRole('button').selector()).toMatchInlineSnapshot(`"internal:role=button"`)
61
+ expect(page.getByRole('button', { name: 'Submit' }).selector()).toMatchInlineSnapshot(`"internal:role=button[name="Submit"i]"`)
62
+ expect(page.getByRole('link').selector()).toMatchInlineSnapshot(`"internal:role=link"`)
63
+ expect(page.getByRole('heading').selector()).toMatchInlineSnapshot(`"internal:role=heading"`)
64
+ expect(page.getByRole('textbox').selector()).toMatchInlineSnapshot(`"internal:role=textbox"`)
65
+ })
66
+
67
+ it('getByText', () => {
68
+ expect(page.getByText('Submit').selector()).toMatchInlineSnapshot(`"internal:text="Submit"i"`)
69
+ expect(page.getByText('Some paragraph').selector()).toMatchInlineSnapshot(`"internal:text="Some paragraph"i"`)
70
+ expect(page.getByText(/card/i).selector()).toMatchInlineSnapshot(`"internal:text=/card/i"`)
71
+ })
72
+
73
+ it('getByPlaceholder', () => {
74
+ expect(page.getByPlaceholder('Enter name').selector()).toMatchInlineSnapshot(`"internal:attr=[placeholder="Enter name"i]"`)
75
+ expect(page.getByPlaceholder('Enter email').selector()).toMatchInlineSnapshot(`"internal:attr=[placeholder="Enter email"i]"`)
76
+ })
77
+
78
+ it('getByLabel', () => {
79
+ expect(page.getByLabel('Age').selector()).toMatchInlineSnapshot(`"internal:label="Age"i"`)
80
+ })
81
+
82
+ it('getByAltText', () => {
83
+ expect(page.getByAltText('Logo').selector()).toMatchInlineSnapshot(`"internal:attr=[alt="Logo"i]"`)
84
+ })
85
+
86
+ it('getByTestId', () => {
87
+ expect(page.getByTestId('dashboard').selector()).toMatchInlineSnapshot(`"internal:testid=[data-testid="dashboard"s]"`)
88
+ })
89
+
90
+ it('chained locators', () => {
91
+ expect(page.locator('#main').locator('.card').selector()).toMatchInlineSnapshot(`"#main >> .card"`)
92
+ expect(page.locator('.card').first().selector()).toMatchInlineSnapshot(`".card >> nth=0"`)
93
+ expect(page.locator('.card').last().selector()).toMatchInlineSnapshot(`".card >> nth=-1"`)
94
+ expect(page.locator('.card').nth(1).selector()).toMatchInlineSnapshot(`".card >> nth=1"`)
95
+ })
96
+
97
+ it('filtered locators', () => {
98
+ expect(page.locator('button').filter({ hasText: 'Save' }).selector()).toMatchInlineSnapshot(`"button >> internal:has-text="Save"i"`)
99
+ expect(page.locator('div').filter({ has: page.locator('span') }).selector()).toMatchInlineSnapshot(`"div >> internal:has="span""`)
100
+ expect(page.locator('button').filter({ hasNotText: 'Cancel' }).selector()).toMatchInlineSnapshot(`"button >> internal:has-not-text="Cancel"i"`)
101
+ })
102
+
103
+ it('described locators', () => {
104
+ expect(page.locator('button').describe('main action button').selector()).toMatchInlineSnapshot(`"button >> internal:describe="main action button""`)
105
+ })
106
+
107
+ it('combined with and/or', () => {
108
+ expect(
109
+ page.locator('button').and(page.locator('.primary')).selector(),
110
+ ).toMatchInlineSnapshot(`"button >> internal:and=".primary""`)
111
+ expect(
112
+ page.locator('button').or(page.locator('a')).selector(),
113
+ ).toMatchInlineSnapshot(`"button >> internal:or="a""`)
114
+ })
115
+ })
package/src/mcp-client.ts CHANGED
@@ -17,7 +17,7 @@ export async function createTransport({ args = [], port }: { args?: string[]; po
17
17
  stderr: Stream | null
18
18
  }> {
19
19
  const env: Record<string, string> = {
20
- ...process.env as Record<string, string>,
20
+ ...(process.env as Record<string, string>),
21
21
  DEBUG: 'playwriter:mcp:test',
22
22
  DEBUG_COLORS: '0',
23
23
  DEBUG_HIDE_DATE: '1',
package/src/mcp.ts CHANGED
@@ -91,12 +91,12 @@ async function getOrCreateExecutor(): Promise<PlaywrightExecutor> {
91
91
  if (executor) {
92
92
  return executor
93
93
  }
94
-
94
+
95
95
  const remote = getRemoteConfig()
96
96
  if (!remote) {
97
97
  await ensureRelayServerForMcp()
98
98
  }
99
-
99
+
100
100
  // Pass config instead of pre-generated URL so executor can generate unique URLs for each connection
101
101
  const cdpConfig = remote || { port: RELAY_PORT }
102
102
  executor = new PlaywrightExecutor({
@@ -104,7 +104,7 @@ async function getOrCreateExecutor(): Promise<PlaywrightExecutor> {
104
104
  logger: mcpLogger,
105
105
  cwd: process.cwd(),
106
106
  })
107
-
107
+
108
108
  return executor
109
109
  }
110
110
 
@@ -198,39 +198,42 @@ server.tool(
198
198
  if (!remote) {
199
199
  await ensureRelayServerForMcp()
200
200
  }
201
-
201
+
202
202
  const exec = await getOrCreateExecutor()
203
203
  const result = await exec.execute(code, timeout)
204
-
204
+
205
205
  // Transform executor result to MCP format
206
206
  const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [
207
207
  { type: 'text', text: result.text },
208
208
  ]
209
-
209
+
210
210
  for (const image of result.images) {
211
211
  content.push({ type: 'image', data: image.data, mimeType: image.mimeType })
212
212
  }
213
-
213
+
214
214
  if (result.isError) {
215
215
  return { content, isError: true }
216
216
  }
217
-
217
+
218
218
  return { content }
219
219
  } catch (error: any) {
220
220
  const errorStack = error.stack || error.message
221
- const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError'
222
-
221
+ const isTimeoutError =
222
+ error instanceof CodeExecutionTimeoutError || error?.name === 'TimeoutError' || error?.name === 'AbortError'
223
+
223
224
  console.error('Error in execute tool:', errorStack)
224
225
  if (!isTimeoutError) {
225
226
  sendLogToRelayServer('error', 'Error in execute tool:', errorStack)
226
227
  }
227
-
228
+
228
229
  const resetHint = isTimeoutError
229
230
  ? ''
230
231
  : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]'
231
-
232
+
233
+ // timeout stacks are internal noise (Promise.race / setTimeout); only show the message
234
+ const errorText = isTimeoutError ? error.message : errorStack
232
235
  return {
233
- content: [{ type: 'text', text: `Error executing code: ${error.message}\n${errorStack}${resetHint}` }],
236
+ content: [{ type: 'text', text: `Error executing code: ${errorText}${resetHint}` }],
234
237
  isError: true,
235
238
  }
236
239
  }
@@ -256,13 +259,16 @@ server.tool(
256
259
  if (!remote) {
257
260
  await ensureRelayServerForMcp()
258
261
  }
259
-
262
+
260
263
  const exec = await getOrCreateExecutor()
261
264
  const { page, context } = await exec.reset()
262
265
  const pagesCount = context.pages().length
263
266
  return {
264
267
  content: [
265
- { type: 'text', text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}` },
268
+ {
269
+ type: 'text',
270
+ text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}`,
271
+ },
266
272
  ],
267
273
  }
268
274
  } catch (error: any) {
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Test for the page.onMouseAction callback added to the playwright fork.
3
+ * Verifies the callback fires for both explicit page.mouse.* calls
4
+ * and locator-initiated actions like page.locator().click().
5
+ */
6
+ import { chromium } from '@xmorse/playwright-core'
7
+ import type { MouseActionEvent, Page } from '@xmorse/playwright-core'
8
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
9
+ import { getCdpUrl } from './utils.js'
10
+ import { enableGhostCursor, applyGhostCursorMouseAction, disableGhostCursor } from './ghost-cursor.js'
11
+ import {
12
+ setupTestContext,
13
+ cleanupTestContext,
14
+ getExtensionServiceWorker,
15
+ type TestContext,
16
+ safeCloseCDPBrowser,
17
+ } from './test-utils.js'
18
+ import './test-declarations.js'
19
+
20
+ const TEST_PORT = 19994
21
+
22
+ describe('onMouseAction callback', () => {
23
+ let cleanup: (() => Promise<void>) | null = null
24
+ let testCtx: TestContext | null = null
25
+
26
+ beforeAll(async () => {
27
+ testCtx = await setupTestContext({
28
+ port: TEST_PORT,
29
+ tempDirPrefix: 'pw-mouse-action-test-',
30
+ toggleExtension: true,
31
+ })
32
+ }, 600000)
33
+
34
+ afterAll(async () => {
35
+ await cleanupTestContext(testCtx, cleanup)
36
+ cleanup = null
37
+ testCtx = null
38
+ })
39
+
40
+ it('should fire onMouseAction for page.mouse.click()', async () => {
41
+ const browserContext = testCtx!.browserContext
42
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
43
+
44
+ const page = await browserContext.newPage()
45
+ await page.goto('data:text/html,<html><body><button id="btn">Click me</button></body></html>')
46
+ await page.bringToFront()
47
+
48
+ await serviceWorker.evaluate(async () => {
49
+ await (globalThis as any).toggleExtensionForActiveTab()
50
+ })
51
+ await new Promise((r) => { setTimeout(r, 200) })
52
+
53
+ const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
54
+ const contexts = directBrowser.contexts()
55
+ const pages = contexts[0].pages()
56
+ const targetPage = pages.find((p) => p.url().startsWith('data:'))
57
+ expect(targetPage).toBeDefined()
58
+
59
+ const events: MouseActionEvent[] = []
60
+ targetPage!.onMouseAction = async (event) => {
61
+ events.push({ ...event })
62
+ }
63
+
64
+ await targetPage!.mouse.click(100, 100)
65
+
66
+ // click dispatches: move → down → up
67
+ const types = events.map((e) => e.type)
68
+ expect(types).toContain('move')
69
+ expect(types).toContain('down')
70
+ expect(types).toContain('up')
71
+
72
+ // All events should have coordinates
73
+ for (const event of events) {
74
+ expect(typeof event.x).toBe('number')
75
+ expect(typeof event.y).toBe('number')
76
+ expect(typeof event.button).toBe('string')
77
+ }
78
+
79
+ // The move event should have the target coordinates
80
+ const moveEvent = events.find((e) => e.type === 'move')!
81
+ expect(moveEvent.x).toBe(100)
82
+ expect(moveEvent.y).toBe(100)
83
+
84
+ await safeCloseCDPBrowser(directBrowser)
85
+ }, 30000)
86
+
87
+ it('should fire onMouseAction for locator.click()', async () => {
88
+ const browserContext = testCtx!.browserContext
89
+
90
+ const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
91
+ const contexts = directBrowser.contexts()
92
+ const pages = contexts[0].pages()
93
+ const targetPage = pages.find((p) => p.url().startsWith('data:'))
94
+ expect(targetPage).toBeDefined()
95
+
96
+ const events: MouseActionEvent[] = []
97
+ targetPage!.onMouseAction = async (event) => {
98
+ events.push({ ...event })
99
+ }
100
+
101
+ // locator.click() resolves coordinates server-side, then calls server Mouse
102
+ await targetPage!.locator('#btn').click()
103
+
104
+ const types = events.map((e) => e.type)
105
+ expect(types).toContain('move')
106
+ expect(types).toContain('down')
107
+ expect(types).toContain('up')
108
+
109
+ // The button center should be somewhere reasonable (not 0,0)
110
+ const moveEvent = events.find((e) => e.type === 'move')!
111
+ expect(moveEvent.x).toBeGreaterThan(0)
112
+ expect(moveEvent.y).toBeGreaterThan(0)
113
+
114
+ await safeCloseCDPBrowser(directBrowser)
115
+ }, 30000)
116
+
117
+ it('should animate ghost cursor from onMouseAction callback', async () => {
118
+ const browserContext = testCtx!.browserContext
119
+
120
+ const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
121
+ let targetPage: Page | null = null
122
+ try {
123
+ const contexts = directBrowser.contexts()
124
+ const pages = contexts[0].pages()
125
+ targetPage = pages.find((p) => p.url().startsWith('data:'))!
126
+ expect(targetPage).toBeDefined()
127
+ const pageForTest = targetPage
128
+
129
+ await enableGhostCursor({ page: pageForTest })
130
+ pageForTest.onMouseAction = async (event) => {
131
+ await applyGhostCursorMouseAction({ page: pageForTest, event })
132
+ }
133
+
134
+ await pageForTest.mouse.click(140, 120)
135
+
136
+ const cursorState = await pageForTest.evaluate(() => {
137
+ const cursorElement = document.getElementById('__playwriter_ghost_cursor__')
138
+ if (!cursorElement) {
139
+ return { exists: false, transform: '' }
140
+ }
141
+ return {
142
+ exists: true,
143
+ transform: cursorElement.getAttribute('style') || '',
144
+ }
145
+ })
146
+
147
+ expect(cursorState.exists).toBe(true)
148
+ const translateMatch = cursorState.transform.match(/translate3d\(([-\d.]+)px, ([-\d.]+)px, 0(?:px)?\)/)
149
+ expect(translateMatch).toBeTruthy()
150
+ const translateX = Number(translateMatch![1])
151
+ const translateY = Number(translateMatch![2])
152
+ // Screen Studio cursor uses a hotspot offset, so CSS position is slightly above/left of click target.
153
+ expect(translateX).toBeLessThanOrEqual(140)
154
+ expect(translateY).toBeLessThanOrEqual(120)
155
+ expect(translateX).toBeGreaterThan(120)
156
+ expect(translateY).toBeGreaterThan(100)
157
+
158
+ await disableGhostCursor({ page: pageForTest })
159
+
160
+ const hasGhostCursor = await pageForTest.evaluate(() => {
161
+ return Boolean(document.getElementById('__playwriter_ghost_cursor__'))
162
+ })
163
+ expect(hasGhostCursor).toBe(false)
164
+ } finally {
165
+ if (targetPage) {
166
+ targetPage.onMouseAction = null
167
+ await disableGhostCursor({ page: targetPage })
168
+ }
169
+ await safeCloseCDPBrowser(directBrowser)
170
+ }
171
+ }, 30000)
172
+
173
+ it('should not fire when onMouseAction is set to null', async () => {
174
+ const browserContext = testCtx!.browserContext
175
+
176
+ const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
177
+ const contexts = directBrowser.contexts()
178
+ const pages = contexts[0].pages()
179
+ const targetPage = pages.find((p) => p.url().startsWith('data:'))
180
+ expect(targetPage).toBeDefined()
181
+
182
+ const events: MouseActionEvent[] = []
183
+ targetPage!.onMouseAction = async (event) => {
184
+ events.push({ ...event })
185
+ }
186
+
187
+ // Disable callback
188
+ targetPage!.onMouseAction = null
189
+
190
+ await targetPage!.mouse.click(50, 50)
191
+
192
+ expect(events).toHaveLength(0)
193
+
194
+ await safeCloseCDPBrowser(directBrowser)
195
+ }, 30000)
196
+ })
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Extract page content as markdown using Mozilla Readability.
3
- *
3
+ *
4
4
  * This utility injects the Readability library into the page and extracts
5
5
  * the main content, similar to Firefox Reader View.
6
6
  */
@@ -65,12 +65,12 @@ function isRegExp(value: unknown): value is RegExp {
65
65
 
66
66
  /**
67
67
  * Extract page content as markdown using Mozilla Readability.
68
- *
68
+ *
69
69
  * Injects Readability into the page if not already present, then extracts
70
70
  * the main content. Returns plain text content (no HTML).
71
71
  */
72
72
  export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<string> {
73
- const { page, search, showDiffSinceLastCall = true } = options
73
+ const { page, search, showDiffSinceLastCall = !search } = options
74
74
 
75
75
  // Check if readability is already injected
76
76
  const hasReadability = await page.evaluate(() => !!(globalThis as any).__readability)
@@ -81,7 +81,7 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<
81
81
  }
82
82
 
83
83
  // Extract content using Readability
84
- const result = await page.evaluate(() => {
84
+ const result = (await page.evaluate(() => {
85
85
  const readability = (globalThis as any).__readability
86
86
  if (!readability) {
87
87
  throw new Error('Readability not loaded')
@@ -131,11 +131,11 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<
131
131
  publishedTime: article.publishedTime || null,
132
132
  wordCount: (article.textContent || '').split(/\s+/).filter(Boolean).length,
133
133
  }
134
- }) as PageMarkdownResult & { _notReadable?: boolean }
134
+ })) as PageMarkdownResult & { _notReadable?: boolean }
135
135
 
136
136
  // Format output
137
137
  const lines: string[] = []
138
-
138
+
139
139
  if (result.title) {
140
140
  lines.push(`# ${result.title}`)
141
141
  lines.push('')
@@ -172,7 +172,7 @@ export async function getPageMarkdown(options: GetPageMarkdownOptions): Promise<
172
172
  const previousSnapshot = lastMarkdownSnapshots.get(page)
173
173
  lastMarkdownSnapshots.set(page, markdown)
174
174
 
175
- // Return diff if we have a previous snapshot and diff mode is enabled
175
+ // Diff defaults off when search is provided, but agent can explicitly enable both
176
176
  if (showDiffSinceLastCall && previousSnapshot) {
177
177
  const diffResult = createSmartDiff({
178
178
  oldContent: previousSnapshot,
package/src/protocol.ts CHANGED
@@ -2,19 +2,18 @@ import { CDPEventFor, ProtocolMapping } from './cdp-types.js'
2
2
 
3
3
  export const VERSION = 1
4
4
 
5
- type ForwardCDPCommand =
6
- {
7
- [K in keyof ProtocolMapping.Commands]: {
8
- id: number
9
- method: 'forwardCDPCommand'
10
- params: {
11
- method: K
12
- sessionId?: string
13
- params?: ProtocolMapping.Commands[K]['paramsType'][0]
14
- source?: 'playwriter'
15
- }
5
+ type ForwardCDPCommand = {
6
+ [K in keyof ProtocolMapping.Commands]: {
7
+ id: number
8
+ method: 'forwardCDPCommand'
9
+ params: {
10
+ method: K
11
+ sessionId?: string
12
+ params?: ProtocolMapping.Commands[K]['paramsType'][0]
13
+ source?: 'playwriter'
16
14
  }
17
- }[keyof ProtocolMapping.Commands]
15
+ }
16
+ }[keyof ProtocolMapping.Commands]
18
17
 
19
18
  export type ExtensionCommandMessage = ForwardCDPCommand
20
19
 
@@ -29,18 +28,17 @@ export type ExtensionResponseMessage = {
29
28
  * This produces a discriminated union for narrowing, similar to ForwardCDPCommand,
30
29
  * but for forwarded CDP events. Uses CDPEvent to maintain proper type extraction.
31
30
  */
32
- export type ExtensionEventMessage =
33
- {
34
- [K in keyof ProtocolMapping.Events]: {
35
- id?: undefined
36
- method: 'forwardCDPEvent'
37
- params: {
38
- method: CDPEventFor<K>['method']
39
- sessionId?: string
40
- params?: CDPEventFor<K>['params']
41
- }
31
+ export type ExtensionEventMessage = {
32
+ [K in keyof ProtocolMapping.Events]: {
33
+ id?: undefined
34
+ method: 'forwardCDPEvent'
35
+ params: {
36
+ method: CDPEventFor<K>['method']
37
+ sessionId?: string
38
+ params?: CDPEventFor<K>['params']
42
39
  }
43
- }[keyof ProtocolMapping.Events]
40
+ }
41
+ }[keyof ProtocolMapping.Events]
44
42
 
45
43
  export type ExtensionLogMessage = {
46
44
  id?: undefined
@@ -78,10 +76,17 @@ export type RecordingCancelledMessage = {
78
76
  }
79
77
  }
80
78
 
81
- export type ExtensionMessage = ExtensionResponseMessage | ExtensionEventMessage | ExtensionLogMessage | ExtensionPongMessage | RecordingDataMessage | RecordingCancelledMessage
79
+ export type ExtensionMessage =
80
+ | ExtensionResponseMessage
81
+ | ExtensionEventMessage
82
+ | ExtensionLogMessage
83
+ | ExtensionPongMessage
84
+ | RecordingDataMessage
85
+ | RecordingCancelledMessage
82
86
 
83
87
  // Recording command messages (MCP -> Extension via relay)
84
88
  export type StartRecordingParams = {
89
+ /** CDP tab session ID (pw-tab-*) to identify which tab to record. */
85
90
  sessionId?: string
86
91
  frameRate?: number
87
92
  audio?: boolean
@@ -95,14 +100,17 @@ export type StartRecordingBody = StartRecordingParams & {
95
100
  }
96
101
 
97
102
  export type StopRecordingParams = {
103
+ /** CDP tab session ID (pw-tab-*) to identify which tab to stop recording. */
98
104
  sessionId?: string
99
105
  }
100
106
 
101
107
  export type IsRecordingParams = {
108
+ /** CDP tab session ID (pw-tab-*) to identify which tab to check. */
102
109
  sessionId?: string
103
110
  }
104
111
 
105
112
  export type CancelRecordingParams = {
113
+ /** CDP tab session ID (pw-tab-*) to identify which tab to cancel. */
106
114
  sessionId?: string
107
115
  }
108
116
 
@@ -137,36 +145,42 @@ export type RecordingCommandMessage =
137
145
  | CancelRecordingMessage
138
146
 
139
147
  // Recording result types
140
- export type StartRecordingResult = {
141
- success: true
142
- tabId: number
143
- startedAt: number
144
- } | {
145
- success: false
146
- error: string
147
- }
148
+ export type StartRecordingResult =
149
+ | {
150
+ success: true
151
+ tabId: number
152
+ startedAt: number
153
+ }
154
+ | {
155
+ success: false
156
+ error: string
157
+ }
148
158
 
149
159
  /** Result from extension - doesn't include path/size since relay writes the file */
150
- export type ExtensionStopRecordingResult = {
151
- success: true
152
- tabId: number
153
- duration: number
154
- } | {
155
- success: false
156
- error: string
157
- }
160
+ export type ExtensionStopRecordingResult =
161
+ | {
162
+ success: true
163
+ tabId: number
164
+ duration: number
165
+ }
166
+ | {
167
+ success: false
168
+ error: string
169
+ }
158
170
 
159
171
  /** Final result from relay - includes path/size after file is written */
160
- export type StopRecordingResult = {
161
- success: true
162
- tabId: number
163
- duration: number
164
- path: string
165
- size: number
166
- } | {
167
- success: false
168
- error: string
169
- }
172
+ export type StopRecordingResult =
173
+ | {
174
+ success: true
175
+ tabId: number
176
+ duration: number
177
+ path: string
178
+ size: number
179
+ }
180
+ | {
181
+ success: false
182
+ error: string
183
+ }
170
184
 
171
185
  export type IsRecordingResult = {
172
186
  isRecording: boolean
@@ -193,10 +207,12 @@ export type GhostBrowserCommandMessage = {
193
207
  }
194
208
  }
195
209
 
196
- export type GhostBrowserCommandResult = {
197
- success: true
198
- result: unknown
199
- } | {
200
- success: false
201
- error: string
202
- }
210
+ export type GhostBrowserCommandResult =
211
+ | {
212
+ success: true
213
+ result: unknown
214
+ }
215
+ | {
216
+ success: false
217
+ error: string
218
+ }