playwriter 0.0.33 → 0.0.37

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 (65) hide show
  1. package/dist/aria-snapshot.d.ts +68 -0
  2. package/dist/aria-snapshot.d.ts.map +1 -0
  3. package/dist/aria-snapshot.js +359 -0
  4. package/dist/aria-snapshot.js.map +1 -0
  5. package/dist/cdp-relay.d.ts.map +1 -1
  6. package/dist/cdp-relay.js +95 -5
  7. package/dist/cdp-relay.js.map +1 -1
  8. package/dist/cdp-session.d.ts +24 -3
  9. package/dist/cdp-session.d.ts.map +1 -1
  10. package/dist/cdp-session.js +23 -0
  11. package/dist/cdp-session.js.map +1 -1
  12. package/dist/debugger-api.md +4 -3
  13. package/dist/debugger.d.ts +4 -3
  14. package/dist/debugger.d.ts.map +1 -1
  15. package/dist/debugger.js +3 -1
  16. package/dist/debugger.js.map +1 -1
  17. package/dist/editor-api.md +2 -2
  18. package/dist/editor.d.ts +2 -2
  19. package/dist/editor.d.ts.map +1 -1
  20. package/dist/editor.js +1 -0
  21. package/dist/editor.js.map +1 -1
  22. package/dist/index.d.ts +8 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/mcp.d.ts.map +1 -1
  27. package/dist/mcp.js +151 -14
  28. package/dist/mcp.js.map +1 -1
  29. package/dist/mcp.test.js +340 -5
  30. package/dist/mcp.test.js.map +1 -1
  31. package/dist/protocol.d.ts +12 -1
  32. package/dist/protocol.d.ts.map +1 -1
  33. package/dist/react-source.d.ts +3 -3
  34. package/dist/react-source.d.ts.map +1 -1
  35. package/dist/react-source.js +3 -1
  36. package/dist/react-source.js.map +1 -1
  37. package/dist/scoped-fs.d.ts +94 -0
  38. package/dist/scoped-fs.d.ts.map +1 -0
  39. package/dist/scoped-fs.js +356 -0
  40. package/dist/scoped-fs.js.map +1 -0
  41. package/dist/styles-api.md +3 -3
  42. package/dist/styles.d.ts +3 -3
  43. package/dist/styles.d.ts.map +1 -1
  44. package/dist/styles.js +3 -1
  45. package/dist/styles.js.map +1 -1
  46. package/package.json +13 -13
  47. package/src/aria-snapshot.ts +446 -0
  48. package/src/assets/aria-labels-github-snapshot.txt +605 -0
  49. package/src/assets/aria-labels-github.png +0 -0
  50. package/src/assets/aria-labels-google-snapshot.txt +110 -0
  51. package/src/assets/aria-labels-google.png +0 -0
  52. package/src/assets/aria-labels-hacker-news-snapshot.txt +1023 -0
  53. package/src/assets/aria-labels-hacker-news.png +0 -0
  54. package/src/cdp-relay.ts +103 -5
  55. package/src/cdp-session.ts +50 -3
  56. package/src/debugger.ts +6 -4
  57. package/src/editor.ts +4 -3
  58. package/src/index.ts +8 -0
  59. package/src/mcp.test.ts +424 -5
  60. package/src/mcp.ts +242 -66
  61. package/src/prompt.md +209 -167
  62. package/src/protocol.ts +14 -1
  63. package/src/react-source.ts +5 -3
  64. package/src/scoped-fs.ts +411 -0
  65. package/src/styles.ts +5 -3
