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/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,15 @@ 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 short for fast agent failure.
456
+ // Navigation timeout (goto, reload) is longer since page loads are slower.
457
+ context.setDefaultTimeout(2000);
458
+ context.setDefaultNavigationTimeout(10000);
323
459
  context.on('page', (page) => {
324
- this.setupPageConsoleListener(page);
460
+ this.setupPageListeners(page);
325
461
  });
326
- context.pages().forEach((p) => this.setupPageConsoleListener(p));
462
+ context.pages().forEach((p) => this.setupPageListeners(p));
327
463
  const page = await this.ensurePageForContext({ context, timeout: 10000 });
328
- await this.preserveSystemColorScheme(context);
329
464
  await this.setDeviceScaleFactorForMacOS(context);
330
465
  this.browser = browser;
331
466
  this.page = page;
@@ -358,12 +493,16 @@ export class PlaywrightExecutor {
358
493
  }
359
494
  async reset() {
360
495
  if (this.browser) {
496
+ this.suppressPageCloseWarnings = true;
361
497
  try {
362
498
  await this.browser.close();
363
499
  }
364
500
  catch (e) {
365
501
  this.logger.error('Error closing browser:', e);
366
502
  }
503
+ finally {
504
+ this.suppressPageCloseWarnings = false;
505
+ }
367
506
  }
368
507
  this.clearConnectionState();
369
508
  this.clearUserState();
@@ -382,12 +521,15 @@ export class PlaywrightExecutor {
382
521
  });
383
522
  const contexts = browser.contexts();
384
523
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
524
+ // Action timeout (click, fill, hover, etc.) is short for fast agent failure.
525
+ // Navigation timeout (goto, reload) is longer since page loads are slower.
526
+ context.setDefaultTimeout(2000);
527
+ context.setDefaultNavigationTimeout(10000);
385
528
  context.on('page', (page) => {
386
- this.setupPageConsoleListener(page);
529
+ this.setupPageListeners(page);
387
530
  });
388
- context.pages().forEach((p) => this.setupPageConsoleListener(p));
531
+ context.pages().forEach((p) => this.setupPageListeners(p));
389
532
  const page = await this.ensurePageForContext({ context, timeout: 10000 });
390
- await this.preserveSystemColorScheme(context);
391
533
  await this.setDeviceScaleFactorForMacOS(context);
392
534
  this.browser = browser;
393
535
  this.page = page;
@@ -397,6 +539,7 @@ export class PlaywrightExecutor {
397
539
  }
