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
@@ -0,0 +1,610 @@
1
+ import type { Page, Locator, ElementHandle } from 'playwright-core'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ export interface AriaRef {
7
+ role: string
8
+ name: string
9
+ ref: string
10
+ }
11
+
12
+ export interface ScreenshotResult {
13
+ path: string
14
+ base64: string
15
+ mimeType: 'image/jpeg'
16
+ snapshot: string
17
+ labelCount: number
18
+ }
19
+
20
+ export interface AriaSnapshotResult {
21
+ snapshot: string
22
+ refToElement: Map<string, { role: string; name: string }>
23
+ refHandles: Array<{ ref: string; handle: ElementHandle }>
24
+ getRefsForLocators: (locators: Array<Locator | ElementHandle>) => Promise<Array<AriaRef | null>>
25
+ getRefForLocator: (locator: Locator | ElementHandle) => Promise<AriaRef | null>
26
+ getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null>
27
+ }
28
+
29
+ const LABELS_CONTAINER_ID = '__e2e_pilot_labels__'
30
+
31
+ // Roles that represent interactive elements (clickable, typeable) and media elements
32
+ const INTERACTIVE_ROLES = new Set([
33
+ 'button',
34
+ 'link',
35
+ 'textbox',
36
+ 'combobox',
37
+ 'searchbox',
38
+ 'checkbox',
39
+ 'radio',
40
+ 'slider',
41
+ 'spinbutton',
42
+ 'switch',
43
+ 'menuitem',
44
+ 'menuitemcheckbox',
45
+ 'menuitemradio',
46
+ 'option',
47
+ 'tab',
48
+ 'treeitem',
49
+ // Media elements - useful for visual tasks
50
+ 'img',
51
+ 'video',
52
+ 'audio',
53
+ ])
54
+
55
+ // Color categories for different role types - warm color scheme
56
+ // Format: [gradient-top, gradient-bottom, border]
57
+ const ROLE_COLORS: Record<string, [string, string, string]> = {
58
+ // Links - yellow (Vimium-style)
59
+ link: ['#FFF785', '#FFC542', '#E3BE23'],
60
+ // Buttons - orange
61
+ button: ['#FFE0B2', '#FFCC80', '#FFB74D'],
62
+ // Text inputs - coral/red
63
+ textbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
64
+ combobox: ['#FFCDD2', '#EF9A9A', '#E57373'],
65
+ searchbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
66
+ spinbutton: ['#FFCDD2', '#EF9A9A', '#E57373'],
67
+ // Checkboxes/Radios/Switches - warm pink
68
+ checkbox: ['#F8BBD0', '#F48FB1', '#EC407A'],
69
+ radio: ['#F8BBD0', '#F48FB1', '#EC407A'],
70
+ switch: ['#F8BBD0', '#F48FB1', '#EC407A'],
71
+ // Sliders - peach
72
+ slider: ['#FFCCBC', '#FFAB91', '#FF8A65'],
73
+ // Menu items - salmon
74
+ menuitem: ['#FFAB91', '#FF8A65', '#FF7043'],
75
+ menuitemcheckbox: ['#FFAB91', '#FF8A65', '#FF7043'],
76
+ menuitemradio: ['#FFAB91', '#FF8A65', '#FF7043'],
77
+ // Tabs/Options - amber
78
+ tab: ['#FFE082', '#FFD54F', '#FFC107'],
79
+ option: ['#FFE082', '#FFD54F', '#FFC107'],
80
+ treeitem: ['#FFE082', '#FFD54F', '#FFC107'],
81
+ // Media elements - light blue
82
+ img: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
83
+ video: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
84
+ audio: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
85
+ }
86
+
87
+ // Default yellow for unknown roles
88
+ const DEFAULT_COLORS: [string, string, string] = ['#FFF785', '#FFC542', '#E3BE23']
89
+
90
+ // Use String.raw for CSS syntax highlighting in editors
91
+ const css = String.raw
92
+
93
+ const LABEL_STYLES = css`
94
+ .__pw_label__ {
95
+ position: absolute;
96
+ font: bold 12px Helvetica, Arial, sans-serif;
97
+ padding: 1px 4px;
98
+ border-radius: 3px;
99
+ color: black;
100
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
101
+ white-space: nowrap;
102
+ }
103
+ `
104
+
105
+ const CONTAINER_STYLES = css`
106
+ position: absolute;
107
+ left: 0;
108
+ top: 0;
109
+ z-index: 2147483647;
110
+ pointer-events: none;
111
+ `
112
+
113
+ /**
114
+ * Get an accessibility snapshot with utilities to look up aria refs for elements.
115
+ * Uses Playwright's internal aria-ref selector engine.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const { snapshot, getRefsForLocators } = await getAriaSnapshot({ page })
120
+ * const refs = await getRefsForLocators([page.locator('button'), page.locator('a')])
121
+ * // refs[0].ref is e.g. "e5" - use page.locator('aria-ref=e5') to select
122
+ * ```
123
+ */
124
+ export async function getAriaSnapshot({ page }: { page: Page }): Promise<AriaSnapshotResult> {
125
+ const snapshotMethod = (page as any)._snapshotForAI
126
+ if (!snapshotMethod) {
127
+ throw new Error('_snapshotForAI not available. Ensure you are using Playwright.')
128
+ }
129
+
130
+ const snapshot = await snapshotMethod.call(page)
131
+ // Sanitize to remove unpaired surrogates that break JSON encoding for Claude API
132
+ const rawStr = typeof snapshot === 'string' ? snapshot : (snapshot.full || JSON.stringify(snapshot, null, 2))
133
+ const snapshotStr = rawStr.toWellFormed?.() ?? rawStr
134
+
135
+ // Discover refs by probing aria-ref=e1, e2, e3... until 10 consecutive misses
136
+ const refToElement = new Map<string, { role: string; name: string }>()
137
+ const refHandles: Array<{ ref: string; handle: ElementHandle }> = []
138
+
139
+ let consecutiveMisses = 0
140
+ let refNum = 1
141
+
142
+ while (consecutiveMisses < 10) {
143
+ const ref = `e${refNum++}`
144
+ try {
145
+ const locator = page.locator(`aria-ref=${ref}`)
146
+ if (await locator.count() === 1) {
147
+ consecutiveMisses = 0
148
+ const [info, handle] = await Promise.all([
149
+ locator.evaluate((el: any) => ({
150
+ role: el.getAttribute('role') || {
151
+ a: el.hasAttribute('href') ? 'link' : 'generic',
152
+ button: 'button', input: { button: 'button', checkbox: 'checkbox', radio: 'radio',
153
+ text: 'textbox', search: 'searchbox', number: 'spinbutton', range: 'slider',
154
+ }[el.type] || 'textbox', select: 'combobox', textarea: 'textbox', img: 'img',
155
+ nav: 'navigation', main: 'main', header: 'banner', footer: 'contentinfo',
156
+ }[el.tagName.toLowerCase()] || 'generic',
157
+ name: el.getAttribute('aria-label') || el.textContent?.trim() || el.placeholder || '',
158
+ })),
159
+ locator.elementHandle({ timeout: 1000 }),
160
+ ])
161
+ refToElement.set(ref, info)
162
+ if (handle) {
163
+ refHandles.push({ ref, handle })
164
+ }
165
+ } else {
166
+ consecutiveMisses++
167
+ }
168
+ } catch {
169
+ consecutiveMisses++
170
+ }
171
+ }
172
+
173
+ // Find refs for multiple locators in a single evaluate call
174
+ const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => {
175
+ if (locators.length === 0 || refHandles.length === 0) {
176
+ return locators.map(() => null)
177
+ }
178
+
179
+ const targetHandles = await Promise.all(
180
+ locators.map(async (loc) => {
181
+ try {
182
+ return 'elementHandle' in loc
183
+ ? await (loc as Locator).elementHandle({ timeout: 1000 })
184
+ : (loc as ElementHandle)
185
+ } catch {
186
+ return null
187
+ }
188
+ })
189
+ )
190
+
191
+ const matchingRefs = await page.evaluate(
192
+ ({ targets, candidates }) => targets.map((target) => {
193
+ if (!target) return null
194
+ return candidates.find(({ element }) => element === target)?.ref ?? null
195
+ }),
196
+ { targets: targetHandles, candidates: refHandles.map(({ ref, handle }) => ({ ref, element: handle })) }
197
+ )
198
+
199
+ return matchingRefs.map((ref) => {
200
+ if (!ref) return null
201
+ const info = refToElement.get(ref)
202
+ return info ? { ...info, ref } : null
203
+ })
204
+ }
205
+
206
+ return {
207
+ snapshot: snapshotStr,
208
+ refToElement,
209
+ refHandles,
210
+ getRefsForLocators,
211
+ getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0],
212
+ getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null,
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Show Vimium-style labels on interactive elements.
218
+ * Labels are yellow badges positioned above each element showing the aria ref (e.g., "e1", "e2").
219
+ * Use with screenshots so agents can see which elements are interactive.
220
+ *
221
+ * Labels auto-hide after 30 seconds to prevent stale labels remaining on the page.
222
+ * Call this function again if the page HTML changes to get fresh labels.
223
+ *
224
+ * By default, only shows labels for truly interactive roles (button, link, textbox, etc.)
225
+ * to reduce visual clutter. Set `interactiveOnly: false` to show all elements with refs.
226
+ *
227
+ * @example
228
+ * ```ts
229
+ * const { snapshot, labelCount } = await showAriaRefLabels({ page })
230
+ * await page.screenshot({ path: '/tmp/screenshot.png' })
231
+ * // Agent sees [e5] label on "Submit" button
232
+ * await page.locator('aria-ref=e5').click()
233
+ * // Labels auto-hide after 30 seconds, or call hideAriaRefLabels() manually
234
+ * ```
235
+ */
236
+ export async function showAriaRefLabels({ page, interactiveOnly = true }: {
237
+ page: Page
238
+ interactiveOnly?: boolean
239
+ }): Promise<{
240
+ snapshot: string
241
+ labelCount: number
242
+ }> {
243
+ const { snapshot, refHandles, refToElement } = await getAriaSnapshot({ page })
244
+
245
+ // Filter to only interactive elements if requested
246
+ const filteredRefs = interactiveOnly
247
+ ? refHandles.filter(({ ref }) => {
248
+ const info = refToElement.get(ref)
249
+ return info && INTERACTIVE_ROLES.has(info.role)
250
+ })
251
+ : refHandles
252
+
253
+ // Build refs with role info for color coding
254
+ const refsWithRoles = filteredRefs.map(({ ref, handle }) => ({
255
+ ref,
256
+ element: handle,
257
+ role: refToElement.get(ref)?.role || 'generic',
258
+ }))
259
+
260
+ // Single evaluate call: create container, styles, and all labels
261
+ // ElementHandles get unwrapped to DOM elements in browser context
262
+ const labelCount = await page.evaluate(
263
+ // Using 'any' for browser types since this runs in browser context
264
+ function ({ refs, containerId, containerStyles, labelStyles, roleColors, defaultColors }: {
265
+ refs: Array<{
266
+ ref: string
267
+ role: string
268
+ element: any // Element in browser context
269
+ }>
270
+ containerId: string
271
+ containerStyles: string
272
+ labelStyles: string
273
+ roleColors: Record<string, [string, string, string]>
274
+ defaultColors: [string, string, string]
275
+ }) {
276
+ // Polyfill esbuild's __name helper which gets injected by vite-node but doesn't exist in browser
277
+ ;(globalThis as any).__name ||= (fn: any) => fn
278
+ const doc = (globalThis as any).document
279
+ const win = globalThis as any
280
+
281
+ // Cancel any pending auto-hide timer from previous call
282
+ const timerKey = '__e2e_pilot_labels_timer__'
283
+ if (win[timerKey]) {
284
+ win.clearTimeout(win[timerKey])
285
+ win[timerKey] = null
286
+ }
287
+
288
+ // Remove existing labels if present (idempotent)
289
+ doc.getElementById(containerId)?.remove()
290
+
291
+ // Create container - absolute positioned, max z-index, no pointer events
292
+ const container = doc.createElement('div')
293
+ container.id = containerId
294
+ container.style.cssText = containerStyles
295
+
296
+ // Inject base label CSS
297
+ const style = doc.createElement('style')
298
+ style.textContent = labelStyles
299
+ container.appendChild(style)
300
+
301
+ // Track placed label rectangles for overlap detection
302
+ // Each rect is { left, top, right, bottom } in viewport coordinates
303
+ const placedLabels: Array<{ left: number; top: number; right: number; bottom: number }> = []
304
+
305
+ // Estimate label dimensions (12px font + padding)
306
+ const LABEL_HEIGHT = 17
307
+ const LABEL_CHAR_WIDTH = 7 // approximate width per character
308
+
309
+ // Parse alpha from rgb/rgba color string (getComputedStyle always returns these formats)
310
+ function getColorAlpha(color: string): number {
311
+ if (color === 'transparent') {
312
+ return 0
313
+ }
314
+ // Match rgba(r, g, b, a) or rgb(r, g, b)
315
+ const match = color.match(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+)\s*)?\)/)
316
+ if (match) {
317
+ return match[1] !== undefined ? parseFloat(match[1]) : 1
318
+ }
319
+ return 1 // Default to opaque for unrecognized formats
320
+ }
321
+
322
+ // Check if an element has an opaque background that would block elements behind it
323
+ function isOpaqueElement(el: any): boolean {
324
+ const style = win.getComputedStyle(el)
325
+
326
+ // Check element opacity
327
+ const opacity = parseFloat(style.opacity)
328
+ if (opacity < 0.1) {
329
+ return false
330
+ }
331
+
332
+ // Check background-color alpha
333
+ const bgAlpha = getColorAlpha(style.backgroundColor)
334
+ if (bgAlpha > 0.1) {
335
+ return true
336
+ }
337
+
338
+ // Check if has background-image (usually opaque)
339
+ if (style.backgroundImage !== 'none') {
340
+ return true
341
+ }
342
+
343
+ return false
344
+ }
345
+
346
+ // Check if element is visible (not covered by opaque overlay)
347
+ function isElementVisible(element: any, rect: any): boolean {
348
+ const centerX = rect.left + rect.width / 2
349
+ const centerY = rect.top + rect.height / 2
350
+
351
+ // Get all elements at this point, from top to bottom
352
+ const stack = doc.elementsFromPoint(centerX, centerY) as any[]
353
+
354
+ // Find our target element in the stack
355
+ let targetIndex = -1
356
+ for (let i = 0; i < stack.length; i++) {
357
+ if (element.contains(stack[i]) || stack[i].contains(element)) {
358
+ targetIndex = i
359
+ break
360
+ }
361
+ }
362
+
363
+ // Element not in stack at all - not visible
364
+ if (targetIndex === -1) {
365
+ return false
366
+ }
367
+
368
+ // Check if any opaque element is above our target
369
+ for (let i = 0; i < targetIndex; i++) {
370
+ const el = stack[i]
371
+ // Skip our own overlay container
372
+ if (el.id === containerId) {
373
+ continue
374
+ }
375
+ // Skip pointer-events: none elements (decorative overlays)
376
+ if (win.getComputedStyle(el).pointerEvents === 'none') {
377
+ continue
378
+ }
379
+ // If this element is opaque, our target is blocked
380
+ if (isOpaqueElement(el)) {
381
+ return false
382
+ }
383
+ }
384
+
385
+ return true
386
+ }
387
+
388
+ // Check if two rectangles overlap
389
+ function rectsOverlap(
390
+ a: { left: number; top: number; right: number; bottom: number },
391
+ b: { left: number; top: number; right: number; bottom: number }
392
+ ) {
393
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
394
+ }
395
+
396
+ // Create SVG for connector lines
397
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
398
+ svg.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;overflow:visible;'
399
+ svg.setAttribute('width', `${doc.documentElement.scrollWidth}`)
400
+ svg.setAttribute('height', `${doc.documentElement.scrollHeight}`)
401
+
402
+ // Create defs for arrow markers (one per color)
403
+ const defs = doc.createElementNS('http://www.w3.org/2000/svg', 'defs')
404
+ svg.appendChild(defs)
405
+ const markerCache: Record<string, string> = {}
406
+
407
+ function getArrowMarkerId(color: string): string {
408
+ if (markerCache[color]) {
409
+ return markerCache[color]
410
+ }
411
+ const markerId = `arrow-${color.replace('#', '')}`
412
+ const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'marker')
413
+ marker.setAttribute('id', markerId)
414
+ marker.setAttribute('viewBox', '0 0 10 10')
415
+ marker.setAttribute('refX', '9')
416
+ marker.setAttribute('refY', '5')
417
+ marker.setAttribute('markerWidth', '6')
418
+ marker.setAttribute('markerHeight', '6')
419
+ marker.setAttribute('orient', 'auto-start-reverse')
420
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
421
+ path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z')
422
+ path.setAttribute('fill', color)
423
+ marker.appendChild(path)
424
+ defs.appendChild(marker)
425
+ markerCache[color] = markerId
426
+ return markerId
427
+ }
428
+
429
+ container.appendChild(svg)
430
+
431
+ // Create label for each interactive element
432
+ let count = 0
433
+ for (const { ref, role, element } of refs) {
434
+ const rect = element.getBoundingClientRect()
435
+
436
+ // Skip elements with no size (hidden)
437
+ if (rect.width === 0 || rect.height === 0) {
438
+ continue
439
+ }
440
+
441
+ // Skip elements that are covered by opaque overlays
442
+ if (!isElementVisible(element, rect)) {
443
+ continue
444
+ }
445
+
446
+ // Calculate label position and dimensions
447
+ const labelWidth = ref.length * LABEL_CHAR_WIDTH + 8 // +8 for padding
448
+ const labelLeft = rect.left
449
+ const labelTop = Math.max(0, rect.top - LABEL_HEIGHT)
450
+ const labelRect = {
451
+ left: labelLeft,
452
+ top: labelTop,
453
+ right: labelLeft + labelWidth,
454
+ bottom: labelTop + LABEL_HEIGHT,
455
+ }
456
+
457
+ // Skip if this label would overlap with any already-placed label
458
+ let overlaps = false
459
+ for (const placed of placedLabels) {
460
+ if (rectsOverlap(labelRect, placed)) {
461
+ overlaps = true
462
+ break
463
+ }
464
+ }
465
+ if (overlaps) {
466
+ continue
467
+ }
468
+
469
+ // Get colors for this role
470
+ const [gradTop, gradBottom, border] = roleColors[role] || defaultColors
471
+
472
+ // Place the label
473
+ const label = doc.createElement('div')
474
+ label.className = '__pw_label__'
475
+ label.textContent = ref
476
+ label.style.background = `linear-gradient(to bottom, ${gradTop} 0%, ${gradBottom} 100%)`
477
+ label.style.border = `1px solid ${border}`
478
+
479
+ // Position above element, accounting for scroll
480
+ label.style.left = `${win.scrollX + labelLeft}px`
481
+ label.style.top = `${win.scrollY + labelTop}px`
482
+
483
+ container.appendChild(label)
484
+
485
+ // Draw connector line from label bottom-center to element center with arrow
486
+ const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line')
487
+ const labelCenterX = win.scrollX + labelLeft + labelWidth / 2
488
+ const labelBottomY = win.scrollY + labelTop + LABEL_HEIGHT
489
+ const elementCenterX = win.scrollX + rect.left + rect.width / 2
490
+ const elementCenterY = win.scrollY + rect.top + rect.height / 2
491
+ line.setAttribute('x1', `${labelCenterX}`)
492
+ line.setAttribute('y1', `${labelBottomY}`)
493
+ line.setAttribute('x2', `${elementCenterX}`)
494
+ line.setAttribute('y2', `${elementCenterY}`)
495
+ line.setAttribute('stroke', border)
496
+ line.setAttribute('stroke-width', '1.5')
497
+ line.setAttribute('marker-end', `url(#${getArrowMarkerId(border)})`)
498
+ svg.appendChild(line)
499
+
500
+ placedLabels.push(labelRect)
501
+ count++
502
+ }
503
+
504
+ doc.documentElement.appendChild(container)
505
+
506
+ // Auto-hide labels after 30 seconds to prevent stale labels
507
+ // Store timer ID so it can be cancelled if showAriaRefLabels is called again
508
+ win[timerKey] = win.setTimeout(function() {
509
+ doc.getElementById(containerId)?.remove()
510
+ win[timerKey] = null
511
+ }, 30000)
512
+
513
+ return count
514
+ },
515
+ {
516
+ refs: refsWithRoles.map(({ ref, role, element }) => ({ ref, role, element })),
517
+ containerId: LABELS_CONTAINER_ID,
518
+ containerStyles: CONTAINER_STYLES,
519
+ labelStyles: LABEL_STYLES,
520
+ roleColors: ROLE_COLORS,
521
+ defaultColors: DEFAULT_COLORS,
522
+ }
523
+ )
524
+
525
+ return { snapshot, labelCount }
526
+ }
527
+
528
+ /**
529
+ * Remove all aria ref labels from the page.
530
+ */
531
+ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> {
532
+ await page.evaluate((id) => {
533
+ const doc = (globalThis as any).document
534
+ const win = globalThis as any
535
+
536
+ // Cancel any pending auto-hide timer
537
+ const timerKey = '__e2e_pilot_labels_timer__'
538
+ if (win[timerKey]) {
539
+ win.clearTimeout(win[timerKey])
540
+ win[timerKey] = null
541
+ }
542
+
543
+ doc.getElementById(id)?.remove()
544
+ }, LABELS_CONTAINER_ID)
545
+ }
546
+
547
+ /**
548
+ * Take a screenshot with accessibility labels overlaid on interactive elements.
549
+ * Shows Vimium-style labels, captures the screenshot, then removes the labels.
550
+ * The screenshot is automatically included in the MCP response.
551
+ *
552
+ * @param collector - Array to collect screenshots (passed by MCP execute tool)
553
+ *
554
+ * @example
555
+ * ```ts
556
+ * await screenshotWithAccessibilityLabels({ page })
557
+ * // Screenshot is automatically included in the MCP response
558
+ * // Use aria-ref from the snapshot to interact with elements
559
+ * await page.locator('aria-ref=e5').click()
560
+ * ```
561
+ */
562
+ export async function screenshotWithAccessibilityLabels({ page, interactiveOnly = true, collector }: {
563
+ page: Page
564
+ interactiveOnly?: boolean
565
+ collector: ScreenshotResult[]
566
+ }): Promise<void> {
567
+ // Show labels and get snapshot
568
+ const { snapshot, labelCount } = await showAriaRefLabels({ page, interactiveOnly })
569
+
570
+ // Generate unique filename with timestamp
571
+ const timestamp = Date.now()
572
+ const random = Math.random().toString(36).slice(2, 6)
573
+ const filename = `e2e-pilot-screenshot-${timestamp}-${random}.jpg`
574
+
575
+ // Use ./tmp folder (gitignored) instead of system temp
576
+ const tmpDir = path.join(process.cwd(), 'tmp')
577
+ if (!fs.existsSync(tmpDir)) {
578
+ fs.mkdirSync(tmpDir, { recursive: true })
579
+ }
580
+ const screenshotPath = path.join(tmpDir, filename)
581
+
582
+ // Get actual viewport size (innerWidth/innerHeight, not outer window size)
583
+ const viewport = await page.evaluate('(() => ({ width: window.innerWidth, height: window.innerHeight }))()') as { width: number; height: number }
584
+
585
+ // Take screenshot clipped to actual viewport (excludes browser chrome)
586
+ const buffer = await page.screenshot({
587
+ type: 'jpeg',
588
+ quality: 80,
589
+ scale: 'css',
590
+ clip: { x: 0, y: 0, width: viewport.width, height: viewport.height },
591
+ })
592
+
593
+ // Save to file
594
+ fs.writeFileSync(screenshotPath, buffer)
595
+
596
+ // Convert to base64
597
+ const base64 = buffer.toString('base64')
598
+
599
+ // Hide labels
600
+ await hideAriaRefLabels({ page })
601
+
602
+ // Add to collector array
603
+ collector.push({
604
+ path: screenshotPath,
605
+ base64,
606
+ mimeType: 'image/jpeg',
607
+ snapshot,
608
+ labelCount,
609
+ })
610
+ }