playwriter 0.0.63 → 0.0.80

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 (216) hide show
  1. package/dist/aria-snapshot.d.ts +41 -3
  2. package/dist/aria-snapshot.d.ts.map +1 -1
  3. package/dist/aria-snapshot.js +131 -54
  4. package/dist/aria-snapshot.js.map +1 -1
  5. package/dist/aria-snapshot.test.js +5 -2
  6. package/dist/aria-snapshot.test.js.map +1 -1
  7. package/dist/aria-snapshot.unit.test.js +83 -41
  8. package/dist/aria-snapshot.unit.test.js.map +1 -1
  9. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  13. package/dist/bippy.js +1 -1
  14. package/dist/cdp-log.d.ts +1 -1
  15. package/dist/cdp-log.d.ts.map +1 -1
  16. package/dist/cdp-log.js +1 -1
  17. package/dist/cdp-log.js.map +1 -1
  18. package/dist/cdp-relay.d.ts.map +1 -1
  19. package/dist/cdp-relay.js +408 -298
  20. package/dist/cdp-relay.js.map +1 -1
  21. package/dist/cdp-session.d.ts.map +1 -1
  22. package/dist/cdp-session.js.map +1 -1
  23. package/dist/cdp-types.d.ts.map +1 -1
  24. package/dist/cdp-types.js +7 -7
  25. package/dist/cdp-types.js.map +1 -1
  26. package/dist/clean-html.d.ts.map +1 -1
  27. package/dist/clean-html.js +4 -5
  28. package/dist/clean-html.js.map +1 -1
  29. package/dist/cli.js +45 -27
  30. package/dist/cli.js.map +1 -1
  31. package/dist/create-logger.d.ts.map +1 -1
  32. package/dist/create-logger.js +3 -1
  33. package/dist/create-logger.js.map +1 -1
  34. package/dist/debugger-examples-types.d.ts.map +1 -1
  35. package/dist/debugger.d.ts.map +1 -1
  36. package/dist/debugger.js +1 -3
  37. package/dist/debugger.js.map +1 -1
  38. package/dist/diff-utils.d.ts.map +1 -1
  39. package/dist/diff-utils.js +1 -4
  40. package/dist/diff-utils.js.map +1 -1
  41. package/dist/editor-api.md +12 -2
  42. package/dist/editor-examples.d.ts +1 -1
  43. package/dist/editor-examples.d.ts.map +1 -1
  44. package/dist/editor-examples.js +1 -1
  45. package/dist/editor-examples.js.map +1 -1
  46. package/dist/editor.d.ts +1 -1
  47. package/dist/editor.d.ts.map +1 -1
  48. package/dist/editor.js +1 -1
  49. package/dist/editor.js.map +1 -1
  50. package/dist/executor.d.ts +26 -3
  51. package/dist/executor.d.ts.map +1 -1
  52. package/dist/executor.js +295 -64
  53. package/dist/executor.js.map +1 -1
  54. package/dist/executor.unit.test.js +38 -1
  55. package/dist/executor.unit.test.js.map +1 -1
  56. package/dist/extension-connection.test.js +139 -36
  57. package/dist/extension-connection.test.js.map +1 -1
  58. package/dist/ffmpeg.d.ts +148 -0
  59. package/dist/ffmpeg.d.ts.map +1 -0
  60. package/dist/ffmpeg.js +523 -0
  61. package/dist/ffmpeg.js.map +1 -0
  62. package/dist/ghost-browser.d.ts.map +1 -1
  63. package/dist/ghost-browser.js.map +1 -1
  64. package/dist/ghost-cursor-client.js +281 -0
  65. package/dist/ghost-cursor.d.ts +27 -0
  66. package/dist/ghost-cursor.d.ts.map +1 -0
  67. package/dist/ghost-cursor.js +63 -0
  68. package/dist/ghost-cursor.js.map +1 -0
  69. package/dist/htmlrewrite.d.ts.map +1 -1
  70. package/dist/htmlrewrite.js +17 -55
  71. package/dist/htmlrewrite.js.map +1 -1
  72. package/dist/htmlrewrite.test.js.map +1 -1
  73. package/dist/kill-port.d.ts.map +1 -1
  74. package/dist/kill-port.js +1 -3
  75. package/dist/kill-port.js.map +1 -1
  76. package/dist/locator-selector.test.d.ts +2 -0
  77. package/dist/locator-selector.test.d.ts.map +1 -0
  78. package/dist/locator-selector.test.js +96 -0
  79. package/dist/locator-selector.test.js.map +1 -0
  80. package/dist/mcp-client.js.map +1 -1
  81. package/dist/mcp.d.ts.map +1 -1
  82. package/dist/mcp.js +8 -3
  83. package/dist/mcp.js.map +1 -1
  84. package/dist/on-mouse-action.test.d.ts +2 -0
  85. package/dist/on-mouse-action.test.d.ts.map +1 -0
  86. package/dist/on-mouse-action.test.js +155 -0
  87. package/dist/on-mouse-action.test.js.map +1 -0
  88. package/dist/page-markdown.js +4 -4
  89. package/dist/page-markdown.js.map +1 -1
  90. package/dist/prompt.md +594 -255
  91. package/dist/protocol.d.ts +4 -0
  92. package/dist/protocol.d.ts.map +1 -1
  93. package/dist/readability.js +1 -1
  94. package/dist/recording-ghost-cursor.d.ts +41 -0
  95. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  96. package/dist/recording-ghost-cursor.js +79 -0
  97. package/dist/recording-ghost-cursor.js.map +1 -0
  98. package/dist/recording-relay.d.ts.map +1 -1
  99. package/dist/recording-relay.js +8 -8
  100. package/dist/recording-relay.js.map +1 -1
  101. package/dist/relay-client.d.ts +17 -4
  102. package/dist/relay-client.d.ts.map +1 -1
  103. package/dist/relay-client.js +44 -10
  104. package/dist/relay-client.js.map +1 -1
  105. package/dist/relay-core.test.d.ts.map +1 -1
  106. package/dist/relay-core.test.js +187 -26
  107. package/dist/relay-core.test.js.map +1 -1
  108. package/dist/relay-navigation.test.d.ts.map +1 -1
  109. package/dist/relay-navigation.test.js +54 -31
  110. package/dist/relay-navigation.test.js.map +1 -1
  111. package/dist/relay-session.test.d.ts.map +1 -1
  112. package/dist/relay-session.test.js +113 -65
  113. package/dist/relay-session.test.js.map +1 -1
  114. package/dist/relay-state.d.ts +158 -0
  115. package/dist/relay-state.d.ts.map +1 -0
  116. package/dist/relay-state.js +306 -0
  117. package/dist/relay-state.js.map +1 -0
  118. package/dist/relay-state.test.d.ts +2 -0
  119. package/dist/relay-state.test.d.ts.map +1 -0
  120. package/dist/relay-state.test.js +472 -0
  121. package/dist/relay-state.test.js.map +1 -0
  122. package/dist/scoped-fs.d.ts.map +1 -1
  123. package/dist/scoped-fs.js.map +1 -1
  124. package/dist/screen-recording.d.ts +42 -4
  125. package/dist/screen-recording.d.ts.map +1 -1
  126. package/dist/screen-recording.js +88 -13
  127. package/dist/screen-recording.js.map +1 -1
  128. package/dist/selector-generator.js +1 -1
  129. package/dist/snapshot-tools.test.js +71 -28
  130. package/dist/snapshot-tools.test.js.map +1 -1
  131. package/dist/start-relay-server.d.ts +1 -1
  132. package/dist/start-relay-server.d.ts.map +1 -1
  133. package/dist/start-relay-server.js +1 -1
  134. package/dist/start-relay-server.js.map +1 -1
  135. package/dist/styles-api.md +8 -1
  136. package/dist/styles-examples.d.ts +1 -1
  137. package/dist/styles-examples.d.ts.map +1 -1
  138. package/dist/styles-examples.js +1 -1
  139. package/dist/styles-examples.js.map +1 -1
  140. package/dist/styles.d.ts.map +1 -1
  141. package/dist/styles.js +1 -3
  142. package/dist/styles.js.map +1 -1
  143. package/dist/test-declarations.d.ts.map +1 -1
  144. package/dist/test-utils.d.ts +1 -1
  145. package/dist/test-utils.d.ts.map +1 -1
  146. package/dist/test-utils.js +7 -5
  147. package/dist/test-utils.js.map +1 -1
  148. package/dist/utils.d.ts.map +1 -1
  149. package/dist/utils.js.map +1 -1
  150. package/dist/wait-for-page-load.d.ts.map +1 -1
  151. package/dist/wait-for-page-load.js +1 -1
  152. package/dist/wait-for-page-load.js.map +1 -1
  153. package/package.json +4 -3
  154. package/src/a11y-client.ts +5 -4
  155. package/src/aria-snapshot.test.ts +5 -2
  156. package/src/aria-snapshot.ts +303 -116
  157. package/src/aria-snapshot.unit.test.ts +199 -141
  158. package/src/aria-snapshots/github-raw.txt +1 -1
  159. package/src/aria-snapshots/hackernews-interactive.txt +240 -240
  160. package/src/aria-snapshots/hackernews-raw.txt +270 -270
  161. package/src/assets/aria-labels-example.png +0 -0
  162. package/src/assets/aria-labels-github.png +0 -0
  163. package/src/assets/aria-labels-hacker-news.png +0 -0
  164. package/src/assets/aria-labels-old-reddit.png +0 -0
  165. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  166. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  167. package/src/cdp-log.ts +4 -1
  168. package/src/cdp-relay.ts +949 -737
  169. package/src/cdp-session.ts +12 -3
  170. package/src/cdp-types.ts +51 -51
  171. package/src/clean-html.ts +4 -5
  172. package/src/cli.ts +82 -55
  173. package/src/create-logger.ts +5 -3
  174. package/src/debugger-examples-types.ts +4 -1
  175. package/src/debugger.ts +1 -5
  176. package/src/diff-utils.ts +2 -5
  177. package/src/editor-examples.ts +11 -1
  178. package/src/editor.ts +10 -2
  179. package/src/executor.ts +372 -73
  180. package/src/executor.unit.test.ts +48 -1
  181. package/src/extension-connection.test.ts +612 -488
  182. package/src/ffmpeg.ts +769 -0
  183. package/src/ghost-browser.ts +4 -6
  184. package/src/ghost-cursor-client.ts +368 -0
  185. package/src/ghost-cursor.ts +110 -0
  186. package/src/htmlrewrite.test.ts +6 -2
  187. package/src/htmlrewrite.ts +348 -386
  188. package/src/kill-port.ts +1 -3
  189. package/src/locator-selector.test.ts +115 -0
  190. package/src/mcp-client.ts +1 -1
  191. package/src/mcp.ts +21 -15
  192. package/src/on-mouse-action.test.ts +196 -0
  193. package/src/page-markdown.ts +7 -7
  194. package/src/protocol.ts +73 -57
  195. package/src/recording-ghost-cursor.ts +107 -0
  196. package/src/recording-relay.ts +20 -12
  197. package/src/relay-client.ts +84 -17
  198. package/src/relay-core.test.ts +761 -583
  199. package/src/relay-navigation.test.ts +517 -484
  200. package/src/relay-session.test.ts +984 -929
  201. package/src/relay-state.test.ts +570 -0
  202. package/src/relay-state.ts +497 -0
  203. package/src/resource.md +21 -49
  204. package/src/scoped-fs.ts +9 -3
  205. package/src/screen-recording.ts +175 -31
  206. package/src/skill.md +619 -271
  207. package/src/snapshot-tools.test.ts +580 -528
  208. package/src/snapshots/shadcn-ui-accessibility-full.md +181 -183
  209. package/src/snapshots/shadcn-ui-accessibility-interactive.md +119 -121
  210. package/src/start-relay-server.ts +14 -11
  211. package/src/styles-examples.ts +8 -1
  212. package/src/styles.ts +20 -21
  213. package/src/test-declarations.ts +6 -6
  214. package/src/test-utils.ts +104 -91
  215. package/src/utils.ts +2 -1
  216. package/src/wait-for-page-load.ts +6 -1