@@ -0,0 +1,446 @@
1
+ import type { Page, Locator, ElementHandle } from 'playwright-core'
2
+
3
+ export interface AriaRef {
4
+ role: string
5
+ name: string
6
+ ref: string
7
+ }
8
+
9
+ export interface AriaSnapshotResult {
10
+ snapshot: string
11
+ refToElement: Map<string, { role: string; name: string }>
12
+ refHandles: Array<{ ref: string; handle: ElementHandle }>
13
+ getRefsForLocators: (locators: Array<Locator | ElementHandle>) => Promise<Array<AriaRef | null>>
14
+ getRefForLocator: (locator: Locator | ElementHandle) => Promise<AriaRef | null>
15
+ getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null>
16
+ }
17
+
18
+ const LABELS_CONTAINER_ID = '__playwriter_labels__'
19
+
20
+ // Roles that represent truly interactive elements (can be clicked, typed into, etc.)
21
+ const INTERACTIVE_ROLES = new Set([
22
+ 'button',
23
+ 'link',
24
+ 'textbox',
25
+ 'combobox',
26
+ 'searchbox',
27
+ 'checkbox',
28
+ 'radio',
29
+ 'slider',
30
+ 'spinbutton',
31
+ 'switch',
32
+ 'menuitem',
33
+ 'menuitemcheckbox',
34
+ 'menuitemradio',
35
+ 'option',
36
+ 'tab',
37
+ 'treeitem',
38
+ ])
39
+
40
+ // Color categories for different role types - warm color scheme
41
+ // Format: [gradient-top, gradient-bottom, border]
42
+ const ROLE_COLORS: Record<string, [string, string, string]> = {
43
+ // Links - yellow (Vimium-style)
44
+ link: ['#FFF785', '#FFC542', '#E3BE23'],
45
+ // Buttons - orange
46
+ button: ['#FFE0B2', '#FFCC80', '#FFB74D'],
47
+ // Text inputs - coral/red
48
+ textbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
49
+ combobox: ['#FFCDD2', '#EF9A9A', '#E57373'],
50
+ searchbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
51
+ spinbutton: ['#FFCDD2', '#EF9A9A', '#E57373'],
52
+ // Checkboxes/Radios/Switches - warm pink
53
+ checkbox: ['#F8BBD0', '#F48FB1', '#EC407A'],
54
+ radio: ['#F8BBD0', '#F48FB1', '#EC407A'],
55
+ switch: ['#F8BBD0', '#F48FB1', '#EC407A'],
56
+ // Sliders - peach
57
+ slider: ['#FFCCBC', '#FFAB91', '#FF8A65'],
58
+ // Menu items - salmon
59
+ menuitem: ['#FFAB91', '#FF8A65', '#FF7043'],
60
+ menuitemcheckbox: ['#FFAB91', '#FF8A65', '#FF7043'],
61
+ menuitemradio: ['#FFAB91', '#FF8A65', '#FF7043'],
62
+ // Tabs/Options - amber
63
+ tab: ['#FFE082', '#FFD54F', '#FFC107'],
64
+ option: ['#FFE082', '#FFD54F', '#FFC107'],
65
+ treeitem: ['#FFE082', '#FFD54F', '#FFC107'],
66
+ }
67
+
68
+ // Default yellow for unknown roles
69
+ const DEFAULT_COLORS: [string, string, string] = ['#FFF785', '#FFC542', '#E3BE23']
70
+
71
+ // Use String.raw for CSS syntax highlighting in editors
72
+ const css = String.raw
73
+
74
+ const LABEL_STYLES = css`
75
+ .__pw_label__ {
76
+ position: absolute;
77
+ font: bold 11px Helvetica, Arial, sans-serif;
78
+ padding: 1px 4px;
79
+ border-radius: 3px;
80
+ color: black;
81
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
82
+ white-space: nowrap;
83
+ }
84
+ `
85
+
86
+ const CONTAINER_STYLES = css`
87
+ position: absolute;
88
+ left: 0;
89
+ top: 0;
90
+ z-index: 2147483647;
91
+ pointer-events: none;
92
+ `
93
+
94
+ /**
95
+ * Get an accessibility snapshot with utilities to look up aria refs for elements.
96
+ * Uses Playwright's internal aria-ref selector engine.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * const { snapshot, getRefsForLocators } = await getAriaSnapshot({ page })
101
+ * const refs = await getRefsForLocators([page.locator('button'), page.locator('a')])
102
+ * // refs[0].ref is e.g. "e5" - use page.locator('aria-ref=e5') to select
103
+ * ```
104
+ */
105
+ export async function getAriaSnapshot({ page }: { page: Page }): Promise<AriaSnapshotResult> {
106
+ const snapshotMethod = (page as any)._snapshotForAI
107
+ if (!snapshotMethod) {
108
+ throw new Error('_snapshotForAI not available. Ensure you are using Playwright.')
109
+ }
110
+
111
+ const snapshot = await snapshotMethod.call(page)
112
+ const snapshotStr = typeof snapshot === 'string' ? snapshot : (snapshot.full || JSON.stringify(snapshot, null, 2))
113
+
114
+ // Discover refs by probing aria-ref=e1, e2, e3... until 10 consecutive misses
115
+ const refToElement = new Map<string, { role: string; name: string }>()
116
+ const refHandles: Array<{ ref: string; handle: ElementHandle }> = []
117
+
118
+ let consecutiveMisses = 0
119
+ let refNum = 1
120
+
121
+ while (consecutiveMisses < 10) {
122
+ const ref = `e${refNum++}`
123
+ try {
124
+ const locator = page.locator(`aria-ref=${ref}`)
125
+ if (await locator.count() === 1) {
126
+ consecutiveMisses = 0
127
+ const [info, handle] = await Promise.all([
128
+ locator.evaluate((el: any) => ({
129
+ role: el.getAttribute('role') || {
130
+ a: el.hasAttribute('href') ? 'link' : 'generic',
131
+ button: 'button', input: { button: 'button', checkbox: 'checkbox', radio: 'radio',
132
+ text: 'textbox', search: 'searchbox', number: 'spinbutton', range: 'slider',
133
+ }[el.type] || 'textbox', select: 'combobox', textarea: 'textbox', img: 'img',
134
+ nav: 'navigation', main: 'main', header: 'banner', footer: 'contentinfo',
135
+ }[el.tagName.toLowerCase()] || 'generic',
136
+ name: el.getAttribute('aria-label') || el.textContent?.trim() || el.placeholder || '',
137
+ })),
138
+ locator.elementHandle({ timeout: 1000 }),
139
+ ])
140
+ refToElement.set(ref, info)
141
+ if (handle) {
142
+ refHandles.push({ ref, handle })
143
+ }
144
+ } else {
145
+ consecutiveMisses++
146
+ }
147
+ } catch {
148
+ consecutiveMisses++
149
+ }
150
+ }
151
+
152
+ // Find refs for multiple locators in a single evaluate call
153
+ const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => {
154
+ if (locators.length === 0 || refHandles.length === 0) {
155
+ return locators.map(() => null)
156
+ }
157
+
158
+ const targetHandles = await Promise.all(
159
+ locators.map(async (loc) => {
160
+ try {
161
+ return 'elementHandle' in loc
162
+ ? await (loc as Locator).elementHandle({ timeout: 1000 })
163
+ : (loc as ElementHandle)
164
+ } catch {
165
+ return null
166
+ }
167
+ })
168
+ )
169
+
170
+ const matchingRefs = await page.evaluate(
171
+ ({ targets, candidates }) => targets.map((target) => {
172
+ if (!target) return null
173
+ return candidates.find(({ element }) => element === target)?.ref ?? null
174
+ }),
175
+ { targets: targetHandles, candidates: refHandles.map(({ ref, handle }) => ({ ref, element: handle })) }
176
+ )
177
+
178
+ return matchingRefs.map((ref) => {
179
+ if (!ref) return null
180
+ const info = refToElement.get(ref)
181
+ return info ? { ...info, ref } : null
182
+ })
183
+ }
184
+
185
+ return {
186
+ snapshot: snapshotStr,
187
+ refToElement,
188
+ refHandles,
189
+ getRefsForLocators,
190
+ getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0],
191
+ getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null,
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Show Vimium-style labels on interactive elements.
197
+ * Labels are yellow badges positioned above each element showing the aria ref (e.g., "e1", "e2").
198
+ * Use with screenshots so agents can see which elements are interactive.
199
+ *
200
+ * Labels auto-hide after 5 seconds to prevent stale labels remaining on the page.
201
+ * Call this function again if the page HTML changes to get fresh labels.
202
+ *
203
+ * By default, only shows labels for truly interactive roles (button, link, textbox, etc.)
204
+ * to reduce visual clutter. Set `interactiveOnly: false` to show all elements with refs.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * const { snapshot, labelCount } = await showAriaRefLabels({ page })
209
+ * await page.screenshot({ path: '/tmp/screenshot.png' })
210
+ * // Agent sees [e5] label on "Submit" button
211
+ * await page.locator('aria-ref=e5').click()
212
+ * // Labels auto-hide after 5 seconds, or call hideAriaRefLabels() manually
213
+ * ```
214
+ */
215
+ export async function showAriaRefLabels({ page, interactiveOnly = true }: {
216
+ page: Page
217
+ interactiveOnly?: boolean
218
+ }): Promise<{
219
+ snapshot: string
220
+ labelCount: number
221
+ }> {
222
+ const { snapshot, refHandles, refToElement } = await getAriaSnapshot({ page })
223
+
224
+ // Filter to only interactive elements if requested
225
+ const filteredRefs = interactiveOnly
226
+ ? refHandles.filter(({ ref }) => {
227
+ const info = refToElement.get(ref)
228
+ return info && INTERACTIVE_ROLES.has(info.role)
229
+ })
230
+ : refHandles
231
+
232
+ // Build refs with role info for color coding
233
+ const refsWithRoles = filteredRefs.map(({ ref, handle }) => ({
234
+ ref,
235
+ element: handle,
236
+ role: refToElement.get(ref)?.role || 'generic',
237
+ }))
238
+
239
+ // Single evaluate call: create container, styles, and all labels
240
+ // ElementHandles get unwrapped to DOM elements in browser context
241
+ // Using 'any' types here since this code runs in browser context
242
+ const labelCount = await page.evaluate(
243
+ // Using 'any' for browser types since this runs in browser context
244
+ ({ refs, containerId, containerStyles, labelStyles, roleColors, defaultColors }: {
245
+ refs: Array<{
246
+ ref: string
247
+ role: string
248
+ element: any // Element in browser context
249
+ }>
250
+ containerId: string
251
+ containerStyles: string
252
+ labelStyles: string
253
+ roleColors: Record<string, [string, string, string]>
254
+ defaultColors: [string, string, string]
255
+ }) => {
256
+ const doc = (globalThis as any).document
257
+ const win = globalThis as any
258
+
259
+ // Cancel any pending auto-hide timer from previous call
260
+ const timerKey = '__playwriter_labels_timer__'
261
+ if (win[timerKey]) {
262
+ win.clearTimeout(win[timerKey])
263
+ win[timerKey] = null
264
+ }
265
+
266
+ // Remove existing labels if present (idempotent)
267
+ doc.getElementById(containerId)?.remove()
268
+
269
+ // Create container - absolute positioned, max z-index, no pointer events
270
+ const container = doc.createElement('div')
271
+ container.id = containerId
272
+ container.style.cssText = containerStyles
273
+
274
+ // Inject base label CSS
275
+ const style = doc.createElement('style')
276
+ style.textContent = labelStyles
277
+ container.appendChild(style)
278
+
279
+ // Track placed label rectangles for overlap detection
280
+ // Each rect is { left, top, right, bottom } in viewport coordinates
281
+ const placedLabels: Array<{ left: number; top: number; right: number; bottom: number }> = []
282
+
283
+ // Estimate label dimensions (11px font + padding)
284
+ const LABEL_HEIGHT = 16
285
+ const LABEL_CHAR_WIDTH = 7 // approximate width per character
286
+
287
+ // Parse alpha from rgb/rgba color string (getComputedStyle always returns these formats)
288
+ const getColorAlpha = (color: string): number => {
289
+ if (color === 'transparent') return 0
290
+ // Match rgba(r, g, b, a) or rgb(r, g, b)
291
+ const match = color.match(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+)\s*)?\)/)
292
+ if (match) {
293
+ return match[1] !== undefined ? parseFloat(match[1]) : 1
294
+ }
295
+ return 1 // Default to opaque for unrecognized formats
296
+ }
297
+
298
+ // Check if an element has an opaque background that would block elements behind it
299
+ const isOpaqueElement = (el: any): boolean => {
300
+ const style = win.getComputedStyle(el)
301
+
302
+ // Check element opacity
303
+ const opacity = parseFloat(style.opacity)
304
+ if (opacity < 0.1) return false
305
+
306
+ // Check background-color alpha
307
+ const bgAlpha = getColorAlpha(style.backgroundColor)
308
+ if (bgAlpha > 0.1) return true
309
+
310
+ // Check if has background-image (usually opaque)
311
+ if (style.backgroundImage !== 'none') return true
312
+
313
+ return false
314
+ }
315
+
316
+ // Check if element is visible (not covered by opaque overlay)
317
+ const isElementVisible = (element: any, rect: any): boolean => {
318
+ const centerX = rect.left + rect.width / 2
319
+ const centerY = rect.top + rect.height / 2
320
+
321
+ // Get all elements at this point, from top to bottom
322
+ const stack = doc.elementsFromPoint(centerX, centerY) as any[]
323
+
324
+ // Find our target element in the stack
325
+ const targetIndex = stack.findIndex((el: any) =>
326
+ element.contains(el) || el.contains(element)
327
+ )
328
+
329
+ // Element not in stack at all - not visible
330
+ if (targetIndex === -1) return false
331
+
332
+ // Check if any opaque element is above our target
333
+ for (let i = 0; i < targetIndex; i++) {
334
+ const el = stack[i]
335
+ // Skip our own overlay container
336
+ if (el.id === containerId) continue
337
+ // Skip pointer-events: none elements (decorative overlays)
338
+ if (win.getComputedStyle(el).pointerEvents === 'none') continue
339
+ // If this element is opaque, our target is blocked
340
+ if (isOpaqueElement(el)) return false
341
+ }
342
+
343
+ return true
344
+ }
345
+
346
+ // Check if two rectangles overlap
347
+ const rectsOverlap = (
348
+ a: { left: number; top: number; right: number; bottom: number },
349
+ b: { left: number; top: number; right: number; bottom: number }
350
+ ) => {
351
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
352
+ }
353
+
354
+ // Create label for each interactive element
355
+ let count = 0
356
+ for (const { ref, role, element } of refs) {
357
+ const rect = element.getBoundingClientRect()
358
+
359
+ // Skip elements with no size (hidden)
360
+ if (rect.width === 0 || rect.height === 0) {
361
+ continue
362
+ }
363
+
364
+ // Skip elements that are covered by opaque overlays
365
+ if (!isElementVisible(element, rect)) {
366
+ continue
367
+ }
368
+
369
+ // Calculate label position and dimensions
370
+ const labelWidth = ref.length * LABEL_CHAR_WIDTH + 8 // +8 for padding
371
+ const labelLeft = rect.left
372
+ const labelTop = Math.max(0, rect.top - LABEL_HEIGHT)
373
+ const labelRect = {
374
+ left: labelLeft,
375
+ top: labelTop,
376
+ right: labelLeft + labelWidth,
377
+ bottom: labelTop + LABEL_HEIGHT,
378
+ }
379
+
380
+ // Skip if this label would overlap with any already-placed label
381
+ const overlaps = placedLabels.some((placed) => rectsOverlap(labelRect, placed))
382
+ if (overlaps) {
383
+ continue
384
+ }
385
+
386
+ // Get colors for this role
387
+ const [gradTop, gradBottom, border] = roleColors[role] || defaultColors
388
+
389
+ // Place the label
390
+ const label = doc.createElement('div')
391
+ label.className = '__pw_label__'
392
+ label.textContent = ref
393
+ label.style.background = `linear-gradient(to bottom, ${gradTop} 0%, ${gradBottom} 100%)`
394
+ label.style.border = `1px solid ${border}`
395
+
396
+ // Position above element, accounting for scroll
397
+ label.style.left = `${win.scrollX + labelLeft}px`
398
+ label.style.top = `${win.scrollY + labelTop}px`
399
+
400
+ container.appendChild(label)
401
+ placedLabels.push(labelRect)
402
+ count++
403
+ }
404
+
405
+ doc.documentElement.appendChild(container)
406
+
407
+ // Auto-hide labels after 5 seconds to prevent stale labels
408
+ // Store timer ID so it can be cancelled if showAriaRefLabels is called again
409
+ win[timerKey] = win.setTimeout(() => {
410
+ doc.getElementById(containerId)?.remove()
411
+ win[timerKey] = null
412
+ }, 5000)
413
+
414
+ return count
415
+ },
416
+ {
417
+ refs: refsWithRoles.map(({ ref, role, element }) => ({ ref, role, element })),
418
+ containerId: LABELS_CONTAINER_ID,
419
+ containerStyles: CONTAINER_STYLES,
420
+ labelStyles: LABEL_STYLES,
421
+ roleColors: ROLE_COLORS,
422
+ defaultColors: DEFAULT_COLORS,
423
+ }
424
+ )
425
+
426
+ return { snapshot, labelCount }
427
+ }
428
+
429
+ /**
430
+ * Remove all aria ref labels from the page.
431
+ */
432
+ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> {
433
+ await page.evaluate((id) => {
434
+ const doc = (globalThis as any).document
435
+ const win = globalThis as any
436
+
437
+ // Cancel any pending auto-hide timer
438
+ const timerKey = '__playwriter_labels_timer__'
439
+ if (win[timerKey]) {
440
+ win.clearTimeout(win[timerKey])
441
+ win[timerKey] = null
442
+ }
443
+
444
+ doc.getElementById(id)?.remove()
445
+ }, LABELS_CONTAINER_ID)
446
+ }