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/dist/executor.js CHANGED
@@ -22,11 +22,13 @@ import { Editor } from './editor.js';
22
22
  import { getStylesForLocator, formatStylesAsText } from './styles.js';
23
23
  import { getReactSource } from './react-source.js';
24
24
  import { ScopedFS } from './scoped-fs.js';
25
- import { screenshotWithAccessibilityLabels, getAriaSnapshot, } from './aria-snapshot.js';
25
+ import { screenshotWithAccessibilityLabels, getAriaSnapshot, resizeImage, } from './aria-snapshot.js';
26
26
  import { createGhostBrowserChrome } from './ghost-browser.js';
27
27
  import { getCleanHTML } from './clean-html.js';
28
28
  import { getPageMarkdown } from './page-markdown.js';
29
- import { startRecording, stopRecording, isRecording, cancelRecording } from './screen-recording.js';
29
+ import { createRecordingApi } from './screen-recording.js';
30
+ import { createDemoVideo } from './ffmpeg.js';
31
+ import { RecordingGhostCursorController } from './recording-ghost-cursor.js';
30
32
  const __filename = fileURLToPath(import.meta.url);
31
33
  const __dirname = path.dirname(__filename);
32
34
  const require = createRequire(import.meta.url);
@@ -53,10 +55,11 @@ const usefulGlobals = {
53
55
  structuredClone,
54
56
  };
55
57
  /**
56
- * Determines if code should be auto-wrapped with `return await (...)`.
57
- * Returns true for single expression statements that aren't assignments.
58
+ * Parse code and check if it's a single expression that should be auto-returned.
59
+ * Returns the exact expression source (without trailing semicolon) using AST
60
+ * node offsets, or null if the code should not be auto-wrapped. See #58.
58
61
  */