package/src/executor.ts CHANGED
@@ -26,6 +26,7 @@ import { ScopedFS } from './scoped-fs.js'
26
26
  import {
27
27
  screenshotWithAccessibilityLabels,
28
28
  getAriaSnapshot,
29
+ resizeImage,
29
30
  type ScreenshotResult,
30
31
  type SnapshotFormat,
31
32
  } from './aria-snapshot.js'
@@ -33,7 +34,10 @@ import { createGhostBrowserChrome, type GhostBrowserCommandResult } from './ghos
33
34
  export type { SnapshotFormat }
34
35
  import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
35
36
  import { getPageMarkdown, type GetPageMarkdownOptions } from './page-markdown.js'
36
- import { startRecording, stopRecording, isRecording, cancelRecording } from './screen-recording.js'
37
+ import { createRecordingApi } from './screen-recording.js'
38
+ import { createDemoVideo } from './ffmpeg.js'
39
+ import { type GhostCursorClientOptions } from './ghost-cursor.js'
40
+ import { RecordingGhostCursorController } from './recording-ghost-cursor.js'
37
41
 
38
42
  const __filename = fileURLToPath(import.meta.url)
39
43
  const __dirname = path.dirname(__filename)
@@ -65,10 +69,11 @@ const usefulGlobals = {
65
69
  } as const
