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/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,19 @@ 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 longer to tolerate slower
596
+ // SPA/Turbo navigations and post-click settling on real sites.
597
+ // Navigation timeout (goto, reload) remains separate.
598
+ context.setDefaultTimeout(60000)
599
+ context.setDefaultNavigationTimeout(10000)
600
+
412
601
  context.on('page', (page) => {
413
- this.setupPageConsoleListener(page)
602
+ this.setupPageListeners(page)
414
603
  })
415
604
 
416
- context.pages().forEach((p) => this.setupPageConsoleListener(p))
605
+ context.pages().forEach((p) => this.setupPageListeners(p))
417
606
  const page = await this.ensurePageForContext({ context, timeout: 10000 })
418
607
 
419
- await this.preserveSystemColorScheme(context)
420
608
  await this.setDeviceScaleFactorForMacOS(context)
421
609
 
422
610
  this.browser = browser
@@ -455,10 +643,13 @@ export class PlaywrightExecutor {
455
643
 
456
644
  async reset(): Promise<{ page: Page; context: BrowserContext }> {
457
645
  if (this.browser) {
646
+ this.suppressPageCloseWarnings = true
458
647
  try {
459
648
  await this.browser.close()
460
649
  } catch (e) {
461
650
  this.logger.error('Error closing browser:', e)
651
+ } finally {
652
+ this.suppressPageCloseWarnings = false
462
653
  }
463
654
  }
464
655
 
@@ -484,14 +675,19 @@ export class PlaywrightExecutor {
484
675
  const contexts = browser.contexts()
485
676
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
486
677
 
678
+ // Action timeout (click, fill, hover, etc.) is longer to tolerate slower
679
+ // SPA/Turbo navigations and post-click settling on real sites.
680
+ // Navigation timeout (goto, reload) remains separate.
681
+ context.setDefaultTimeout(60000)
682
+ context.setDefaultNavigationTimeout(10000)
683
+
487
684
  context.on('page', (page) => {
488
- this.setupPageConsoleListener(page)
685
+ this.setupPageListeners(page)
489
686
  })
490
687
 
491
- context.pages().forEach((p) => this.setupPageConsoleListener(p))
688
+ context.pages().forEach((p) => this.setupPageListeners(p))
492
689
  const page = await this.ensurePageForContext({ context, timeout: 10000 })
493
690
 
494
- await this.preserveSystemColorScheme(context)
495
691
  await this.setDeviceScaleFactorForMacOS(context)
496
692
 
497
693
  this.browser = browser
@@ -504,6 +700,7 @@ export class PlaywrightExecutor {
504
700
 
505
701
  async execute(code: string, timeout = 10000): Promise<ExecuteResult> {
506
702
  const consoleLogs: Array<{ method: string; args: any[] }> = []
703
+ const warningScope = this.beginWarningScope()
507
704
 
508
705
  const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => {
509
706
  if (logs.length === 0) {
@@ -514,7 +711,13 @@ export class PlaywrightExecutor {
514
711
  const formattedArgs = args
515
712
  .map((arg) => {
516
713
  if (typeof arg === 'string') return arg
517
- return util.inspect(arg, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 })
714
+ return util.inspect(arg, {
715
+ depth: 4,
716
+ colors: false,
717
+ maxArrayLength: 100,
718
+ maxStringLength: 1000,
719
+ breakLength: 80,
720
+ })
518
721
  })
519
722
  .join(' ')
520
723
  text += `[${method}] ${formattedArgs}\n`
@@ -526,7 +729,6 @@ export class PlaywrightExecutor {
526
729
  await this.ensureConnection()
527
730
  const page = await this.getCurrentPage(timeout)
528
731
  const context = this.context || page.context()
529
- context.setDefaultTimeout(timeout)
530
732
 
531
733
  this.logger.log('Executing code:', code)
532
734
 
@@ -548,7 +750,7 @@ export class PlaywrightExecutor {
548
750
  },
549
751
  }
550
752
 
551
- const accessibilitySnapshot = async (options: {
753
+ const snapshot = async (options: {
552
754
  page?: Page
553
755
  /** Optional frame to scope the snapshot (e.g. from iframe.contentFrame() or page.frames()) */
554
756
  frame?: Frame | FrameLocator
@@ -561,14 +763,25 @@ export class PlaywrightExecutor {
561
763
  /** Only include interactive elements (default: true) */
562
764
  interactiveOnly?: boolean
563
765
  }) => {
564
- const { page: targetPage, frame, locator, search, showDiffSinceLastCall = true, interactiveOnly = false } = options
766
+ const {
767
+ page: targetPage,
768
+ frame,
769
+ locator,
770
+ search,
771
+ showDiffSinceLastCall = !search,
772
+ interactiveOnly = false,
773
+ } = options
565
774
  const resolvedPage = targetPage || page
566
775
  if (!resolvedPage) {
567
- throw new Error('accessibilitySnapshot requires a page')
776
+ throw new Error('snapshot requires a page')
568
777
  }
569
778
 
570
779
  // Use new in-page implementation via getAriaSnapshot
571
- const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({
780
+ const {
781
+ snapshot: rawSnapshot,
782
+ refs,
783
+ getSelectorForRef,
784
+ } = await getAriaSnapshot({
572
785
  page: resolvedPage,
573
786
  frame,
574
787
  locator,
@@ -586,12 +799,20 @@ export class PlaywrightExecutor {
586
799
  this.lastRefToLocator.set(resolvedPage, refToLocator)
587
800
 
588
801
  const shouldCacheSnapshot = !frame
589
- const previousSnapshot = shouldCacheSnapshot ? this.lastSnapshots.get(resolvedPage) : undefined
802
+ // Cache keyed by locator selector so full-page and locator-scoped snapshots
803
+ // don't pollute each other's diff baselines
804
+ const snapshotKey = locator ? `locator:${locator.selector()}` : 'page'
805
+ let pageSnapshots = this.lastSnapshots.get(resolvedPage)
806
+ if (!pageSnapshots) {
807
+ pageSnapshots = new Map()
808
+ this.lastSnapshots.set(resolvedPage, pageSnapshots)
809
+ }
810
+ const previousSnapshot = shouldCacheSnapshot ? pageSnapshots.get(snapshotKey) : undefined
590
811
  if (shouldCacheSnapshot) {
591
- this.lastSnapshots.set(resolvedPage, snapshotStr)
812
+ pageSnapshots.set(snapshotKey, snapshotStr)
592
813
  }
593
814
 
594
- // Return diff if we have a previous snapshot and diff mode is enabled
815
+ // Diff defaults off when search is provided, but agent can explicitly enable both
595
816
  if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) {
596
817
  const diffResult = createSmartDiff({
597
818
  oldContent: previousSnapshot,
@@ -680,7 +901,7 @@ export class PlaywrightExecutor {
680
901
 
681
902
  if (filterPage) {
682
903
  // Use targetId() if available, fallback to internal _guid for CDP connections
683
- const targetId = filterPage.targetId() || (filterPage as any)._guid as string | undefined
904
+ const targetId = filterPage.targetId() || ((filterPage as any)._guid as string | undefined)
684
905
  if (!targetId) {
685
906
  throw new Error('Could not get page targetId')
686
907
  }
@@ -770,18 +991,51 @@ export class PlaywrightExecutor {
770
991
  // Recording uses chrome.tabCapture which requires activeTab permission.
771
992
  // This permission is granted when the user clicks the Playwriter extension icon on a tab.
772
993
  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
994
  const self = this
995
+ const recordingGhostCursor = new RecordingGhostCursorController({
996
+ logger: {
997
+ error: (...args: unknown[]) => {
998
+ self.logger.error(...args)
999
+ },
1000
+ },
1001
+ })
1002
+
1003
+ const showGhostCursor = async (options?: ({ page?: Page } & GhostCursorClientOptions)) => {
1004
+ const targetPage = options?.page || page
1005
+ const cursorOptions: GhostCursorClientOptions | undefined = (() => {
1006
+ if (!options) {
1007
+ return undefined
1008
+ }
1009
+
1010
+ const { page: _ignoredPage, ...rest } = options
1011
+ return rest
1012
+ })()
1013
+
1014
+ await recordingGhostCursor.show({ page: targetPage, cursorOptions })
1015
+ }
1016
+
1017
+ const hideGhostCursor = async (options?: { page?: Page }) => {
1018
+ const targetPage = options?.page || page
1019
+ await recordingGhostCursor.hide({ page: targetPage })
1020
+ }
1021
+
1022
+ const recordingApi = createRecordingApi({
1023
+ context,
1024
+ defaultPage: page,
1025
+ relayPort,
1026
+ ghostCursorController: recordingGhostCursor,
1027
+ onStart: () => {
1028
+ self.recordingStartedAt = Date.now()
1029
+ self.executionTimestamps = []
1030
+ },
1031
+ onFinish: () => {
1032
+ self.recordingStartedAt = null
1033
+ self.executionTimestamps = []
1034
+ },
1035
+ getExecutionTimestamps: () => {
1036
+ return self.executionTimestamps
1037
+ },
1038
+ })
785
1039
 
786
1040
  // Ghost Browser API - creates chrome object that mirrors Ghost Browser's APIs
787
1041
  // See extension/src/ghost-browser-api.d.ts for full API documentation
@@ -800,7 +1054,8 @@ export class PlaywrightExecutor {
800
1054
  context,
801
1055
  state: this.userState,
802
1056
  console: customConsole,
803
- accessibilitySnapshot,
1057
+ snapshot,
1058
+ accessibilitySnapshot: snapshot, // backward compat alias
804
1059
  refToLocator,
805
1060
  getCleanHTML,
806
1061
  getPageMarkdown,
@@ -815,10 +1070,23 @@ export class PlaywrightExecutor {
815
1070
  formatStylesAsText,
816
1071
  getReactSource: getReactSourceFn,
817
1072
  screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
818
- startRecording: withRecordingDefaults(startRecording),
819
- stopRecording: withRecordingDefaults(stopRecording),
820
- isRecording: withRecordingDefaults(isRecording),
821
- cancelRecording: withRecordingDefaults(cancelRecording),
1073
+ resizeImage,
1074
+ ghostCursor: {
1075
+ show: showGhostCursor,
1076
+ hide: hideGhostCursor,
1077
+ },
1078
+ recording: {
1079
+ start: recordingApi.start,
1080
+ stop: recordingApi.stop,
1081
+ isRecording: recordingApi.isRecording,
1082
+ cancel: recordingApi.cancel,
1083
+ },
1084
+ // Backward-compatible aliases
1085
+ startRecording: recordingApi.start,
1086
+ stopRecording: recordingApi.stop,
1087
+ isRecording: recordingApi.isRecording,
1088
+ cancelRecording: recordingApi.cancel,
1089
+ createDemoVideo,
822
1090
  resetPlaywright: async () => {
823
1091
  const { page: newPage, context: newContext } = await self.reset()
824
1092
  vmContextObj.page = newPage
@@ -833,16 +1101,37 @@ export class PlaywrightExecutor {
833
1101
  }
834
1102
 
835
1103
  const vmContext = vm.createContext(vmContextObj)
836
- const autoReturn = shouldAutoReturn(code)
837
- const wrappedCode = autoReturn
838
- ? `(async () => { return await (${code}) })()`
1104
+ const autoReturnExpr = getAutoReturnExpression(code)
1105
+ const wrappedCode = autoReturnExpr !== null
1106
+ ? `(async () => { return await (${autoReturnExpr}) })()`
839
1107
  : `(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
- ])
1108
+ const hasExplicitReturn = autoReturnExpr !== null || /\breturn\b/.test(code)
1109
+
1110
+ // Track execution timestamps relative to recording start (seconds).
1111
+ // Used to identify idle gaps that can be sped up in demo videos.
1112
+ // Captured before execution so we can record timing even if it throws.
1113
+ const recordingStartSnapshot = this.recordingStartedAt
1114
+ const execStartSec = recordingStartSnapshot !== null
1115
+ ? (Date.now() - recordingStartSnapshot) / 1000
1116
+ : -1
1117
+
1118
+ const result = await (async () => {
1119
+ try {
1120
+ return await Promise.race([
1121
+ vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }),
1122
+ new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
1123
+ ])
1124
+ } finally {
1125
+ // Record timestamp even on error — the execution still occupied real time
1126
+ // that should not be sped up in the demo video.
1127
+ // Compare against snapshot to avoid cross-session contamination if
1128
+ // recording was stopped and restarted inside the same execute() call.
1129
+ if (recordingStartSnapshot !== null && execStartSec >= 0 && this.recordingStartedAt === recordingStartSnapshot) {
1130
+ const execEndSec = (Date.now() - recordingStartSnapshot) / 1000
1131
+ this.executionTimestamps.push({ start: execStartSec, end: execEndSec })
1132
+ }
1133
+ }
1134
+ })()
846
1135
 
847
1136
  let responseText = formatConsoleLogs(consoleLogs)
848
1137
 
@@ -853,13 +1142,21 @@ export class PlaywrightExecutor {
853
1142
  const formatted =
854
1143
  typeof resolvedResult === 'string'
855
1144
  ? resolvedResult
856
- : util.inspect(resolvedResult, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 })
1145
+ : util.inspect(resolvedResult, {
1146
+ depth: 4,
1147
+ colors: false,
1148
+ maxArrayLength: 100,
1149
+ maxStringLength: 1000,
1150
+ breakLength: 80,
1151
+ })
857
1152
  if (formatted.trim()) {
858
1153
  responseText += `[return value] ${formatted}\n`
859
1154
  }
860
1155
  }
861
1156
  }
862
1157
 
1158
+ responseText += this.flushWarningsForScope(warningScope)
1159
+
863
1160
  if (!responseText.trim()) {
864
1161
  responseText = 'Code executed successfully (no output)'
865
1162
  }
@@ -883,17 +1180,21 @@ export class PlaywrightExecutor {
883
1180
  return { text: finalText, images, isError: false }
884
1181
  } catch (error: any) {
885
1182
  const errorStack = error.stack || error.message
886
- const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError'
1183
+ const isTimeoutError =
1184
+ error instanceof CodeExecutionTimeoutError || error?.name === 'TimeoutError' || error?.name === 'AbortError'
887
1185
 
888
1186
  this.logger.error('Error in execute:', errorStack)
889
1187
 
890
1188
  const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
1189
+ const warningText = this.flushWarningsForScope(warningScope)
891
1190
  const resetHint = isTimeoutError
892
1191
  ? ''
893
1192
  : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]'
894
1193
 
1194
+ // timeout stacks are internal noise (Promise.race / setTimeout); only show the message
1195
+ const errorText = isTimeoutError ? error.message : errorStack
895
1196
  return {
896
- text: `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`,
1197
+ text: `${logsText}${warningText}\nError executing code: ${errorText}${resetHint}`,
897
1198
  images: [],
898
1199
  isError: true,
899
1200
  }
@@ -927,7 +1228,7 @@ export class PlaywrightExecutor {
927
1228
  }
928
1229
 
929
1230
  const page = await context.newPage()
930
- this.setupPageConsoleListener(page)
1231
+ this.setupPageListeners(page)
931
1232
  const pageUrl = page.url()
932
1233
  if (pageUrl === 'about:blank') {
933
1234
  return page