59
- export function shouldAutoReturn(code) {
62
+ export function getAutoReturnExpression(code) {
60
63
  try {
61
64
  const ast = acorn.parse(code, {
62
65
  ecmaVersion: 'latest',
@@ -66,37 +69,55 @@ export function shouldAutoReturn(code) {
66
69
  });
67
70
  // Must be exactly one statement
68
71
  if (ast.body.length !== 1) {
69
- return false;
72
+ return null;
70
73
  }
71
74
  const stmt = ast.body[0];
72
75
  // If it's already a return statement, don't auto-wrap
73
76
  if (stmt.type === 'ReturnStatement') {
74
- return false;
77
+ return null;
75
78
  }
76
79
  // Must be an ExpressionStatement
77
80
  if (stmt.type !== 'ExpressionStatement') {
78
- return false;
81
+ return null;
79
82
  }
80
83
  // Don't auto-return side-effect expressions
81
84
  const expr = stmt.expression;
82
85
  if (expr.type === 'AssignmentExpression' ||
83
86
  expr.type === 'UpdateExpression' ||
84
87
  (expr.type === 'UnaryExpression' && expr.operator === 'delete')) {
85
- return false;
88
+ return null;
86
89
  }
87
90
  // Don't auto-return sequence expressions that contain assignments
88
91
  if (expr.type === 'SequenceExpression') {
89
92
  const hasAssignment = expr.expressions.some((e) => e.type === 'AssignmentExpression');
90
93
  if (hasAssignment) {
91
- return false;
94
+ return null;
92
95
  }
93
96
  }
94
- return true;
97
+ // Use the expression node's start/end offsets to extract just the expression
98
+ // source, excluding any trailing semicolon. This is more robust than regex.
99
+ return code.slice(expr.start, expr.end);
95
100
  }
96
101
  catch {
97
102
  // Parse failed, don't auto-return
98
- return false;
103
+ return null;
104
+ }
105
+ }
106
+ /** Backward-compatible helper: returns true if code should be auto-wrapped. */
107
+ export function shouldAutoReturn(code) {
108
+ return getAutoReturnExpression(code) !== null;
109
+ }
110
+ /**
111
+ * Wraps user code in an async IIFE for vm execution.
112
+ * Uses AST node offsets to extract the expression without trailing semicolons,
113
+ * avoiding SyntaxError when embedding inside `return await (...)`. See #58.
114
+ */
115
+ export function wrapCode(code) {
116
+ const expr = getAutoReturnExpression(code);
117
+ if (expr !== null) {
118
+ return `(async () => { return await (${expr}) })()`;
99
119
  }
120
+ return `(async () => { ${code} })()`;
100
121
  }
101
122
  const EXTENSION_NOT_CONNECTED_ERROR = `The Playwriter Chrome extension is not connected. Make sure you have:
102
123
  1. Installed the extension: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe
@@ -156,6 +177,17 @@ export class PlaywrightExecutor {
156
177
  browserLogs = new Map();
157
178
  lastSnapshots = new WeakMap();
158
179
  lastRefToLocator = new WeakMap();
180
+ warningEvents = [];
181
+ nextWarningEventId = 0;
182
+ lastDeliveredWarningEventId = 0;
183
+ // Recording timestamp tracking: when recording is active, each execute()
184
+ // call pushes {start, end} (seconds relative to recordingStartedAt).
185
+ // Returned by stopRecording() so the model can speed up idle sections.
186
+ recordingStartedAt = null;
187
+ executionTimestamps = [];
188
+ activeWarningScopes = new Set();
189
+ pagesWithListeners = new WeakSet();
190
+ suppressPageCloseWarnings = false;
159
191
  scopedFs;
160
192
  sandboxedRequire;
161
193
  cdpConfig;
@@ -200,15 +232,6 @@ export class PlaywrightExecutor {
200
232
  }
201
233
  options.deviceScaleFactor = 2;
202
234
  }
203
- async preserveSystemColorScheme(context) {
204
- const options = context._options;
205
- if (!options) {
206
- return;
207
- }
208
- options.colorScheme = 'no-override';
209
- options.reducedMotion = 'no-override';
210
- options.forcedColors = 'no-override';
211
- }
212
235
  clearUserState() {
213
236
  Object.keys(this.userState).forEach((key) => delete this.userState[key]);
214
237
  }
@@ -218,6 +241,42 @@ export class PlaywrightExecutor {
218
241
  this.page = null;
219
242
  this.context = null;
220
243
  }
244
+ enqueueWarning(message) {
245
+ this.nextWarningEventId += 1;
246
+ this.warningEvents.push({ id: this.nextWarningEventId, message });
247
+ }
248
+ beginWarningScope() {
249
+ const scope = {
250
+ cursor: this.nextWarningEventId,
251
+ };
252
+ this.activeWarningScopes.add(scope);
253
+ return scope;
254
+ }
255
+ flushWarningsForScope(scope) {
256
+ const relevantWarnings = this.warningEvents.filter((warning) => {
257
+ return warning.id > scope.cursor;
258
+ });
259
+ const latestWarningId = relevantWarnings.at(-1)?.id;
260
+ if (latestWarningId && latestWarningId > this.lastDeliveredWarningEventId) {
261
+ this.lastDeliveredWarningEventId = latestWarningId;
262
+ }
263
+ this.activeWarningScopes.delete(scope);
264
+ this.pruneDeliveredWarnings();
265
+ if (relevantWarnings.length === 0) {
266
+ return '';
267
+ }
268
+ return `${relevantWarnings.map((warning) => `[WARNING] ${warning.message}`).join('\n')}\n`;
269
+ }
270
+ pruneDeliveredWarnings() {
271
+ const activeCursors = [...this.activeWarningScopes].map((scope) => {
272
+ return scope.cursor;
273
+ });
274
+ const minActiveCursor = activeCursors.length > 0 ? Math.min(...activeCursors) : this.lastDeliveredWarningEventId;
275
+ const pruneBeforeOrAt = Math.min(this.lastDeliveredWarningEventId, minActiveCursor);
276
+ this.warningEvents = this.warningEvents.filter((warning) => {
277
+ return warning.id > pruneBeforeOrAt;
278
+ });
279
+ }
221
280
  warnIfExtensionOutdated(playwriterVersion) {
222
281
  if (this.hasWarnedExtensionOutdated) {
223
282
  return;
@@ -228,6 +287,75 @@ export class PlaywrightExecutor {
228
287
  this.hasWarnedExtensionOutdated = true;
229
288
  }
230
289
  }
290
+ setupPageListeners(page) {
291
+ if (this.pagesWithListeners.has(page)) {
292
+ return;
293
+ }
294
+ this.pagesWithListeners.add(page);
295
+ this.setupPageCloseDetection(page);
296
+ this.setupPageConsoleListener(page);
297
+ this.setupPopupDetection(page);
298
+ }
299
+ setupPageCloseDetection(page) {
300
+ page.on('close', () => {
301
+ const stateKeysForClosedPage = Object.entries(this.userState)
302
+ .filter(([, value]) => {
303
+ return value === page;
304
+ })
305
+ .map(([key]) => key);
306
+ const wasCurrentPage = this.page === page;
307
+ let replacementPageInfo = null;
308
+ if (wasCurrentPage) {
309
+ this.page = null;
310
+ const context = this.context || page.context();
311
+ const openPages = context.pages().filter((candidate) => {
312
+ return !candidate.isClosed();
313
+ });
314
+ if (openPages.length > 0) {
315
+ const replacementPage = openPages[0];
316
+ this.page = replacementPage;
317
+ const replacementIndex = context.pages().indexOf(replacementPage);
318
+ replacementPageInfo = {
319
+ index: replacementIndex >= 0 ? String(replacementIndex) : 'unknown',
320
+ url: replacementPage.url() || 'unknown',
321
+ };
322
+ }
323
+ }
324
+ if (!this.isConnected || this.suppressPageCloseWarnings || stateKeysForClosedPage.length === 0) {
325
+ return;
326
+ }
327
+ const stateKeyLabel = stateKeysForClosedPage.map((key) => `state.${key}`).join(', ');
328
+ const closedUrl = page.url() || 'unknown';
329
+ if (!wasCurrentPage) {
330
+ this.enqueueWarning(`Page closed (url: ${closedUrl}) for ${stateKeyLabel}. ` +
331
+ `Assign a new open page to ${stateKeyLabel} before reusing it.`);
332
+ return;
333
+ }
334
+ if (replacementPageInfo) {
335
+ this.enqueueWarning(`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
336
+ `Switched active page to index ${replacementPageInfo.index} (url: ${replacementPageInfo.url}). ` +
337
+ `Reassign ${stateKeyLabel} before using it again.`);
338
+ return;
339
+ }
340
+ this.enqueueWarning(`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` +
341
+ `No open pages remain. Open a tab with Playwriter enabled, then reassign ${stateKeyLabel}.`);
342
+ });
343
+ }
344
+ setupPopupDetection(page) {
345
+ // Listen for popup events (window.open, target=_blank) on each page.
346
+ // This is more reliable than checking page.opener() on context 'page' event,
347
+ // which also fires for context.newPage() and CDP reconnection scenarios.
348
+ page.on('popup', (popup) => {
349
+ const context = page.context();
350
+ const pages = context.pages();
351
+ const rawIndex = pages.indexOf(popup);
352
+ const pageIndex = rawIndex >= 0 ? String(rawIndex) : 'unknown';
353
+ const url = popup.url();
354
+ this.enqueueWarning(`Popup window detected (page index ${pageIndex}, url: ${url}). ` +
355
+ `Popup windows cannot be controlled by playwriter. ` +
356
+ `Repeat the interaction in a way that does not open a popup, or navigate to the URL directly in a new tab.`);
357
+ });
358
+ }
231
359
  setupPageConsoleListener(page) {
232
360
  // Use targetId() if available, fallback to internal _guid for CDP connections
233
361
  const targetId = page.targetId() || page._guid;
@@ -280,14 +408,18 @@ export class PlaywrightExecutor {
280
408
  }
281
409
  return (await fallback.json());
282
410
  }
