e2e-pilot 0.0.69

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 (152) hide show
  1. package/bin.js +3 -0
  2. package/dist/aria-snapshot.d.ts +95 -0
  3. package/dist/aria-snapshot.d.ts.map +1 -0
  4. package/dist/aria-snapshot.js +490 -0
  5. package/dist/aria-snapshot.js.map +1 -0
  6. package/dist/bippy.js +971 -0
  7. package/dist/cdp-relay.d.ts +16 -0
  8. package/dist/cdp-relay.d.ts.map +1 -0
  9. package/dist/cdp-relay.js +715 -0
  10. package/dist/cdp-relay.js.map +1 -0
  11. package/dist/cdp-session.d.ts +42 -0
  12. package/dist/cdp-session.d.ts.map +1 -0
  13. package/dist/cdp-session.js +154 -0
  14. package/dist/cdp-session.js.map +1 -0
  15. package/dist/cdp-types.d.ts +63 -0
  16. package/dist/cdp-types.d.ts.map +1 -0
  17. package/dist/cdp-types.js +91 -0
  18. package/dist/cdp-types.js.map +1 -0
  19. package/dist/cli.d.ts +3 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +213 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/create-logger.d.ts +9 -0
  24. package/dist/create-logger.d.ts.map +1 -0
  25. package/dist/create-logger.js +25 -0
  26. package/dist/create-logger.js.map +1 -0
  27. package/dist/debugger-api.md +458 -0
  28. package/dist/debugger-examples-types.d.ts +24 -0
  29. package/dist/debugger-examples-types.d.ts.map +1 -0
  30. package/dist/debugger-examples-types.js +2 -0
  31. package/dist/debugger-examples-types.js.map +1 -0
  32. package/dist/debugger-examples.d.ts +6 -0
  33. package/dist/debugger-examples.d.ts.map +1 -0
  34. package/dist/debugger-examples.js +53 -0
  35. package/dist/debugger-examples.js.map +1 -0
  36. package/dist/debugger.d.ts +381 -0
  37. package/dist/debugger.d.ts.map +1 -0
  38. package/dist/debugger.js +633 -0
  39. package/dist/debugger.js.map +1 -0
  40. package/dist/editor-api.md +364 -0
  41. package/dist/editor-examples.d.ts +11 -0
  42. package/dist/editor-examples.d.ts.map +1 -0
  43. package/dist/editor-examples.js +124 -0
  44. package/dist/editor-examples.js.map +1 -0
  45. package/dist/editor.d.ts +203 -0
  46. package/dist/editor.d.ts.map +1 -0
  47. package/dist/editor.js +336 -0
  48. package/dist/editor.js.map +1 -0
  49. package/dist/execute.d.ts +50 -0
  50. package/dist/execute.d.ts.map +1 -0
  51. package/dist/execute.js +576 -0
  52. package/dist/execute.js.map +1 -0
  53. package/dist/index.d.ts +11 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +7 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/mcp-client.d.ts +20 -0
  58. package/dist/mcp-client.d.ts.map +1 -0
  59. package/dist/mcp-client.js +56 -0
  60. package/dist/mcp-client.js.map +1 -0
  61. package/dist/mcp.d.ts +5 -0
  62. package/dist/mcp.d.ts.map +1 -0
  63. package/dist/mcp.js +720 -0
  64. package/dist/mcp.js.map +1 -0
  65. package/dist/mcp.test.d.ts +10 -0
  66. package/dist/mcp.test.d.ts.map +1 -0
  67. package/dist/mcp.test.js +2999 -0
  68. package/dist/mcp.test.js.map +1 -0
  69. package/dist/network-capture.d.ts +23 -0
  70. package/dist/network-capture.d.ts.map +1 -0
  71. package/dist/network-capture.js +98 -0
  72. package/dist/network-capture.js.map +1 -0
  73. package/dist/protocol.d.ts +54 -0
  74. package/dist/protocol.d.ts.map +1 -0
  75. package/dist/protocol.js +2 -0
  76. package/dist/protocol.js.map +1 -0
  77. package/dist/react-source.d.ts +13 -0
  78. package/dist/react-source.d.ts.map +1 -0
  79. package/dist/react-source.js +68 -0
  80. package/dist/react-source.js.map +1 -0
  81. package/dist/scoped-fs.d.ts +94 -0
  82. package/dist/scoped-fs.d.ts.map +1 -0
  83. package/dist/scoped-fs.js +356 -0
  84. package/dist/scoped-fs.js.map +1 -0
  85. package/dist/selector-generator.js +8126 -0
  86. package/dist/start-relay-server.d.ts +6 -0
  87. package/dist/start-relay-server.d.ts.map +1 -0
  88. package/dist/start-relay-server.js +33 -0
  89. package/dist/start-relay-server.js.map +1 -0
  90. package/dist/styles-api.md +117 -0
  91. package/dist/styles-examples.d.ts +8 -0
  92. package/dist/styles-examples.d.ts.map +1 -0
  93. package/dist/styles-examples.js +64 -0
  94. package/dist/styles-examples.js.map +1 -0
  95. package/dist/styles.d.ts +27 -0
  96. package/dist/styles.d.ts.map +1 -0
  97. package/dist/styles.js +234 -0
  98. package/dist/styles.js.map +1 -0
  99. package/dist/trace-utils.d.ts +14 -0
  100. package/dist/trace-utils.d.ts.map +1 -0
  101. package/dist/trace-utils.js +21 -0
  102. package/dist/trace-utils.js.map +1 -0
  103. package/dist/utils.d.ts +20 -0
  104. package/dist/utils.d.ts.map +1 -0
  105. package/dist/utils.js +75 -0
  106. package/dist/utils.js.map +1 -0
  107. package/dist/wait-for-page-load.d.ts +16 -0
  108. package/dist/wait-for-page-load.d.ts.map +1 -0
  109. package/dist/wait-for-page-load.js +127 -0
  110. package/dist/wait-for-page-load.js.map +1 -0
  111. package/package.json +67 -0
  112. package/src/aria-snapshot.ts +610 -0
  113. package/src/assets/aria-labels-github-snapshot.txt +605 -0
  114. package/src/assets/aria-labels-github.png +0 -0
  115. package/src/assets/aria-labels-google-snapshot.txt +49 -0
  116. package/src/assets/aria-labels-google.png +0 -0
  117. package/src/assets/aria-labels-hacker-news-snapshot.txt +1023 -0
  118. package/src/assets/aria-labels-hacker-news.png +0 -0
  119. package/src/cdp-relay.ts +925 -0
  120. package/src/cdp-session.ts +203 -0
  121. package/src/cdp-timing.md +128 -0
  122. package/src/cdp-types.ts +155 -0
  123. package/src/cli.ts +250 -0
  124. package/src/create-logger.ts +36 -0
  125. package/src/debugger-examples-types.ts +13 -0
  126. package/src/debugger-examples.ts +66 -0
  127. package/src/debugger.md +453 -0
  128. package/src/debugger.ts +713 -0
  129. package/src/editor-examples.ts +148 -0
  130. package/src/editor.ts +390 -0
  131. package/src/execute.ts +763 -0
  132. package/src/index.ts +10 -0
  133. package/src/mcp-client.ts +78 -0
  134. package/src/mcp.test.ts +3596 -0
  135. package/src/mcp.ts +876 -0
  136. package/src/network-capture.ts +140 -0
  137. package/src/prompt.bak.md +323 -0
  138. package/src/prompt.md +7 -0
  139. package/src/protocol.ts +63 -0
  140. package/src/react-source.ts +94 -0
  141. package/src/resource.md +436 -0
  142. package/src/scoped-fs.ts +411 -0
  143. package/src/snapshots/hacker-news-focused-accessibility.md +202 -0
  144. package/src/snapshots/hacker-news-initial-accessibility.md +11 -0
  145. package/src/snapshots/hacker-news-tabbed-accessibility.md +202 -0
  146. package/src/snapshots/shadcn-ui-accessibility.md +11 -0
  147. package/src/start-relay-server.ts +43 -0
  148. package/src/styles-examples.ts +77 -0
  149. package/src/styles.ts +345 -0
  150. package/src/trace-utils.ts +43 -0
  151. package/src/utils.ts +91 -0
  152. package/src/wait-for-page-load.ts +174 -0