66
70
 
67
71
  /**
68
- * Determines if code should be auto-wrapped with `return await (...)`.
69
- * Returns true for single expression statements that aren't assignments.
72
+ * Parse code and check if it's a single expression that should be auto-returned.
73
+ * Returns the exact expression source (without trailing semicolon) using AST
74
+ * node offsets, or null if the code should not be auto-wrapped. See #58.
70
75
  */
71
- export function shouldAutoReturn(code: string): boolean {
76
+ export function getAutoReturnExpression(code: string): string | null {
72
77
  try {
73
78
  const ast = acorn.parse(code, {
74
79
  ecmaVersion: 'latest',
@@ -79,19 +84,19 @@ export function shouldAutoReturn(code: string): boolean {
79
84
 
80
85
  // Must be exactly one statement
81
86
  if (ast.body.length !== 1) {
82
- return false
87
+ return null
83
88
  }
84
89
 
85
90
  const stmt = ast.body[0]
86
91
 
87
92
  // If it's already a return statement, don't auto-wrap
88
93
  if (stmt.type === 'ReturnStatement') {
89
- return false
94
+ return null
90
95
  }
91
96
 
92
97
  // Must be an ExpressionStatement
93
98
  if (stmt.type !== 'ExpressionStatement') {
94
- return false
99
+ return null
95
100
  }
96
101
 
97
102
  // Don't auto-return side-effect expressions
@@ -101,24 +106,44 @@ export function shouldAutoReturn(code: string): boolean {
101
106
  expr.type === 'UpdateExpression' ||
102
107
  (expr.type === 'UnaryExpression' && (expr as acorn.UnaryExpression).operator === 'delete')
103
108
  ) {
104
- return false
109
+ return null
105
110
  }
106
111
 
107
112
  // Don't auto-return sequence expressions that contain assignments
108
113
  if (expr.type === 'SequenceExpression') {
109
114
  const hasAssignment = expr.expressions.some((e: acorn.Expression) => e.type === 'AssignmentExpression')
110
115
  if (hasAssignment) {
111
- return false
116
+ return null
112
117
  }
113
118
  }
114
119
 
115
- return true
120
+ // Use the expression node's start/end offsets to extract just the expression
121
+ // source, excluding any trailing semicolon. This is more robust than regex.
122
+ return code.slice(expr.start, expr.end)
116
123
  } catch {
117
124
  // Parse failed, don't auto-return
118
- return false
125
+ return null
119
126
  }
120
127
  }
121
128
 
129
+ /** Backward-compatible helper: returns true if code should be auto-wrapped. */
130
+ export function shouldAutoReturn(code: string): boolean {
131
+ return getAutoReturnExpression(code) !== null
132
+ }
133
+
134
+ /**
135
+ * Wraps user code in an async IIFE for vm execution.
136
+ * Uses AST node offsets to extract the expression without trailing semicolons,
137
+ * avoiding SyntaxError when embedding inside `return await (...)`. See #58.
138
+ */
139
+ export function wrapCode(code: string): string {
140
+ const expr = getAutoReturnExpression(code)
141
+ if (expr !== null) {
142
+ return `(async () => { return await (${expr}) })()`
143
+ }
144
+ return `(async () => { ${code} })()`
145
+ }
146
+
122
147
  const EXTENSION_NOT_CONNECTED_ERROR = `The Playwriter Chrome extension is not connected. Make sure you have:
123
148
  1. Installed the extension: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe
124
149
  2. Clicked the extension icon on a tab to enable it (or refreshed the page if just installed)`
@@ -173,6 +198,15 @@ export interface ExecuteResult {
173
198
  isError: boolean
174
199
  }
175
200
 
201
+ interface WarningEvent {
202
+ id: number
203
+ message: string
204
+ }
205
+
206
+ interface WarningScope {
207
+ cursor: number
208
+ }
209
+
176
210
  export interface ExecutorLogger {
177
211
  log(...args: any[]): void
178
212
  error(...args: any[]): void
@@ -217,8 +251,20 @@ export class PlaywrightExecutor {
217
251
 
218
252
  private userState: Record<string, any> = {}
219
253
  private browserLogs: Map<string, string[]> = new Map()
220
- private lastSnapshots: WeakMap<Page, string> = new WeakMap()
254
+ private lastSnapshots: WeakMap<Page, Map<string, string>> = new WeakMap()
221
255
  private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
256
+ private warningEvents: WarningEvent[] = []
257
+ private nextWarningEventId = 0
258
+ private lastDeliveredWarningEventId = 0
259
+
260
+ // Recording timestamp tracking: when recording is active, each execute()
261
+ // call pushes {start, end} (seconds relative to recordingStartedAt).
262
+ // Returned by stopRecording() so the model can speed up idle sections.
263
+ private recordingStartedAt: number | null = null
264
+ private executionTimestamps: Array<{ start: number; end: number }> = []
265
+ private activeWarningScopes = new Set<WarningScope>()
266
+ private pagesWithListeners = new WeakSet<Page>()
267
+ private suppressPageCloseWarnings = false
222
268
 
223
269
  private scopedFs: ScopedFS
224
270
  private sandboxedRequire: NodeRequire
@@ -273,16 +319,6 @@ export class PlaywrightExecutor {
273
319
  options.deviceScaleFactor = 2
274
320
  }
275
321
 
276
- private async preserveSystemColorScheme(context: BrowserContext): Promise<void> {
277
- const options = (context as any)._options
278
- if (!options) {
279
- return
280
- }
281
- options.colorScheme = 'no-override'
282
- options.reducedMotion = 'no-override'
283
- options.forcedColors = 'no-override'
284
- }
285
-
286
322
  private clearUserState() {
287
323
  Object.keys(this.userState).forEach((key) => delete this.userState[key])
288
324
  }
@@ -294,6 +330,49 @@ export class PlaywrightExecutor {
294
330
  this.context = null
295
331
  }
296
332
 
333
+ private enqueueWarning(message: string) {
334
+ this.nextWarningEventId += 1
335
+ this.warningEvents.push({ id: this.nextWarningEventId, message })
336
+ }
337
+
338
+ private beginWarningScope(): WarningScope {
339
+ const scope: WarningScope = {
340
+ cursor: this.nextWarningEventId,
341
+ }
342
+ this.activeWarningScopes.add(scope)
343
+ return scope
344
+ }
345
+
346
+ private flushWarningsForScope(scope: WarningScope): string {
347
+ const relevantWarnings = this.warningEvents.filter((warning) => {
348
+ return warning.id > scope.cursor
349
+ })
350
+ const latestWarningId = relevantWarnings.at(-1)?.id
351
+ if (latestWarningId && latestWarningId > this.lastDeliveredWarningEventId) {
352
+ this.lastDeliveredWarningEventId = latestWarningId
353
+ }
354
+
355
+ this.activeWarningScopes.delete(scope)
356
+ this.pruneDeliveredWarnings()
357
+
358
+ if (relevantWarnings.length === 0) {
359
+ return ''
360
+ }
361
+
362
+ return `${relevantWarnings.map((warning) => `[WARNING] ${warning.message}`).join('\n')}\n`
363
+ }
364
+
365
+ private pruneDeliveredWarnings() {
366
+ const activeCursors = [...this.activeWarningScopes].map((scope) => {
367
+ return scope.cursor
368
+ })
369
+ const minActiveCursor = activeCursors.length > 0 ? Math.min(...activeCursors) : this.lastDeliveredWarningEventId
370
+ const pruneBeforeOrAt = Math.min(this.lastDeliveredWarningEventId, minActiveCursor)
371
+ this.warningEvents = this.warningEvents.filter((warning) => {
372
+ return warning.id > pruneBeforeOrAt
373
+ })
374
+ }
375
+
297
376
  private warnIfExtensionOutdated(playwriterVersion: string | null) {
298
377
  if (this.hasWarnedExtensionOutdated) {
299
378
  return
@@ -305,9 +384,96 @@ export class PlaywrightExecutor {
305
384
  }
306
385
  }
307
386
 
387
+ private setupPageListeners(page: Page) {
388
+ if (this.pagesWithListeners.has(page)) {
389
+ return
390
+ }
391
+ this.pagesWithListeners.add(page)
392
+ this.setupPageCloseDetection(page)
393
+ this.setupPageConsoleListener(page)
394
+ this.setupPopupDetection(page)
395
+ }
396
+
397
+ private setupPageCloseDetection(page: Page) {
398
+ page.on('close', () => {
399
+ const stateKeysForClosedPage = Object.entries(this.userState)
400
+ .filter(([, value]) => {
401
+ return value === page
402
+ })
403
+ .map(([key]) => key)
404
+
405
+ const wasCurrentPage = this.page === page
406
+ let replacementPageInfo: { index: string; url: string } | null = null
407
+
408
+ if (wasCurrentPage) {
409
+ this.page = null
410
+ const context = this.context || page.context()
411
+ const openPages = context.pages().filter((candidate) => {
412
+ return !candidate.isClosed()
413
+ })
414
+ if (openPages.length > 0) {
415
+ const replacementPage = openPages[0]
416
+ this.page = replacementPage
417
+ const replacementIndex = context.pages().indexOf(replacementPage)
418
+ replacementPageInfo = {
419
+ index: replacementIndex >= 0 ? String(replacementIndex) : 'unknown',
420
+ url: replacementPage.url() || 'unknown',
421
+ }
422
+ }
423
+ }
424
+
425
+ if (!this.isConnected || this.suppressPageCloseWarnings || stateKeysForClosedPage.length === 0) {
426
+ return
427
+ }
428
+
429
+ const stateKeyLabel = stateKeysForClosedPage.map((key) => `state.${key}`).join(', ')
430
+ const closedUrl = page.url() || 'unknown'
431
+
432
+ if (!wasCurrentPage) {
433
+ this.enqueueWarning(
434
+ `Page closed (url: ${closedUrl}) for ${stateKeyLabel}. ` +
435
+ `Assign a new open page to ${stateKeyLabel} before reusing it.`,
436
+ )
437
+ return
438
+ }
439
+
440
+ if (replacementPageInfo) {
441
+ this.enqueueWarning(
442
+ `The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
443
+ `Switched active page to index ${replacementPageInfo.index} (url: ${replacementPageInfo.url}). ` +
444
+ `Reassign ${stateKeyLabel} before using it again.`,
445
+ )
446
+ return
447
+ }
448
+
449
+ this.enqueueWarning(
450
+ `The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
451
+ `No open pages remain. Open a tab with Playwriter enabled, then reassign ${stateKeyLabel}.`,
452
+ )
453
+ })
454
+ }
455
+
456
+ private setupPopupDetection(page: Page) {
457
+ // Listen for popup events (window.open, target=_blank) on each page.
458
+ // This is more reliable than checking page.opener() on context 'page' event,
459
+ // which also fires for context.newPage() and CDP reconnection scenarios.
460
+ page.on('popup', (popup) => {
461
+ const context = page.context()
462
+ const pages = context.pages()
463
+ const rawIndex = pages.indexOf(popup)
464
+ const pageIndex = rawIndex >= 0 ? String(rawIndex) : 'unknown'
465
+ const url = popup.url()
466
+ this.enqueueWarning(
467
+ `Popup window detected (page index ${pageIndex}, url: ${url}). ` +
468
+ `Popup windows cannot be controlled by playwriter. ` +
469
+ `Repeat the interaction in a way that does not open a popup, or navigate to the URL directly in a new tab.`,
470
+ )
471
+ })
472
+ }
473
+
308
474
  private setupPageConsoleListener(page: Page) {
309
475
  // Use targetId() if available, fallback to internal _guid for CDP connections
310
- const targetId = page.targetId() || (page as any)._guid as string | undefined
476
+ const targetId = page.targetId() || ((page as any)._guid as string | undefined)
311
477
  if (!targetId) {
312
478
  return
313
479
  }
@@ -343,7 +509,11 @@ export class PlaywrightExecutor {
343
509
  })
344
510
  }
345
511
 
346
- private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null }> {
512
+ private async checkExtensionStatus(): Promise<{
513
+ connected: boolean
514
+ activeTargets: number
515
+ playwriterVersion: string | null
516
+ }> {
347
517
  const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig
348
518
  const { httpBaseUrl } = parseRelayHost(host, port)
349
519
  const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }
@@ -359,10 +529,19 @@ export class PlaywrightExecutor {
359
529
  if (!fallback.ok) {
360
530
  return notConnected
361
531
  }
362
- return (await fallback.json()) as { connected: boolean; activeTargets: number; playwriterVersion: string | null }
532
+ return (await fallback.json()) as {
533
+ connected: boolean
534
+ activeTargets: number
535
+ playwriterVersion: string | null
536
+ }
363
537
  }
364
- const data = await response.json() as {
365
- extensions: Array<{ extensionId: string; stableKey?: string; activeTargets: number; playwriterVersion?: string | null }>
538
+ const data = (await response.json()) as {
539
+ extensions: Array<{
540
+ extensionId: string
541
+ stableKey?: string
542
+ activeTargets: number
543
+ playwriterVersion?: string | null
544
+ }>
366
545
  }
367
546
  const extension = data.extensions.find((item) => {
368
547
  return item.extensionId === extensionId || item.stableKey === extensionId
@@ -370,7 +549,11 @@ export class PlaywrightExecutor {
370
549
  if (!extension) {
371
550
  return notConnected
372
551
  }
373
- return { connected: true, activeTargets: extension.activeTargets, playwriterVersion: extension?.playwriterVersion || null }
552
+ return {
553
+ connected: true,
554
+ activeTargets: extension.activeTargets,
555
+ playwriterVersion: extension?.playwriterVersion || null,
556
+ }
374
557
  }
375
558
 
376
559
  const response = await fetch(`${httpBaseUrl}/extension/status`, {
@@ -409,14 +592,18 @@ export class PlaywrightExecutor {
409
592
  const contexts = browser.contexts()
410
593
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
411
594
 
595
+ // Action timeout (click, fill, hover, etc.) is short for fast agent failure.
596
+ // Navigation timeout (goto, reload) is longer since page loads are slower.
597
+ context.setDefaultTimeout(2000)
598
+ context.setDefaultNavigationTimeout(10000)
599
+
412
600
  context.on('page', (page) => {
413
- this.setupPageConsoleListener(page)
601
+ this.setupPageListeners(page)
414
602
  })
415
603
 
416
- context.pages().forEach((p) => this.setupPageConsoleListener(p))
604
+ context.pages().forEach((p) => this.setupPageListeners(p))
417
605
  const page = await this.ensurePageForContext({ context, timeout: 10000 })
418
606
 
419
- await this.preserveSystemColorScheme(context)
420
607
  await this.setDeviceScaleFactorForMacOS(context)
421
608
 
422
609
  this.browser = browser
@@ -455,10 +642,13 @@ export class PlaywrightExecutor {
455
642
 
456
643
  async reset(): Promise<{ page: Page; context: BrowserContext }> {
457
644
  if (this.browser) {
645
+ this.suppressPageCloseWarnings = true
458
646
  try {
459
647
  await this.browser.close()
460
648
  } catch (e) {
461
649
  this.logger.error('Error closing browser:', e)
650
+ } finally {
651
+ this.suppressPageCloseWarnings = false
462
652
  }
463
653
  }
464
654
 
@@ -484,14 +674,18 @@ export class PlaywrightExecutor {
484
674
  const contexts = browser.contexts()
485
675
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
486
676
 
677
+ // Action timeout (click, fill, hover, etc.) is short for fast agent failure.
678
+ // Navigation timeout (goto, reload) is longer since page loads are slower.
679
+ context.setDefaultTimeout(2000)
680
+ context.setDefaultNavigationTimeout(10000)
681
+
487
682
  context.on('page', (page) => {
488
- this.setupPageConsoleListener(page)
683
+ this.setupPageListeners(page)
489
684
  })
490
685
 
491
- context.pages().forEach((p) => this.setupPageConsoleListener(p))
686
+ context.pages().forEach((p) => this.setupPageListeners(p))
492
687
  const page = await this.ensurePageForContext({ context, timeout: 10000 })
493
688
 
494
- await this.preserveSystemColorScheme(context)
495
689
  await this.setDeviceScaleFactorForMacOS(context)
496
690
 
497
691
  this.browser = browser
@@ -504,6 +698,7 @@ export class PlaywrightExecutor {
504
698
 
505
699
  async execute(code: string, timeout = 10000): Promise<ExecuteResult> {
506
700
  const consoleLogs: Array<{ method: string; args: any[] }> = []
701
+ const warningScope = this.beginWarningScope()
507
702
 
508
703
  const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => {
509
704
  if (logs.length === 0) {
@@ -514,7 +709,13 @@ export class PlaywrightExecutor {
514
709
  const formattedArgs = args
515
710
  .map((arg) => {
516
711
  if (typeof arg === 'string') return arg
517
- return util.inspect(arg, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 })
712
+ return util.inspect(arg, {
713
+ depth: 4,
714
+ colors: false,
715
+ maxArrayLength: 100,
716
+ maxStringLength: 1000,
717
+ breakLength: 80,
718
+ })
518
719
  })
519
720
  .join(' ')
520
721
  text += `[${method}] ${formattedArgs}\n`
@@ -526,7 +727,6 @@ export class PlaywrightExecutor {
526
727
  await this.ensureConnection()
527
728
  const page = await this.getCurrentPage(timeout)
528
729
  const context = this.context || page.context()
529
- context.setDefaultTimeout(timeout)
530
730
 
531
731
  this.logger.log('Executing code:', code)
532
732
 
@@ -548,7 +748,7 @@ export class PlaywrightExecutor {
548
748
  },
549
749
  }
550
750
 
551
- const accessibilitySnapshot = async (options: {
751
+ const snapshot = async (options: {
552
752
  page?: Page
553
753
  /** Optional frame to scope the snapshot (e.g. from iframe.contentFrame() or page.frames()) */
554
754
  frame?: Frame | FrameLocator
@@ -561,14 +761,25 @@ export class PlaywrightExecutor {
561
761
  /** Only include interactive elements (default: true) */
562
762
  interactiveOnly?: boolean
563
763
  }) => {
564
- const { page: targetPage, frame, locator, search, showDiffSinceLastCall = true, interactiveOnly = false } = options
764
+ const {
765
+ page: targetPage,
766
+ frame,
767
+ locator,
768
+ search,
769
+ showDiffSinceLastCall = !search,
770
+ interactiveOnly = false,
771
+ } = options
565
772
  const resolvedPage = targetPage || page
566
773
  if (!resolvedPage) {
567
- throw new Error('accessibilitySnapshot requires a page')
774
+ throw new Error('snapshot requires a page')
568
775
  }
569
776
 
570
777
  // Use new in-page implementation via getAriaSnapshot
571
- const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({
778
+ const {
779
+ snapshot: rawSnapshot,
780
+ refs,
781
+ getSelectorForRef,
782
+ } = await getAriaSnapshot({
572
783
  page: resolvedPage,
573
784
  frame,
574
785
  locator,
@@ -586,12 +797,20 @@ export class PlaywrightExecutor {
586
797
  this.lastRefToLocator.set(resolvedPage, refToLocator)
587
798
 
588
799
  const shouldCacheSnapshot = !frame
589
- const previousSnapshot = shouldCacheSnapshot ? this.lastSnapshots.get(resolvedPage) : undefined
800
+ // Cache keyed by locator selector so full-page and locator-scoped snapshots
801
+ // don't pollute each other's diff baselines
802
+ const snapshotKey = locator ? `locator:${locator.selector()}` : 'page'
803
+ let pageSnapshots = this.lastSnapshots.get(resolvedPage)
804
+ if (!pageSnapshots) {
805
+ pageSnapshots = new Map()
806
+ this.lastSnapshots.set(resolvedPage, pageSnapshots)
807
+ }
808
+ const previousSnapshot = shouldCacheSnapshot ? pageSnapshots.get(snapshotKey) : undefined
590
809
  if (shouldCacheSnapshot) {
591
- this.lastSnapshots.set(resolvedPage, snapshotStr)
810
+ pageSnapshots.set(snapshotKey, snapshotStr)
592
811
  }
593
812
 
594
- // Return diff if we have a previous snapshot and diff mode is enabled
813
+ // Diff defaults off when search is provided, but agent can explicitly enable both
595
814
  if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) {
596
815
  const diffResult = createSmartDiff({
597
816
  oldContent: previousSnapshot,
@@ -680,7 +899,7 @@ export class PlaywrightExecutor {
680
899
 
681
900
  if (filterPage) {
682
901
  // Use targetId() if available, fallback to internal _guid for CDP connections
683
- const targetId = filterPage.targetId() || (filterPage as any)._guid as string | undefined
902
+ const targetId = filterPage.targetId() || ((filterPage as any)._guid as string | undefined)
684
903
  if (!targetId) {
685
904
  throw new Error('Could not get page targetId')
686
905
  }
@@ -770,18 +989,51 @@ export class PlaywrightExecutor {
770
989
  // Recording uses chrome.tabCapture which requires activeTab permission.
771
990
  // This permission is granted when the user clicks the Playwriter extension icon on a tab.
772
991
  const relayPort = this.cdpConfig.port || 19988
773
- // Recording will work on any tab where the user has clicked the icon.
774
- const withRecordingDefaults = <T extends { page?: Page; sessionId?: string }, R>(
775
- fn: (opts: T & { relayPort: number; sessionId?: string }) => Promise<R>,
776
- ) => {
777
- return async (options: T = {} as T) => {
778
- const targetPage = options.page || page
779
- // Use Playwright's exposed sessionId directly
780
- const sessionId = options.sessionId || targetPage.sessionId() || undefined
781
- return fn({ page: targetPage, sessionId, relayPort, ...options })
782
- }
783
- }
784
992
  const self = this
993
+ const recordingGhostCursor = new RecordingGhostCursorController({
994
+ logger: {
995
+ error: (...args: unknown[]) => {
996
+ self.logger.error(...args)
997
+ },
998
+ },
999
+ })
1000
+
1001
+ const showGhostCursor = async (options?: ({ page?: Page } & GhostCursorClientOptions)) => {
1002
+ const targetPage = options?.page || page
1003
+ const cursorOptions: GhostCursorClientOptions | undefined = (() => {
1004
+ if (!options) {
1005
+ return undefined
1006
+ }
1007
+
1008
+ const { page: _ignoredPage, ...rest } = options
1009
+ return rest
1010
+ })()
1011
+
1012
+ await recordingGhostCursor.show({ page: targetPage, cursorOptions })
1013
+ }
1014
+
1015
+ const hideGhostCursor = async (options?: { page?: Page }) => {
1016
+ const targetPage = options?.page || page
1017
+ await recordingGhostCursor.hide({ page: targetPage })
1018
+ }
1019
+
1020
+ const recordingApi = createRecordingApi({
1021
+ context,
1022
+ defaultPage: page,
1023
+ relayPort,
1024
+ ghostCursorController: recordingGhostCursor,
1025
+ onStart: () => {
1026
+ self.recordingStartedAt = Date.now()
1027
+ self.executionTimestamps = []
1028
+ },
1029
+ onFinish: () => {
1030
+ self.recordingStartedAt = null
1031
+ self.executionTimestamps = []
1032
+ },
1033
+ getExecutionTimestamps: () => {
1034
+ return self.executionTimestamps
1035
+ },
1036
+ })
785
1037
 
786
1038
  // Ghost Browser API - creates chrome object that mirrors Ghost Browser's APIs
787
1039
  // See extension/src/ghost-browser-api.d.ts for full API documentation
@@ -800,7 +1052,8 @@ export class PlaywrightExecutor {
800
1052
  context,
801
1053
  state: this.userState,
802
1054
  console: customConsole,
803
- accessibilitySnapshot,
1055
+ snapshot,
1056
+ accessibilitySnapshot: snapshot, // backward compat alias
804
1057
  refToLocator,
805
1058
  getCleanHTML,
806
1059
  getPageMarkdown,
@@ -815,10 +1068,23 @@ export class PlaywrightExecutor {
815
1068
  formatStylesAsText,
816
1069
  getReactSource: getReactSourceFn,
817
1070
  screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
818
- startRecording: withRecordingDefaults(startRecording),
819
- stopRecording: withRecordingDefaults(stopRecording),
820
- isRecording: withRecordingDefaults(isRecording),
821
- cancelRecording: withRecordingDefaults(cancelRecording),
1071
+ resizeImage,
1072
+ ghostCursor: {
1073
+ show: showGhostCursor,
1074
+ hide: hideGhostCursor,
1075
+ },
1076
+ recording: {
1077
+ start: recordingApi.start,
1078
+ stop: recordingApi.stop,
1079
+ isRecording: recordingApi.isRecording,
1080
+ cancel: recordingApi.cancel,
1081
+ },
1082
+ // Backward-compatible aliases
1083
+ startRecording: recordingApi.start,
1084
+ stopRecording: recordingApi.stop,
1085
+ isRecording: recordingApi.isRecording,
1086
+ cancelRecording: recordingApi.cancel,
1087
+ createDemoVideo,
822
1088
  resetPlaywright: async () => {
823
1089
  const { page: newPage, context: newContext } = await self.reset()
824
1090
  vmContextObj.page = newPage
@@ -833,16 +1099,37 @@ export class PlaywrightExecutor {
833
1099
  }
834
1100
 
835
1101
  const vmContext = vm.createContext(vmContextObj)
836
- const autoReturn = shouldAutoReturn(code)
837
- const wrappedCode = autoReturn
838
- ? `(async () => { return await (${code}) })()`
1102
+ const autoReturnExpr = getAutoReturnExpression(code)
1103
+ const wrappedCode = autoReturnExpr !== null
1104
+ ? `(async () => { return await (${autoReturnExpr}) })()`
839
1105
  : `(async () => { ${code} })()`
840
- const hasExplicitReturn = autoReturn || /\breturn\b/.test(code)
841
-
842
- const result = await Promise.race([
843
- vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }),
844
- new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
845
- ])
1106
+ const hasExplicitReturn = autoReturnExpr !== null || /\breturn\b/.test(code)
1107
+
1108
+ // Track execution timestamps relative to recording start (seconds).
1109
+ // Used to identify idle gaps that can be sped up in demo videos.
1110
+ // Captured before execution so we can record timing even if it throws.
1111
+ const recordingStartSnapshot = this.recordingStartedAt
1112
+ const execStartSec = recordingStartSnapshot !== null
1113
+ ? (Date.now() - recordingStartSnapshot) / 1000
1114
+ : -1
1115
+
1116
+ const result = await (async () => {
1117
+ try {
1118
+ return await Promise.race([
1119
+ vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }),
1120
+ new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
1121
+ ])
1122
+ } finally {
1123
+ // Record timestamp even on error — the execution still occupied real time
1124
+ // that should not be sped up in the demo video.
1125
+ // Compare against snapshot to avoid cross-session contamination if
1126
+ // recording was stopped and restarted inside the same execute() call.
1127
+ if (recordingStartSnapshot !== null && execStartSec >= 0 && this.recordingStartedAt === recordingStartSnapshot) {
1128
+ const execEndSec = (Date.now() - recordingStartSnapshot) / 1000
1129
+ this.executionTimestamps.push({ start: execStartSec, end: execEndSec })
1130
+ }
1131
+ }
1132
+ })()
846
1133
 
847
1134
  let responseText = formatConsoleLogs(consoleLogs)
848
1135
 
@@ -853,13 +1140,21 @@ export class PlaywrightExecutor {
853
1140
  const formatted =
854
1141
  typeof resolvedResult === 'string'
855
1142
  ? resolvedResult
856
- : util.inspect(resolvedResult, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 })
1143
+ : util.inspect(resolvedResult, {
1144
+ depth: 4,
1145
+ colors: false,
1146
+ maxArrayLength: 100,
1147
+ maxStringLength: 1000,
1148
+ breakLength: 80,
1149
+ })
857
1150
  if (formatted.trim()) {
858
1151
  responseText += `[return value] ${formatted}\n`
859
1152
  }
860
1153
  }
861
1154
  }
862
1155
 
1156
+ responseText += this.flushWarningsForScope(warningScope)
1157
+
863
1158
  if (!responseText.trim()) {
864
1159
  responseText = 'Code executed successfully (no output)'
865
1160
  }
@@ -883,17 +1178,21 @@ export class PlaywrightExecutor {
883
1178
  return { text: finalText, images, isError: false }
884
1179
  } catch (error: any) {
885
1180
  const errorStack = error.stack || error.message
886
- const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError'
1181
+ const isTimeoutError =
1182
+ error instanceof CodeExecutionTimeoutError || error?.name === 'TimeoutError' || error?.name === 'AbortError'
887
1183
 
888
1184
  this.logger.error('Error in execute:', errorStack)
889
1185
 
890
1186
  const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
1187
+ const warningText = this.flushWarningsForScope(warningScope)
891
1188
  const resetHint = isTimeoutError
892
1189
  ? ''
893
1190
  : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]'
894
1191
 
1192
+ // timeout stacks are internal noise (Promise.race / setTimeout); only show the message
1193
+ const errorText = isTimeoutError ? error.message : errorStack
895
1194
  return {
896
- text: `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`,
1195
+ text: `${logsText}${warningText}\nError executing code: ${errorText}${resetHint}`,
897
1196
  images: [],
898
1197
  isError: true,
899
1198
  }
@@ -927,7 +1226,7 @@ export class PlaywrightExecutor {
927
1226
  }
928
1227
 
929
1228
  const page = await context.newPage()
930
- this.setupPageConsoleListener(page)
1229
+ this.setupPageListeners(page)
931
1230
  const pageUrl = page.url()
932
1231
  if (pageUrl === 'about:blank') {
933
1232
  return page