283
- const data = await response.json();
411
+ const data = (await response.json());
284
412
  const extension = data.extensions.find((item) => {
285
413
  return item.extensionId === extensionId || item.stableKey === extensionId;
286
414
  });
287
415
  if (!extension) {
288
416
  return notConnected;
289
417
  }
290
- return { connected: true, activeTargets: extension.activeTargets, playwriterVersion: extension?.playwriterVersion || null };
418
+ return {
419
+ connected: true,
420
+ activeTargets: extension.activeTargets,
421
+ playwriterVersion: extension?.playwriterVersion || null,
422
+ };
291
423
  }
292
424
  const response = await fetch(`${httpBaseUrl}/extension/status`, {
293
425
  signal: AbortSignal.timeout(2000),
@@ -320,12 +452,16 @@ export class PlaywrightExecutor {
320
452
  });
321
453
  const contexts = browser.contexts();
322
454
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
455
+ // Action timeout (click, fill, hover, etc.) is longer to tolerate slower
456
+ // SPA/Turbo navigations and post-click settling on real sites.
457
+ // Navigation timeout (goto, reload) remains separate.
458
+ context.setDefaultTimeout(60000);
459
+ context.setDefaultNavigationTimeout(10000);
323
460
  context.on('page', (page) => {
324
- this.setupPageConsoleListener(page);
461
+ this.setupPageListeners(page);
325
462
  });
326
- context.pages().forEach((p) => this.setupPageConsoleListener(p));
463
+ context.pages().forEach((p) => this.setupPageListeners(p));
327
464
  const page = await this.ensurePageForContext({ context, timeout: 10000 });
328
- await this.preserveSystemColorScheme(context);
329
465
  await this.setDeviceScaleFactorForMacOS(context);
330
466
  this.browser = browser;
331
467
  this.page = page;
@@ -358,12 +494,16 @@ export class PlaywrightExecutor {
358
494
  }
359
495
  async reset() {
360
496
  if (this.browser) {
497
+ this.suppressPageCloseWarnings = true;
361
498
  try {
362
499
  await this.browser.close();
363
500
  }
364
501
  catch (e) {
365
502
  this.logger.error('Error closing browser:', e);
366
503
  }
504
+ finally {
505
+ this.suppressPageCloseWarnings = false;
506
+ }
367
507
  }
368
508
  this.clearConnectionState();
369
509
  this.clearUserState();
@@ -382,12 +522,16 @@ export class PlaywrightExecutor {
382
522
  });
383
523
  const contexts = browser.contexts();
384
524
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
525
+ // Action timeout (click, fill, hover, etc.) is longer to tolerate slower
526
+ // SPA/Turbo navigations and post-click settling on real sites.
527
+ // Navigation timeout (goto, reload) remains separate.
528
+ context.setDefaultTimeout(60000);
529
+ context.setDefaultNavigationTimeout(10000);
385
530
  context.on('page', (page) => {
386
- this.setupPageConsoleListener(page);
531
+ this.setupPageListeners(page);
387
532
  });
388
- context.pages().forEach((p) => this.setupPageConsoleListener(p));
533
+ context.pages().forEach((p) => this.setupPageListeners(p));
389
534
  const page = await this.ensurePageForContext({ context, timeout: 10000 });
390
- await this.preserveSystemColorScheme(context);
391
535
  await this.setDeviceScaleFactorForMacOS(context);
392
536
  this.browser = browser;
393
537
  this.page = page;
@@ -397,6 +541,7 @@ export class PlaywrightExecutor {
397
541
  }
398
542
  async execute(code, timeout = 10000) {
399
543
  const consoleLogs = [];
544
+ const warningScope = this.beginWarningScope();
400
545
  const formatConsoleLogs = (logs, prefix = 'Console output') => {
401
546
  if (logs.length === 0) {
402
547
  return '';
@@ -407,7 +552,13 @@ export class PlaywrightExecutor {
407
552
  .map((arg) => {
408
553
  if (typeof arg === 'string')
409
554
  return arg;
410
- return util.inspect(arg, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 });
555
+ return util.inspect(arg, {
556
+ depth: 4,
557
+ colors: false,
558
+ maxArrayLength: 100,
559
+ maxStringLength: 1000,
560
+ breakLength: 80,
561
+ });
411
562
  })
412
563
  .join(' ');
413
564
  text += `[${method}] ${formattedArgs}\n`;
@@ -418,7 +569,6 @@ export class PlaywrightExecutor {
418
569
  await this.ensureConnection();
419
570
  const page = await this.getCurrentPage(timeout);
420
571
  const context = this.context || page.context();
421
- context.setDefaultTimeout(timeout);
422
572
  this.logger.log('Executing code:', code);
423
573
  const customConsole = {
424
574
  log: (...args) => {
@@ -437,14 +587,14 @@ export class PlaywrightExecutor {
437
587
  consoleLogs.push({ method: 'debug', args });
438
588
  },
439
589
  };
440
- const accessibilitySnapshot = async (options) => {
441
- const { page: targetPage, frame, locator, search, showDiffSinceLastCall = true, interactiveOnly = false } = options;
590
+ const snapshot = async (options) => {
591
+ const { page: targetPage, frame, locator, search, showDiffSinceLastCall = !search, interactiveOnly = false, } = options;
442
592
  const resolvedPage = targetPage || page;
443
593
  if (!resolvedPage) {
444
- throw new Error('accessibilitySnapshot requires a page');
594
+ throw new Error('snapshot requires a page');
445
595
  }
446
596
  // Use new in-page implementation via getAriaSnapshot
447
- const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({
597
+ const { snapshot: rawSnapshot, refs, getSelectorForRef, } = await getAriaSnapshot({
448
598
  page: resolvedPage,
449
599
  frame,
450
600
  locator,
@@ -460,11 +610,19 @@ export class PlaywrightExecutor {
460
610
  }
461
611
  this.lastRefToLocator.set(resolvedPage, refToLocator);
462
612
  const shouldCacheSnapshot = !frame;
463
- const previousSnapshot = shouldCacheSnapshot ? this.lastSnapshots.get(resolvedPage) : undefined;
613
+ // Cache keyed by locator selector so full-page and locator-scoped snapshots
614
+ // don't pollute each other's diff baselines
615
+ const snapshotKey = locator ? `locator:${locator.selector()}` : 'page';
616
+ let pageSnapshots = this.lastSnapshots.get(resolvedPage);
617
+ if (!pageSnapshots) {
618
+ pageSnapshots = new Map();
619
+ this.lastSnapshots.set(resolvedPage, pageSnapshots);
620
+ }
621
+ const previousSnapshot = shouldCacheSnapshot ? pageSnapshots.get(snapshotKey) : undefined;
464
622
  if (shouldCacheSnapshot) {
465
- this.lastSnapshots.set(resolvedPage, snapshotStr);
623
+ pageSnapshots.set(snapshotKey, snapshotStr);
466
624
  }
467
- // Return diff if we have a previous snapshot and diff mode is enabled
625
+ // Diff defaults off when search is provided, but agent can explicitly enable both
468
626
  if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) {
469
627
  const diffResult = createSmartDiff({
470
628
  oldContent: previousSnapshot,
@@ -625,16 +783,46 @@ export class PlaywrightExecutor {
625
783
  // Recording uses chrome.tabCapture which requires activeTab permission.
626
784
  // This permission is granted when the user clicks the Playwriter extension icon on a tab.
627
785
  const relayPort = this.cdpConfig.port || 19988;
628
- // Recording will work on any tab where the user has clicked the icon.
629
- const withRecordingDefaults = (fn) => {
630
- return async (options = {}) => {
631
- const targetPage = options.page || page;
632
- // Use Playwright's exposed sessionId directly
633
- const sessionId = options.sessionId || targetPage.sessionId() || undefined;
634
- return fn({ page: targetPage, sessionId, relayPort, ...options });
635
- };
636
- };
637
786
  const self = this;
787
+ const recordingGhostCursor = new RecordingGhostCursorController({
788
+ logger: {
789
+ error: (...args) => {
790
+ self.logger.error(...args);
791
+ },
792
+ },
793
+ });
794
+ const showGhostCursor = async (options) => {
795
+ const targetPage = options?.page || page;
796
+ const cursorOptions = (() => {
797
+ if (!options) {
798
+ return undefined;
799
+ }
800
+ const { page: _ignoredPage, ...rest } = options;
801
+ return rest;
802
+ })();
803
+ await recordingGhostCursor.show({ page: targetPage, cursorOptions });
804
+ };
805
+ const hideGhostCursor = async (options) => {
806
+ const targetPage = options?.page || page;
807
+ await recordingGhostCursor.hide({ page: targetPage });
808
+ };
809
+ const recordingApi = createRecordingApi({
810
+ context,
811
+ defaultPage: page,
812
+ relayPort,
813
+ ghostCursorController: recordingGhostCursor,
814
+ onStart: () => {
815
+ self.recordingStartedAt = Date.now();
816
+ self.executionTimestamps = [];
817
+ },
818
+ onFinish: () => {
819
+ self.recordingStartedAt = null;
820
+ self.executionTimestamps = [];
821
+ },
822
+ getExecutionTimestamps: () => {
823
+ return self.executionTimestamps;
824
+ },
825
+ });
638
826
  // Ghost Browser API - creates chrome object that mirrors Ghost Browser's APIs
639
827
  // See extension/src/ghost-browser-api.d.ts for full API documentation
640
828
  const chromeGhostBrowser = createGhostBrowserChrome(async (namespace, method, args) => {
@@ -651,7 +839,8 @@ export class PlaywrightExecutor {
651
839
  context,
652
840
  state: this.userState,
653
841
  console: customConsole,
654
- accessibilitySnapshot,
842
+ snapshot,
843
+ accessibilitySnapshot: snapshot, // backward compat alias
655
844
  refToLocator,
656
845
  getCleanHTML,
657
846
  getPageMarkdown,
@@ -666,10 +855,23 @@ export class PlaywrightExecutor {
666
855
  formatStylesAsText,
667
856
  getReactSource: getReactSourceFn,
668
857
  screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
669
- startRecording: withRecordingDefaults(startRecording),
670
- stopRecording: withRecordingDefaults(stopRecording),
671
- isRecording: withRecordingDefaults(isRecording),
672
- cancelRecording: withRecordingDefaults(cancelRecording),
858
+ resizeImage,
859
+ ghostCursor: {
860
+ show: showGhostCursor,
861
+ hide: hideGhostCursor,
862
+ },
863
+ recording: {
864
+ start: recordingApi.start,
865
+ stop: recordingApi.stop,
866
+ isRecording: recordingApi.isRecording,
867
+ cancel: recordingApi.cancel,
868
+ },
869
+ // Backward-compatible aliases
870
+ startRecording: recordingApi.start,
871
+ stopRecording: recordingApi.stop,
872
+ isRecording: recordingApi.isRecording,
873
+ cancelRecording: recordingApi.cancel,
874
+ createDemoVideo,
673
875
  resetPlaywright: async () => {
674
876
  const { page: newPage, context: newContext } = await self.reset();
675
877
  vmContextObj.page = newPage;
@@ -683,15 +885,36 @@ export class PlaywrightExecutor {
683
885
  ...usefulGlobals,
684
886
  };
685
887
  const vmContext = vm.createContext(vmContextObj);
686
- const autoReturn = shouldAutoReturn(code);
687
- const wrappedCode = autoReturn
688
- ? `(async () => { return await (${code}) })()`
888
+ const autoReturnExpr = getAutoReturnExpression(code);
889
+ const wrappedCode = autoReturnExpr !== null
890
+ ? `(async () => { return await (${autoReturnExpr}) })()`
689
891
  : `(async () => { ${code} })()`;
690
- const hasExplicitReturn = autoReturn || /\breturn\b/.test(code);
691
- const result = await Promise.race([
692
- vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }),
693
- new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
694
- ]);
892
+ const hasExplicitReturn = autoReturnExpr !== null || /\breturn\b/.test(code);
893
+ // Track execution timestamps relative to recording start (seconds).
894
+ // Used to identify idle gaps that can be sped up in demo videos.
895
+ // Captured before execution so we can record timing even if it throws.
896
+ const recordingStartSnapshot = this.recordingStartedAt;
897
+ const execStartSec = recordingStartSnapshot !== null
898
+ ? (Date.now() - recordingStartSnapshot) / 1000
899
+ : -1;
900
+ const result = await (async () => {
901
+ try {
902
+ return await Promise.race([
903
+ vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }),
904
+ new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
905
+ ]);
906
+ }
907
+ finally {
908
+ // Record timestamp even on error — the execution still occupied real time
909
+ // that should not be sped up in the demo video.
910
+ // Compare against snapshot to avoid cross-session contamination if
911
+ // recording was stopped and restarted inside the same execute() call.
912
+ if (recordingStartSnapshot !== null && execStartSec >= 0 && this.recordingStartedAt === recordingStartSnapshot) {
913
+ const execEndSec = (Date.now() - recordingStartSnapshot) / 1000;
914
+ this.executionTimestamps.push({ start: execStartSec, end: execEndSec });
915
+ }
916
+ }
917
+ })();
695
918
  let responseText = formatConsoleLogs(consoleLogs);
696
919
  // Only show return value if user explicitly used return
697
920
  if (hasExplicitReturn) {
@@ -699,12 +922,19 @@ export class PlaywrightExecutor {
699
922
  if (resolvedResult !== undefined) {
700
923
  const formatted = typeof resolvedResult === 'string'
701
924
  ? resolvedResult
702
- : util.inspect(resolvedResult, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 });
925
+ : util.inspect(resolvedResult, {
926
+ depth: 4,
927
+ colors: false,
928
+ maxArrayLength: 100,
929
+ maxStringLength: 1000,
930
+ breakLength: 80,
931
+ });
703
932
  if (formatted.trim()) {
704
933
  responseText += `[return value] ${formatted}\n`;
705
934
  }
706
935
  }
707
936
  }
937
+ responseText += this.flushWarningsForScope(warningScope);
708
938
  if (!responseText.trim()) {
709
939
  responseText = 'Code executed successfully (no output)';
710
940
  }
@@ -725,14 +955,17 @@ export class PlaywrightExecutor {
725
955
  }
726
956
  catch (error) {
727
957
  const errorStack = error.stack || error.message;
728
- const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError';
958
+ const isTimeoutError = error instanceof CodeExecutionTimeoutError || error?.name === 'TimeoutError' || error?.name === 'AbortError';
729
959
  this.logger.error('Error in execute:', errorStack);
730
960
  const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)');
961
+ const warningText = this.flushWarningsForScope(warningScope);
731
962
  const resetHint = isTimeoutError
732
963
  ? ''
733
964
  : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]';
965
+ // timeout stacks are internal noise (Promise.race / setTimeout); only show the message
966
+ const errorText = isTimeoutError ? error.message : errorStack;
734
967
  return {
735
- text: `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`,
968
+ text: `${logsText}${warningText}\nError executing code: ${errorText}${resetHint}`,
736
969
  images: [],
737
970
  isError: true,
738
971
  };
@@ -762,7 +995,7 @@ export class PlaywrightExecutor {
762
995
  throw new Error(NO_PAGES_AVAILABLE_ERROR);
763
996
  }
764
997
  const page = await context.newPage();
765
- this.setupPageConsoleListener(page);
998
+ this.setupPageListeners(page);
766
999
  const pageUrl = page.url();
767
1000
  if (pageUrl === 'about:blank') {
768
1001
  return page;