package/src/execute.ts ADDED
@@ -0,0 +1,763 @@
1
+ import { Page, BrowserContext } from 'playwright-core'
2
+ import { expect } from '@playwright/test'
3
+ import crypto from 'node:crypto'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import os from 'node:os'
7
+ import { createRequire } from 'node:module'
8
+ import { fileURLToPath } from 'node:url'
9
+ import vm from 'node:vm'
10
+ import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js'
11
+ import { getCDPSessionForPage, CDPSession, ICDPSession } from './cdp-session.js'
12
+ import { Debugger } from './debugger.js'
13
+ import { Editor } from './editor.js'
14
+ import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
15
+ import { getReactSource, type ReactSourceLocation } from './react-source.js'
16
+ import { ScopedFS } from './scoped-fs.js'
17
+ import { screenshotWithAccessibilityLabels, type ScreenshotResult } from './aria-snapshot.js'
18
+ import { formatAsCommentLines, formatDateTimeForPath } from './utils.js'
19
+ import { setupNetworkCapture, saveNetworkRequests } from './network-capture.js'
20
+
21
+ const __filename = fileURLToPath(import.meta.url)
22
+ const __dirname = path.dirname(__filename)
23
+
24
+ const require = createRequire(import.meta.url)
25
+
26
+ // Extracts all unique aria-ref patterns (e.g., 'aria-ref=e12') from code
27
+ function extractAriaRefs(code: string): string[] {
28
+ const pattern = /['"`]aria-ref=(e\d+)['"`]/g
29
+ const refs = new Set<string>()
30
+ let match: RegExpExecArray | null
31
+ while ((match = pattern.exec(code)) !== null) {
32
+ refs.add(match[1])
33
+ }
34
+ return [...refs]
35
+ }
36
+
37
+ class CodeExecutionTimeoutError extends Error {
38
+ constructor(timeout: number) {
39
+ super(`Code execution timed out after ${timeout}ms`)
40
+ this.name = 'CodeExecutionTimeoutError'
41
+ }
42
+ }
43
+
44
+ const usefulGlobals = {
45
+ setTimeout,
46
+ setInterval,
47
+ clearTimeout,
48
+ clearInterval,
49
+ URL,
50
+ URLSearchParams,
51
+ fetch,
52
+ Buffer,
53
+ TextEncoder,
54
+ TextDecoder,
55
+ crypto,
56
+ AbortController,
57
+ AbortSignal,
58
+ structuredClone,
59
+ } as const
60
+
61
+ interface VMContext {
62
+ page: Page
63
+ context: BrowserContext
64
+ state: Record<string, any>
65
+ expect: typeof expect
66
+ console: {
67
+ log: (...args: any[]) => void
68
+ info: (...args: any[]) => void
69
+ warn: (...args: any[]) => void
70
+ error: (...args: any[]) => void
71
+ debug: (...args: any[]) => void
72
+ }
73
+ accessibilitySnapshot: (options: {
74
+ page: Page
75
+ search?: string | RegExp
76
+ contextLines?: number
77
+ showDiffSinceLastCall?: boolean
78
+ }) => Promise<string | null>
79
+ getLocatorStringForElement: (element: any) => Promise<string>
80
+ resetPlaywright: () => Promise<{ page: Page; context: BrowserContext }>
81
+ getLatestLogs: (options?: { page?: Page; count?: number; search?: string | RegExp }) => Promise<string[]>
82
+ clearAllLogs: () => void
83
+ waitForPageLoad: (options: WaitForPageLoadOptions) => Promise<WaitForPageLoadResult>
84
+ getCDPSession: (options: { page: Page }) => Promise<CDPSession>
85
+ createDebugger: (options: { cdp: ICDPSession }) => Debugger
86
+ createEditor: (options: { cdp: ICDPSession }) => Editor
87
+ getStylesForLocator: (options: { locator: any }) => Promise<StylesResult>
88
+ formatStylesAsText: (styles: StylesResult) => string
89
+ getReactSource: (options: { locator: any }) => Promise<ReactSourceLocation | null>
90
+ screenshotWithAccessibilityLabels: (options: { page: Page; interactiveOnly?: boolean }) => Promise<void>
91
+ require: NodeRequire
92
+ import: (specifier: string) => Promise<any>
93
+ }
94
+
95
+ type VMContextWithGlobals = VMContext & typeof usefulGlobals
96
+
97
+ /**
98
+ * Allowlist of Node.js built-in modules that are safe to use in the sandbox.
99
+ */
100
+ const ALLOWED_MODULES = new Set([
101
+ 'path',
102
+ 'node:path',
103
+ 'url',
104
+ 'node:url',
105
+ 'querystring',
106
+ 'node:querystring',
107
+ 'punycode',
108
+ 'node:punycode',
109
+ 'crypto',
110
+ 'node:crypto',
111
+ 'buffer',
112
+ 'node:buffer',
113
+ 'string_decoder',
114
+ 'node:string_decoder',
115
+ 'util',
116
+ 'node:util',
117
+ 'assert',
118
+ 'node:assert',
119
+ 'events',
120
+ 'node:events',
121
+ 'timers',
122
+ 'node:timers',
123
+ 'stream',
124
+ 'node:stream',
125
+ 'zlib',
126
+ 'node:zlib',
127
+ 'http',
128
+ 'node:http',
129
+ 'https',
130
+ 'node:https',
131
+ 'http2',
132
+ 'node:http2',
133
+ 'os',
134
+ 'node:os',
135
+ 'fs',
136
+ 'node:fs',
137
+ ])
138
+
139
+ function createSandboxedRequire(originalRequire: NodeRequire, scopedFs: ScopedFS): NodeRequire {
140
+ const sandboxedRequire = ((id: string) => {
141
+ if (!ALLOWED_MODULES.has(id)) {
142
+ const error = new Error(
143
+ `Module "${id}" is not allowed in the sandbox. ` +
144
+ `Only safe Node.js built-ins are permitted: ${[...ALLOWED_MODULES].filter((m) => !m.startsWith('node:')).join(', ')}`,
145
+ )
146
+ error.name = 'ModuleNotAllowedError'
147
+ throw error
148
+ }
149
+
150
+ if (id === 'fs' || id === 'node:fs') {
151
+ return scopedFs
152
+ }
153
+
154
+ return originalRequire(id)
155
+ }) as NodeRequire
156
+
157
+ sandboxedRequire.resolve = originalRequire.resolve
158
+ sandboxedRequire.cache = originalRequire.cache
159
+ sandboxedRequire.extensions = originalRequire.extensions
160
+ sandboxedRequire.main = originalRequire.main
161
+
162
+ return sandboxedRequire
163
+ }
164
+
165
+ function isRegExp(value: unknown): value is RegExp {
166
+ return (
167
+ typeof value === 'object' &&
168
+ value !== null &&
169
+ typeof (value as RegExp).test === 'function' &&
170
+ typeof (value as RegExp).exec === 'function'
171
+ )
172
+ }
173
+
174
+ interface Snapshots {
175
+ fullPath: string
176
+ incrementalPath: string
177
+ full: string | null
178
+ incremental: string | null
179
+ }
180
+
181
+ async function snapshot(options: {
182
+ page: Page
183
+ timeout: number
184
+ }): Promise<{ full: string | null; incremental: string | null }> {
185
+ const { page, timeout } = options
186
+
187
+ if (!(page as any)._snapshotForAI) {
188
+ console.error('snapshotForAI is not available on this page')
189
+ return { full: null, incremental: null }
190
+ }
191
+
192
+ // use fixed `track` so it always compare to previous run.
193
+ const snapshot = await (page as any)._snapshotForAI({ timeout, track: 'track' })
194
+ if (!snapshot) {
195
+ console.error('_snapshotForAI returned null/undefined')
196
+ return { full: null, incremental: null }
197
+ }
198
+ const fullSnapshot: string | null = snapshot.full?.toWellFormed?.() ?? snapshot.full ?? null
199
+ const incrementalSnapshot: string | null = snapshot.incremental?.toWellFormed?.() ?? snapshot.incremental ?? null
200
+ return { full: fullSnapshot, incremental: incrementalSnapshot }
201
+ }
202
+
203
+ export async function savePageSnapshots({
204
+ page,
205
+ lastSnapshots,
206
+ timeout,
207
+ }: {
208
+ page: Page
209
+ lastSnapshots: WeakMap<Page, string>
210
+ timeout: number
211
+ }): Promise<Snapshots | null> {
212
+ try {
213
+ const { full, incremental } = await snapshot({ page, timeout })
214
+
215
+ const now = new Date()
216
+ const dateTimeStr = formatDateTimeForPath(now)
217
+
218
+ const snapshotDir = path.join(process.cwd(), '.e2e-pilot', 'snapshots', dateTimeStr)
219
+ fs.mkdirSync(snapshotDir, { recursive: true })
220
+
221
+ const fullPath = path.join(snapshotDir, 'full.txt')
222
+ const incrementalPath = path.join(snapshotDir, 'incremental.txt')
223
+
224
+ if (full) {
225
+ lastSnapshots.set(page, full)
226
+ fs.writeFileSync(fullPath, full, 'utf-8')
227
+ }
228
+ if (incremental) {
229
+ fs.writeFileSync(incrementalPath, incremental, 'utf-8')
230
+ }
231
+
232
+ return { fullPath, incrementalPath, full, incremental }
233
+ } catch (err) {
234
+ console.error('savePageSnapshots error:', err)
235
+ return null
236
+ }
237
+ }
238
+
239
+ export interface ExecuteCodeDependencies {
240
+ page: Page
241
+ context: BrowserContext
242
+ userState: Record<string, any>
243
+ browserLogs: Map<string, string[]>
244
+ lastSnapshots: WeakMap<Page, string>
245
+ cdpSessionCache: WeakMap<Page, CDPSession>
246
+ getPageTargetId: (page: Page) => Promise<string>
247
+ getCdpUrl: () => string
248
+ resetConnection: () => Promise<{ page: Page; context: BrowserContext }>
249
+ logger: (...args: any[]) => void
250
+ sendLogToRelayServer: (level: string, ...args: any[]) => void
251
+ intend?: string
252
+ activeRecordingPath?: string
253
+ }
254
+
255
+ export interface ExecuteCodeResult {
256
+ [x: string]: unknown
257
+ content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }>
258
+ isError?: boolean
259
+ }
260
+
261
+ export async function executeCode({
262
+ code,
263
+ timeout,
264
+ deps,
265
+ }: {
266
+ code: string
267
+ timeout: number
268
+ deps: ExecuteCodeDependencies
269
+ }): Promise<ExecuteCodeResult> {
270
+ const {
271
+ page,
272
+ context,
273
+ userState,
274
+ browserLogs,
275
+ lastSnapshots,
276
+ cdpSessionCache,
277
+ getPageTargetId,
278
+ getCdpUrl,
279
+ resetConnection,
280
+ logger,
281
+ sendLogToRelayServer,
282
+ intend,
283
+ activeRecordingPath,
284
+ } = deps
285
+
286
+ // Will be populated before VM execution with resolved aria-ref -> stable locator mappings
287
+ // See docs/recording.md for the recording design and why pre-resolution is needed
288
+ // All refs include html for debugging; resolved refs also have the stable locator
289
+ let ariaRefResolutions: Map<
290
+ string,
291
+ { resolved: true; locator: string; html: string } | { resolved: false; html: string }
292
+ > | null = null
293
+
294
+ const appendToRecording = (options: { failed?: string }) => {
295
+ if (!activeRecordingPath || !intend) {
296
+ return
297
+ }
298
+ try {
299
+ let recordedCode = code
300
+ const ariaRefComments: string[] = []
301
+
302
+ // Replace aria-ref locators with stable locators, collect HTML comments for all refs
303
+ if (ariaRefResolutions && ariaRefResolutions.size > 0) {
304
+ for (const [ref, resolution] of ariaRefResolutions) {
305
+ const patterns = [
306
+ new RegExp(`locator\\(['"]aria-ref=${ref}['"]\\)`, 'g'),
307
+ new RegExp(`locator\\(\`aria-ref=${ref}\`\\)`, 'g'),
308
+ ]
309
+ if (resolution.resolved) {
310
+ // Replace with stable locator, add .first() to avoid strict mode violations
311
+ for (const pattern of patterns) {
312
+ recordedCode = recordedCode.replace(pattern, `${resolution.locator}.first()`)
313
+ }
314
+ // // Add comment with HTML for debugging
315
+ // ariaRefComments.push(`// aria-ref=${ref} resolved - element HTML: ${resolution.html}`)
316
+ } else {
317
+ // Keep aria-ref, add comment with HTML for manual resolution
318
+ ariaRefComments.push(`// UNRESOLVED aria-ref=${ref} - element HTML: ${resolution.html}`)
319
+ }
320
+ }
321
+ }
322
+
323
+ let entry = `// ${intend}\n`
324
+ if (ariaRefComments.length > 0) {
325
+ entry += ariaRefComments.join('\n') + '\n'
326
+ }
327
+ entry += `${recordedCode}\n`
328
+ if (options.failed) {
329
+ entry += formatAsCommentLines(`FAILED: ${options.failed}`) + '\n'
330
+ }
331
+ entry += '\n'
332
+ fs.appendFileSync(activeRecordingPath, entry, 'utf-8')
333
+ } catch {
334
+ // Silently fail if we can't write to the recording
335
+ }
336
+ }
337
+
338
+ const consoleLogs: Array<{ method: string; args: any[] }> = []
339
+ const scopedFs = new ScopedFS()
340
+ const sandboxedRequire = createSandboxedRequire(require, scopedFs)
341
+
342
+ const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => {
343
+ if (logs.length === 0) {
344
+ return ''
345
+ }
346
+
347
+ let text = `${prefix}:\n`
348
+ logs.forEach(({ method, args }) => {
349
+ const formattedArgs = args
350
+ .map((arg) => {
351
+ if (typeof arg === 'object') {
352
+ return JSON.stringify(arg, null, 2)
353
+ }
354
+ return String(arg)
355
+ })
356
+ .join(' ')
357
+ text += `[${method}] ${formattedArgs}\n`
358
+ })
359
+ return text + '\n'
360
+ }
361
+
362
+ // Network request capture during code execution (attach to all pages in context)
363
+ const networkCapture = setupNetworkCapture({ context })
364
+
365
+ try {
366
+ logger('Executing code:', code)
367
+
368
+ const customConsole = {
369
+ log: (...args: any[]) => {
370
+ consoleLogs.push({ method: 'log', args })
371
+ },
372
+ info: (...args: any[]) => {
373
+ consoleLogs.push({ method: 'info', args })
374
+ },
375
+ warn: (...args: any[]) => {
376
+ consoleLogs.push({ method: 'warn', args })
377
+ },
378
+ error: (...args: any[]) => {
379
+ consoleLogs.push({ method: 'error', args })
380
+ },
381
+ debug: (...args: any[]) => {
382
+ consoleLogs.push({ method: 'debug', args })
383
+ },
384
+ }
385
+
386
+ const accessibilitySnapshot = async (options: {
387
+ page: Page
388
+ // XXX: probably remove these and just let agent grep the snapshot file.
389
+ search?: string | RegExp
390
+ contextLines?: number
391
+ showDiffSinceLastCall?: boolean
392
+ timeout?: number
393
+ }): Promise<string | null> => {
394
+ const { page: targetPage, search, contextLines = 10, showDiffSinceLastCall = false, timeout = 5000 } = options
395
+ if ((targetPage as any)._snapshotForAI) {
396
+ const { full, incremental } = await snapshot({ page: targetPage, timeout })
397
+
398
+ if (!full) {
399
+ return null
400
+ }
401
+
402
+ lastSnapshots.set(targetPage, full)
403
+
404
+ if (showDiffSinceLastCall) {
405
+ return incremental
406
+ }
407
+
408
+ if (!search) {
409
+ return full
410
+ }
411
+
412
+ // XXX: consider remove search. just let it grep.
413
+ const lines = full.split('\n')
414
+ const matches: { line: string; index: number }[] = []
415
+
416
+ for (let i = 0; i < lines.length; i++) {
417
+ const line = lines[i]
418
+ let isMatch = false
419
+ if (isRegExp(search)) {
420
+ isMatch = search.test(line)
421
+ } else {
422
+ isMatch = line.includes(search)
423
+ }
424
+
425
+ if (isMatch) {
426
+ matches.push({ line, index: i })
427
+ if (matches.length >= 10) break
428
+ }
429
+ }
430
+
431
+ if (matches.length === 0) {
432
+ return 'No matches found'
433
+ }
434
+
435
+ return matches
436
+ .map((m) => {
437
+ const start = Math.max(0, m.index - contextLines)
438
+ const end = Math.min(lines.length, m.index + contextLines + 1)
439
+ return lines.slice(start, end).join('\n')
440
+ })
441
+ .join('\n\n---\n\n')
442
+ }
443
+ throw new Error('accessibilitySnapshot is not available on this page')
444
+ }
445
+
446
+ // Uses Playwright's own InjectedScript to generate locators from elements
447
+ const getLocatorStringForElement = async (element: any) => {
448
+ if (!element || typeof element.evaluate !== 'function') {
449
+ throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle')
450
+ }
451
+
452
+ const elementPage = element.page ? element.page() : page
453
+ const hasGenerator = await elementPage.evaluate(() => !!(globalThis as any).__selectorGenerator)
454
+
455
+ if (!hasGenerator) {
456
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
457
+ // TODO: is it possible to avoid building separate selector-generator.js? can we just use playwright's injectedScript.js?
458
+ const scriptPath = path.join(currentDir, '..', 'dist', 'selector-generator.js')
459
+ const scriptContent = fs.readFileSync(scriptPath, 'utf-8')
460
+ const cdp = await getCDPSessionFn({ page: elementPage })
461
+ await cdp.send('Runtime.evaluate', { expression: scriptContent })
462
+ }
463
+
464
+ return await element.evaluate((el: any) => {
465
+ return (globalThis as any).__selectorGenerator.generateLocator(el)
466
+ })
467
+ }
468
+
469
+ const getLatestLogs = async (options?: { page?: Page; count?: number; search?: string | RegExp }) => {
470
+ const { page: filterPage, count, search } = options || {}
471
+
472
+ let allLogs: string[] = []
473
+
474
+ if (filterPage) {
475
+ const targetId = await getPageTargetId(filterPage)
476
+ const pageLogs = browserLogs.get(targetId) || []
477
+ allLogs = [...pageLogs]
478
+ } else {
479
+ for (const pageLogs of browserLogs.values()) {
480
+ allLogs.push(...pageLogs)
481
+ }
482
+ }
483
+
484
+ if (search) {
485
+ allLogs = allLogs.filter((log) => {
486
+ if (typeof search === 'string') {
487
+ return log.includes(search)
488
+ } else if (isRegExp(search)) {
489
+ return search.test(log)
490
+ }
491
+ return false
492
+ })
493
+ }
494
+
495
+ return count !== undefined ? allLogs.slice(-count) : allLogs
496
+ }
497
+
498
+ const clearAllLogs = () => {
499
+ browserLogs.clear()
500
+ }
501
+
502
+ const getCDPSessionFn = async (options: { page: Page }) => {
503
+ const cached = cdpSessionCache.get(options.page)
504
+ if (cached) {
505
+ return cached
506
+ }
507
+ const wsUrl = getCdpUrl()
508
+ const session = await getCDPSessionForPage({ page: options.page, wsUrl })
509
+ cdpSessionCache.set(options.page, session)
510
+ return session
511
+ }
512
+
513
+ const createDebuggerFn = (options: { cdp: ICDPSession }) => {
514
+ return new Debugger(options)
515
+ }
516
+
517
+ const createEditorFn = (options: { cdp: ICDPSession }) => {
518
+ return new Editor(options)
519
+ }
520
+
521
+ const getStylesForLocatorFn = async (options: { locator: any }) => {
522
+ const cdp = await getCDPSessionFn({ page: options.locator.page() })
523
+ return getStylesForLocator({ locator: options.locator, cdp })
524
+ }
525
+
526
+ const getReactSourceFn = async (options: { locator: any }) => {
527
+ const cdp = await getCDPSessionFn({ page: options.locator.page() })
528
+ return getReactSource({ locator: options.locator, cdp })
529
+ }
530
+
531
+ const screenshotCollector: ScreenshotResult[] = []
532
+
533
+ const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => {
534
+ return screenshotWithAccessibilityLabels({ ...options, collector: screenshotCollector })
535
+ }
536
+
537
+ // Pre-resolve aria-refs to stable locators before VM execution (for recording)
538
+ if (activeRecordingPath) {
539
+ const ariaRefs = extractAriaRefs(code)
540
+ ariaRefResolutions = new Map()
541
+ for (const ref of ariaRefs) {
542
+ try {
543
+ const locator = page.locator(`aria-ref=${ref}`)
544
+ const count = await locator.count()
545
+ if (count === 1) {
546
+ const stableLocator = await getLocatorStringForElement(locator)
547
+ const html = await locator.evaluate((el) => el.outerHTML.slice(0, 200))
548
+ ariaRefResolutions.set(ref, { resolved: true, locator: stableLocator, html })
549
+ } else {
550
+ // Element not found or multiple matches - capture what we can
551
+ const html = count === 0 ? '<element not found>' : '<multiple elements matched>'
552
+ ariaRefResolutions.set(ref, { resolved: false, html })
553
+ }
554
+ } catch (err) {
555
+ // Try to get HTML even if resolution failed
556
+ try {
557
+ const locator = page.locator(`aria-ref=${ref}`)
558
+ const html = await locator.evaluate((el) => el.outerHTML.slice(0, 200))
559
+ ariaRefResolutions.set(ref, { resolved: false, html })
560
+ } catch {
561
+ ariaRefResolutions.set(ref, { resolved: false, html: '<failed to get element>' })
562
+ }
563
+ }
564
+ }
565
+ }
566
+
567
+ let vmContextObj: VMContextWithGlobals = {
568
+ page,
569
+ context,
570
+ state: userState,
571
+ expect,
572
+ console: customConsole,
573
+ accessibilitySnapshot,
574
+ getLocatorStringForElement,
575
+ getLatestLogs,
576
+ clearAllLogs,
577
+ waitForPageLoad,
578
+ getCDPSession: getCDPSessionFn,
579
+ createDebugger: createDebuggerFn,
580
+ createEditor: createEditorFn,
581
+ getStylesForLocator: getStylesForLocatorFn,
582
+ formatStylesAsText,
583
+ getReactSource: getReactSourceFn,
584
+ screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
585
+ resetPlaywright: async () => {
586
+ const { page: newPage, context: newContext } = await resetConnection()
587
+
588
+ const resetObj: VMContextWithGlobals = {
589
+ page: newPage,
590
+ context: newContext,
591
+ state: userState,
592
+ expect,
593
+ console: customConsole,
594
+ accessibilitySnapshot,
595
+ getLocatorStringForElement,
596
+ getLatestLogs,
597
+ clearAllLogs,
598
+ waitForPageLoad,
599
+ getCDPSession: getCDPSessionFn,
600
+ createDebugger: createDebuggerFn,
601
+ createEditor: createEditorFn,
602
+ getStylesForLocator: getStylesForLocatorFn,
603
+ formatStylesAsText,
604
+ getReactSource: getReactSourceFn,
605
+ screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
606
+ resetPlaywright: vmContextObj.resetPlaywright,
607
+ require: sandboxedRequire,
608
+ import: vmContextObj.import,
609
+ ...usefulGlobals,
610
+ }
611
+ Object.keys(vmContextObj).forEach((key) => delete (vmContextObj as any)[key])
612
+ Object.assign(vmContextObj, resetObj)
613
+ return { page: newPage, context: newContext }
614
+ },
615
+ require: sandboxedRequire,
616
+ import: (specifier: string) => import(specifier),
617
+ ...usefulGlobals,
618
+ }
619
+
620
+ const vmContext = vm.createContext(vmContextObj)
621
+
622
+ const wrappedCode = `(async () => { ${code} })()`
623
+
624
+ const result = await Promise.race([
625
+ vm.runInContext(wrappedCode, vmContext, {
626
+ timeout,
627
+ displayErrors: true,
628
+ }),
629
+ new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
630
+ ])
631
+
632
+ let responseText = formatConsoleLogs(consoleLogs)
633
+
634
+ if (result !== undefined) {
635
+ responseText += 'Return value:\n'
636
+ if (typeof result === 'string') {
637
+ responseText += result
638
+ } else {
639
+ responseText += JSON.stringify(result, null, 2)
640
+ }
641
+ } else if (consoleLogs.length === 0) {
642
+ responseText += 'Code executed successfully (no output)'
643
+ }
644
+
645
+ for (const screenshot of screenshotCollector) {
646
+ responseText += `\nScreenshot saved to: ${screenshot.path}\n`
647
+ responseText += `Labels shown: ${screenshot.labelCount}\n\n`
648
+ responseText += `Accessibility snapshot:\n${screenshot.snapshot}\n`
649
+ }
650
+
651
+ const MAX_LENGTH = 5000
652
+ let finalText = responseText.trim()
653
+ if (finalText.length > MAX_LENGTH) {
654
+ finalText =
655
+ finalText.slice(0, MAX_LENGTH) +
656
+ `\n\n[Truncated to ${MAX_LENGTH} characters. Better manage your logs or paginate them to read the full logs]`
657
+ }
658
+
659
+ const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [
660
+ {
661
+ type: 'text',
662
+ text: finalText,
663
+ },
664
+ ]
665
+
666
+ for (const screenshot of screenshotCollector) {
667
+ content.push({
668
+ type: 'image',
669
+ data: screenshot.base64,
670
+ mimeType: screenshot.mimeType,
671
+ })
672
+ }
673
+
674
+ // Remove network listeners after code execution
675
+ networkCapture.cleanup()
676
+
677
+ const snapshotPaths = await savePageSnapshots({ page, lastSnapshots, timeout })
678
+ if (snapshotPaths) {
679
+ const firstItem = content[0]
680
+ if (firstItem.type === 'text') {
681
+ let prependedText = `Full snapshot: ${snapshotPaths.fullPath}`
682
+
683
+ // Save captured network requests to the same snapshot folder
684
+ const snapshotDir = path.dirname(snapshotPaths.fullPath)
685
+ const networksPath = saveNetworkRequests({ requests: networkCapture.requests, snapshotDir })
686
+ if (networksPath) {
687
+ prependedText += `\nNetworks: ${networksPath}`
688
+ }
689
+
690
+ let appendedText = ''
691
+ if (snapshotPaths.incremental) {
692
+ if (snapshotPaths.incremental.length > 2000) {
693
+ appendedText = `\n\nIncremental snapshot (truncated, full content at ${snapshotPaths.incrementalPath}):\n${snapshotPaths.incremental.slice(0, 2000)}...`
694
+ } else {
695
+ appendedText = `\n\nIncremental snapshot:\n${snapshotPaths.incremental}`
696
+ }
697
+ }
698
+
699
+ content[0] = {
700
+ type: 'text',
701
+ text: `${prependedText}\n\n${firstItem.text}${appendedText}`,
702
+ }
703
+ }
704
+ }
705
+
706
+ appendToRecording({})
707
+
708
+ return { content }
709
+ } catch (error: any) {
710
+ const errorStack = error.stack || error.message
711
+ const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError'
712
+
713
+ console.error('Error in execute:', errorStack)
714
+ if (!isTimeoutError) {
715
+ sendLogToRelayServer('error', 'Error in execute:', errorStack)
716
+ }
717
+
718
+ const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
719
+
720
+ const resetHint = isTimeoutError
721
+ ? ''
722
+ : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]'
723
+
724
+ let errorText = `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`
725
+
726
+ // Remove network listeners on error
727
+ networkCapture.cleanup()
728
+
729
+ const snapshotPaths = await savePageSnapshots({ page, lastSnapshots, timeout })
730
+ if (snapshotPaths) {
731
+ let snapshotText = `Full snapshot: ${snapshotPaths.fullPath}`
732
+
733
+ // Save captured network requests on error too
734
+ const snapshotDir = path.dirname(snapshotPaths.fullPath)
735
+ const networksPath = saveNetworkRequests({ requests: networkCapture.requests, snapshotDir })
736
+ if (networksPath) {
737
+ snapshotText += `\nNetworks: ${networksPath}`
738
+ }
739
+
740
+ if (snapshotPaths.incremental) {
741
+ if (snapshotPaths.incremental.length > 2000) {
742
+ snapshotText += `\n\nIncremental snapshot (truncated, full content at ${snapshotPaths.incrementalPath}):\n${snapshotPaths.incremental.slice(0, 2000)}...`
743
+ } else {
744
+ snapshotText += `\n\nIncremental snapshot:\n${snapshotPaths.incremental}`
745
+ }
746
+ }
747
+
748
+ errorText = `${snapshotText}\n\n${errorText}`
749
+ }
750
+
751
+ appendToRecording({ failed: error.message })
752
+
753
+ return {
754
+ content: [
755
+ {
756
+ type: 'text',
757
+ text: errorText,
758
+ },
759
+ ],
760
+ isError: true,
761
+ }
762
+ }
763
+ }