398
540
  async execute(code, timeout = 10000) {
399
541
  const consoleLogs = [];
542
+ const warningScope = this.beginWarningScope();
400
543
  const formatConsoleLogs = (logs, prefix = 'Console output') => {
401
544
  if (logs.length === 0) {
402
545
  return '';
@@ -407,7 +550,13 @@ export class PlaywrightExecutor {
407
550
  .map((arg) => {
408
551
  if (typeof arg === 'string')
409
552
  return arg;
410
- return util.inspect(arg, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 });
553
+ return util.inspect(arg, {
554
+ depth: 4,
555
+ colors: false,
556
+ maxArrayLength: 100,
557
+ maxStringLength: 1000,
558
+ breakLength: 80,
559
+ });
411
560
  })
412
561
  .join(' ');
413
562
  text += `[${method}] ${formattedArgs}\n`;
@@ -418,7 +567,6 @@ export class PlaywrightExecutor {
418
567
  await this.ensureConnection();
419
568
  const page = await this.getCurrentPage(timeout);
420
569
  const context = this.context || page.context();
421
- context.setDefaultTimeout(timeout);
422
570
  this.logger.log('Executing code:', code);
423
571
  const customConsole = {
424
572
  log: (...args) => {
@@ -437,14 +585,14 @@ export class PlaywrightExecutor {
437
585
  consoleLogs.push({ method: 'debug', args });
438
586
  },
439
587
  };
440
- const accessibilitySnapshot = async (options) => {
441
- const { page: targetPage, frame, locator, search, showDiffSinceLastCall = true, interactiveOnly = false } = options;
588
+ const snapshot = async (options) => {
589
+ const { page: targetPage, frame, locator, search, showDiffSinceLastCall = !search, interactiveOnly = false, } = options;
442
590
  const resolvedPage = targetPage || page;
443
591
  if (!resolvedPage) {
444
- throw new Error('accessibilitySnapshot requires a page');
592
+ throw new Error('snapshot requires a page');
445
593
  }
446
594
  // Use new in-page implementation via getAriaSnapshot
447
- const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({
595
+ const { snapshot: rawSnapshot, refs, getSelectorForRef, } = await getAriaSnapshot({
448
596
  page: resolvedPage,
449
597
  frame,
450
598
  locator,
@@ -460,11 +608,19 @@ export class PlaywrightExecutor {
460
608
  }
461
609
  this.lastRefToLocator.set(resolvedPage, refToLocator);
462
610
  const shouldCacheSnapshot = !frame;
463
- const previousSnapshot = shouldCacheSnapshot ? this.lastSnapshots.get(resolvedPage) : undefined;
611
+ // Cache keyed by locator selector so full-page and locator-scoped snapshots
612
+ // don't pollute each other's diff baselines
613
+ const snapshotKey = locator ? `locator:${locator.selector()}` : 'page';
614
+ let pageSnapshots = this.lastSnapshots.get(resolvedPage);
615
+ if (!pageSnapshots) {
616
+ pageSnapshots = new Map();
617
+ this.lastSnapshots.set(resolvedPage, pageSnapshots);
618
+ }
619
+ const previousSnapshot = shouldCacheSnapshot ? pageSnapshots.get(snapshotKey) : undefined;
464
620
  if (shouldCacheSnapshot) {
465
- this.lastSnapshots.set(resolvedPage, snapshotStr);
621
+ pageSnapshots.set(snapshotKey, snapshotStr);
466
622
  }
467
- // Return diff if we have a previous snapshot and diff mode is enabled
623
+ // Diff defaults off when search is provided, but agent can explicitly enable both
468
624
  if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) {
469
625
  const diffResult = createSmartDiff({
470
626
  oldContent: previousSnapshot,
@@ -625,16 +781,46 @@ export class PlaywrightExecutor {
625
781
  // Recording uses chrome.tabCapture which requires activeTab permission.
626
782
  // This permission is granted when the user clicks the Playwriter extension icon on a tab.
627
783
  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
784
  const self = this;
785
+ const recordingGhostCursor = new RecordingGhostCursorController({
786
+ logger: {
787
+ error: (...args) => {
788
+ self.logger.error(...args);
789
+ },
790
+ },
791
+ });
792
+ const showGhostCursor = async (options) => {
793
+ const targetPage = options?.page || page;
794
+ const cursorOptions = (() => {
795
+ if (!options) {
796
+ return undefined;
797
+ }
798
+ const { page: _ignoredPage, ...rest } = options;
799
+ return rest;
800
+ })();
801
+ await recordingGhostCursor.show({ page: targetPage, cursorOptions });
802
+ };
803
+ const hideGhostCursor = async (options) => {
804
+ const targetPage = options?.page || page;
805
+ await recordingGhostCursor.hide({ page: targetPage });
806
+ };
807
+ const recordingApi = createRecordingApi({
808
+ context,
809
+ defaultPage: page,
810
+ relayPort,
811
+ ghostCursorController: recordingGhostCursor,
812
+ onStart: () => {
813
+ self.recordingStartedAt = Date.now();
814
+ self.executionTimestamps = [];
815
+ },
816
+ onFinish: () => {
817
+ self.recordingStartedAt = null;
818
+ self.executionTimestamps = [];
819
+ },
820
+ getExecutionTimestamps: () => {
821
+ return self.executionTimestamps;
822
+ },
823
+ });
638
824
  // Ghost Browser API - creates chrome object that mirrors Ghost Browser's APIs
639
825
  // See extension/src/ghost-browser-api.d.ts for full API documentation
640
826
  const chromeGhostBrowser = createGhostBrowserChrome(async (namespace, method, args) => {
@@ -651,7 +837,8 @@ export class PlaywrightExecutor {
651
837
  context,
652
838
  state: this.userState,
653
839
  console: customConsole,
654
- accessibilitySnapshot,
840
+ snapshot,
841
+ accessibilitySnapshot: snapshot, // backward compat alias
655
842
  refToLocator,
656
843
  getCleanHTML,
657
844
  getPageMarkdown,
@@ -666,10 +853,23 @@ export class PlaywrightExecutor {
666
853
  formatStylesAsText,
667
854
  getReactSource: getReactSourceFn,
668
855
  screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
669
- startRecording: withRecordingDefaults(startRecording),
670
- stopRecording: withRecordingDefaults(stopRecording),
671
- isRecording: withRecordingDefaults(isRecording),
672
- cancelRecording: withRecordingDefaults(cancelRecording),
856
+ resizeImage,
857
+ ghostCursor: {
858
+ show: showGhostCursor,
859
+ hide: hideGhostCursor,
860
+ },
861
+ recording: {
862
+ start: recordingApi.start,
863
+ stop: recordingApi.stop,
864
+ isRecording: recordingApi.isRecording,
865
+ cancel: recordingApi.cancel,
866
+ },
867
+ // Backward-compatible aliases
868
+ startRecording: recordingApi.start,
869
+ stopRecording: recordingApi.stop,
870
+ isRecording: recordingApi.isRecording,
871
+ cancelRecording: recordingApi.cancel,
872
+ createDemoVideo,
673
873
  resetPlaywright: async () => {
674
874
  const { page: newPage, context: newContext } = await self.reset();
675
875
  vmContextObj.page = newPage;
@@ -683,15 +883,36 @@ export class PlaywrightExecutor {
683
883
  ...usefulGlobals,
684
884
  };
685
885
  const vmContext = vm.createContext(vmContextObj);
686
- const autoReturn = shouldAutoReturn(code);
687
- const wrappedCode = autoReturn
688
- ? `(async () => { return await (${code}) })()`
886
+ const autoReturnExpr = getAutoReturnExpression(code);
887
+ const wrappedCode = autoReturnExpr !== null
888
+ ? `(async () => { return await (${autoReturnExpr}) })()`
689
889
  : `(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
- ]);
890
+ const hasExplicitReturn = autoReturnExpr !== null || /\breturn\b/.test(code);
891
+ // Track execution timestamps relative to recording start (seconds).
892
+ // Used to identify idle gaps that can be sped up in demo videos.
893
+ // Captured before execution so we can record timing even if it throws.
894
+ const recordingStartSnapshot = this.recordingStartedAt;
895
+ const execStartSec = recordingStartSnapshot !== null
896
+ ? (Date.now() - recordingStartSnapshot) / 1000
897
+ : -1;
898
+ const result = await (async () => {
899
+ try {
900
+ return await Promise.race([
901
+ vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }),
902
+ new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
903
+ ]);
904
+ }
905
+ finally {
906
+ // Record timestamp even on error — the execution still occupied real time
907
+ // that should not be sped up in the demo video.
908
+ // Compare against snapshot to avoid cross-session contamination if
909
+ // recording was stopped and restarted inside the same execute() call.
910
+ if (recordingStartSnapshot !== null && execStartSec >= 0 && this.recordingStartedAt === recordingStartSnapshot) {
911
+ const execEndSec = (Date.now() - recordingStartSnapshot) / 1000;
912
+ this.executionTimestamps.push({ start: execStartSec, end: execEndSec });
913
+ }
914
+ }
915
+ })();
695
916
  let responseText = formatConsoleLogs(consoleLogs);
696
917
  // Only show return value if user explicitly used return
697
918
  if (hasExplicitReturn) {
@@ -699,12 +920,19 @@ export class PlaywrightExecutor {
699
920
  if (resolvedResult !== undefined) {
700
921
  const formatted = typeof resolvedResult === 'string'
701
922
  ? resolvedResult
702
- : util.inspect(resolvedResult, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80 });
923
+ : util.inspect(resolvedResult, {
924
+ depth: 4,
925
+ colors: false,
926
+ maxArrayLength: 100,
927
+ maxStringLength: 1000,
928
+ breakLength: 80,
929
+ });
703
930
  if (formatted.trim()) {
704
931
  responseText += `[return value] ${formatted}\n`;
705
932
  }
706
933
  }
707
934
  }
935
+ responseText += this.flushWarningsForScope(warningScope);
708
936
  if (!responseText.trim()) {
709
937
  responseText = 'Code executed successfully (no output)';
710
938
  }
@@ -725,14 +953,17 @@ export class PlaywrightExecutor {
725
953
  }
726
954
  catch (error) {
727
955
  const errorStack = error.stack || error.message;
728
- const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError';
956
+ const isTimeoutError = error instanceof CodeExecutionTimeoutError || error?.name === 'TimeoutError' || error?.name === 'AbortError';
729
957
  this.logger.error('Error in execute:', errorStack);
730
958
  const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)');
959
+ const warningText = this.flushWarningsForScope(warningScope);
731
960
  const resetHint = isTimeoutError
732
961
  ? ''
733
962
  : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]';
963
+ // timeout stacks are internal noise (Promise.race / setTimeout); only show the message
964
+ const errorText = isTimeoutError ? error.message : errorStack;
734
965
  return {
735
- text: `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`,
966
+ text: `${logsText}${warningText}\nError executing code: ${errorText}${resetHint}`,
736
967
  images: [],
737
968
  isError: true,
738
969
  };
@@ -762,7 +993,7 @@ export class PlaywrightExecutor {
762
993
  throw new Error(NO_PAGES_AVAILABLE_ERROR);
763
994
  }
764
995
  const page = await context.newPage();
765
- this.setupPageConsoleListener(page);
996
+ this.setupPageListeners(page);
766
997
  const pageUrl = page.url();
767
998
  if (pageUrl === 'about:blank') {
768
999
  return page;