playwriter 0.0.38 → 0.0.40
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.
- package/dist/aria-snapshot.d.ts +29 -2
- package/dist/aria-snapshot.d.ts.map +1 -1
- package/dist/aria-snapshot.js +103 -26
- package/dist/aria-snapshot.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +30 -13
- package/dist/mcp.js.map +1 -1
- package/dist/mcp.test.js +73 -0
- package/dist/mcp.test.js.map +1 -1
- package/package.json +13 -12
- package/src/aria-snapshot.ts +129 -26
- package/src/assets/aria-labels-github.png +0 -0
- package/src/assets/aria-labels-google-snapshot.txt +1 -1
- package/src/assets/aria-labels-google.png +0 -0
- package/src/assets/aria-labels-hacker-news-snapshot.txt +826 -811
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/mcp.test.ts +86 -0
- package/src/mcp.ts +35 -14
- package/src/prompt.md +10 -11
package/src/aria-snapshot.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
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'
|
|
2
5
|
|
|
3
6
|
export interface AriaRef {
|
|
4
7
|
role: string
|
|
@@ -6,6 +9,14 @@ export interface AriaRef {
|
|
|
6
9
|
ref: string
|
|
7
10
|
}
|
|
8
11
|
|
|
12
|
+
export interface ScreenshotResult {
|
|
13
|
+
path: string
|
|
14
|
+
base64: string
|
|
15
|
+
mimeType: 'image/jpeg'
|
|
16
|
+
snapshot: string
|
|
17
|
+
labelCount: number
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
export interface AriaSnapshotResult {
|
|
10
21
|
snapshot: string
|
|
11
22
|
refToElement: Map<string, { role: string; name: string }>
|
|
@@ -17,7 +28,7 @@ export interface AriaSnapshotResult {
|
|
|
17
28
|
|
|
18
29
|
const LABELS_CONTAINER_ID = '__playwriter_labels__'
|
|
19
30
|
|
|
20
|
-
// Roles that represent
|
|
31
|
+
// Roles that represent interactive elements (clickable, typeable) and media elements
|
|
21
32
|
const INTERACTIVE_ROLES = new Set([
|
|
22
33
|
'button',
|
|
23
34
|
'link',
|
|
@@ -35,6 +46,10 @@ const INTERACTIVE_ROLES = new Set([
|
|
|
35
46
|
'option',
|
|
36
47
|
'tab',
|
|
37
48
|
'treeitem',
|
|
49
|
+
// Media elements - useful for visual tasks
|
|
50
|
+
'img',
|
|
51
|
+
'video',
|
|
52
|
+
'audio',
|
|
38
53
|
])
|
|
39
54
|
|
|
40
55
|
// Color categories for different role types - warm color scheme
|
|
@@ -63,6 +78,10 @@ const ROLE_COLORS: Record<string, [string, string, string]> = {
|
|
|
63
78
|
tab: ['#FFE082', '#FFD54F', '#FFC107'],
|
|
64
79
|
option: ['#FFE082', '#FFD54F', '#FFC107'],
|
|
65
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'],
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
// Default yellow for unknown roles
|
|
@@ -197,7 +216,7 @@ export async function getAriaSnapshot({ page }: { page: Page }): Promise<AriaSna
|
|
|
197
216
|
* Labels are yellow badges positioned above each element showing the aria ref (e.g., "e1", "e2").
|
|
198
217
|
* Use with screenshots so agents can see which elements are interactive.
|
|
199
218
|
*
|
|
200
|
-
* Labels auto-hide after
|
|
219
|
+
* Labels auto-hide after 30 seconds to prevent stale labels remaining on the page.
|
|
201
220
|
* Call this function again if the page HTML changes to get fresh labels.
|
|
202
221
|
*
|
|
203
222
|
* By default, only shows labels for truly interactive roles (button, link, textbox, etc.)
|
|
@@ -209,7 +228,7 @@ export async function getAriaSnapshot({ page }: { page: Page }): Promise<AriaSna
|
|
|
209
228
|
* await page.screenshot({ path: '/tmp/screenshot.png' })
|
|
210
229
|
* // Agent sees [e5] label on "Submit" button
|
|
211
230
|
* await page.locator('aria-ref=e5').click()
|
|
212
|
-
* // Labels auto-hide after
|
|
231
|
+
* // Labels auto-hide after 30 seconds, or call hideAriaRefLabels() manually
|
|
213
232
|
* ```
|
|
214
233
|
*/
|
|
215
234
|
export async function showAriaRefLabels({ page, interactiveOnly = true }: {
|
|
@@ -238,10 +257,9 @@ export async function showAriaRefLabels({ page, interactiveOnly = true }: {
|
|
|
238
257
|
|
|
239
258
|
// Single evaluate call: create container, styles, and all labels
|
|
240
259
|
// ElementHandles get unwrapped to DOM elements in browser context
|
|
241
|
-
// Using 'any' types here since this code runs in browser context
|
|
242
260
|
const labelCount = await page.evaluate(
|
|
243
261
|
// Using 'any' for browser types since this runs in browser context
|
|
244
|
-
({ refs, containerId, containerStyles, labelStyles, roleColors, defaultColors }: {
|
|
262
|
+
function ({ refs, containerId, containerStyles, labelStyles, roleColors, defaultColors }: {
|
|
245
263
|
refs: Array<{
|
|
246
264
|
ref: string
|
|
247
265
|
role: string
|
|
@@ -252,7 +270,9 @@ export async function showAriaRefLabels({ page, interactiveOnly = true }: {
|
|
|
252
270
|
labelStyles: string
|
|
253
271
|
roleColors: Record<string, [string, string, string]>
|
|
254
272
|
defaultColors: [string, string, string]
|
|
255
|
-
})
|
|
273
|
+
}) {
|
|
274
|
+
// Polyfill esbuild's __name helper which gets injected by vite-node but doesn't exist in browser
|
|
275
|
+
;(globalThis as any).__name ||= (fn: any) => fn
|
|
256
276
|
const doc = (globalThis as any).document
|
|
257
277
|
const win = globalThis as any
|
|
258
278
|
|
|
@@ -285,8 +305,10 @@ export async function showAriaRefLabels({ page, interactiveOnly = true }: {
|
|
|
285
305
|
const LABEL_CHAR_WIDTH = 7 // approximate width per character
|
|
286
306
|
|
|
287
307
|
// Parse alpha from rgb/rgba color string (getComputedStyle always returns these formats)
|
|
288
|
-
|
|
289
|
-
if (color === 'transparent')
|
|
308
|
+
function getColorAlpha(color: string): number {
|
|
309
|
+
if (color === 'transparent') {
|
|
310
|
+
return 0
|
|
311
|
+
}
|
|
290
312
|
// Match rgba(r, g, b, a) or rgb(r, g, b)
|
|
291
313
|
const match = color.match(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+)\s*)?\)/)
|
|
292
314
|
if (match) {
|
|
@@ -296,25 +318,31 @@ export async function showAriaRefLabels({ page, interactiveOnly = true }: {
|
|
|
296
318
|
}
|
|
297
319
|
|
|
298
320
|
// Check if an element has an opaque background that would block elements behind it
|
|
299
|
-
|
|
321
|
+
function isOpaqueElement(el: any): boolean {
|
|
300
322
|
const style = win.getComputedStyle(el)
|
|
301
323
|
|
|
302
324
|
// Check element opacity
|
|
303
325
|
const opacity = parseFloat(style.opacity)
|
|
304
|
-
if (opacity < 0.1)
|
|
326
|
+
if (opacity < 0.1) {
|
|
327
|
+
return false
|
|
328
|
+
}
|
|
305
329
|
|
|
306
330
|
// Check background-color alpha
|
|
307
331
|
const bgAlpha = getColorAlpha(style.backgroundColor)
|
|
308
|
-
if (bgAlpha > 0.1)
|
|
332
|
+
if (bgAlpha > 0.1) {
|
|
333
|
+
return true
|
|
334
|
+
}
|
|
309
335
|
|
|
310
336
|
// Check if has background-image (usually opaque)
|
|
311
|
-
if (style.backgroundImage !== 'none')
|
|
337
|
+
if (style.backgroundImage !== 'none') {
|
|
338
|
+
return true
|
|
339
|
+
}
|
|
312
340
|
|
|
313
341
|
return false
|
|
314
342
|
}
|
|
315
343
|
|
|
316
344
|
// Check if element is visible (not covered by opaque overlay)
|
|
317
|
-
|
|
345
|
+
function isElementVisible(element: any, rect: any): boolean {
|
|
318
346
|
const centerX = rect.left + rect.width / 2
|
|
319
347
|
const centerY = rect.top + rect.height / 2
|
|
320
348
|
|
|
@@ -322,32 +350,44 @@ export async function showAriaRefLabels({ page, interactiveOnly = true }: {
|
|
|
322
350
|
const stack = doc.elementsFromPoint(centerX, centerY) as any[]
|
|
323
351
|
|
|
324
352
|
// Find our target element in the stack
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
353
|
+
let targetIndex = -1
|
|
354
|
+
for (let i = 0; i < stack.length; i++) {
|
|
355
|
+
if (element.contains(stack[i]) || stack[i].contains(element)) {
|
|
356
|
+
targetIndex = i
|
|
357
|
+
break
|
|
358
|
+
}
|
|
359
|
+
}
|
|
328
360
|
|
|
329
361
|
// Element not in stack at all - not visible
|
|
330
|
-
if (targetIndex === -1)
|
|
362
|
+
if (targetIndex === -1) {
|
|
363
|
+
return false
|
|
364
|
+
}
|
|
331
365
|
|
|
332
366
|
// Check if any opaque element is above our target
|
|
333
367
|
for (let i = 0; i < targetIndex; i++) {
|
|
334
368
|
const el = stack[i]
|
|
335
369
|
// Skip our own overlay container
|
|
336
|
-
if (el.id === containerId)
|
|
370
|
+
if (el.id === containerId) {
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
337
373
|
// Skip pointer-events: none elements (decorative overlays)
|
|
338
|
-
if (win.getComputedStyle(el).pointerEvents === 'none')
|
|
374
|
+
if (win.getComputedStyle(el).pointerEvents === 'none') {
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
339
377
|
// If this element is opaque, our target is blocked
|
|
340
|
-
if (isOpaqueElement(el))
|
|
378
|
+
if (isOpaqueElement(el)) {
|
|
379
|
+
return false
|
|
380
|
+
}
|
|
341
381
|
}
|
|
342
382
|
|
|
343
383
|
return true
|
|
344
384
|
}
|
|
345
385
|
|
|
346
386
|
// Check if two rectangles overlap
|
|
347
|
-
|
|
387
|
+
function rectsOverlap(
|
|
348
388
|
a: { left: number; top: number; right: number; bottom: number },
|
|
349
389
|
b: { left: number; top: number; right: number; bottom: number }
|
|
350
|
-
)
|
|
390
|
+
) {
|
|
351
391
|
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
|
|
352
392
|
}
|
|
353
393
|
|
|
@@ -378,7 +418,13 @@ export async function showAriaRefLabels({ page, interactiveOnly = true }: {
|
|
|
378
418
|
}
|
|
379
419
|
|
|
380
420
|
// Skip if this label would overlap with any already-placed label
|
|
381
|
-
|
|
421
|
+
let overlaps = false
|
|
422
|
+
for (const placed of placedLabels) {
|
|
423
|
+
if (rectsOverlap(labelRect, placed)) {
|
|
424
|
+
overlaps = true
|
|
425
|
+
break
|
|
426
|
+
}
|
|
427
|
+
}
|
|
382
428
|
if (overlaps) {
|
|
383
429
|
continue
|
|
384
430
|
}
|
|
@@ -404,12 +450,12 @@ export async function showAriaRefLabels({ page, interactiveOnly = true }: {
|
|
|
404
450
|
|
|
405
451
|
doc.documentElement.appendChild(container)
|
|
406
452
|
|
|
407
|
-
// Auto-hide labels after
|
|
453
|
+
// Auto-hide labels after 30 seconds to prevent stale labels
|
|
408
454
|
// Store timer ID so it can be cancelled if showAriaRefLabels is called again
|
|
409
|
-
win[timerKey] = win.setTimeout(()
|
|
455
|
+
win[timerKey] = win.setTimeout(function() {
|
|
410
456
|
doc.getElementById(containerId)?.remove()
|
|
411
457
|
win[timerKey] = null
|
|
412
|
-
},
|
|
458
|
+
}, 30000)
|
|
413
459
|
|
|
414
460
|
return count
|
|
415
461
|
},
|
|
@@ -444,3 +490,60 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void>
|
|
|
444
490
|
doc.getElementById(id)?.remove()
|
|
445
491
|
}, LABELS_CONTAINER_ID)
|
|
446
492
|
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Take a screenshot with accessibility labels overlaid on interactive elements.
|
|
496
|
+
* Shows Vimium-style labels, captures the screenshot, then removes the labels.
|
|
497
|
+
* The screenshot is automatically included in the MCP response.
|
|
498
|
+
*
|
|
499
|
+
* @param collector - Array to collect screenshots (passed by MCP execute tool)
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* ```ts
|
|
503
|
+
* await screenshotWithAccessibilityLabels({ page })
|
|
504
|
+
* // Screenshot is automatically included in the MCP response
|
|
505
|
+
* // Use aria-ref from the snapshot to interact with elements
|
|
506
|
+
* await page.locator('aria-ref=e5').click()
|
|
507
|
+
* ```
|
|
508
|
+
*/
|
|
509
|
+
export async function screenshotWithAccessibilityLabels({ page, interactiveOnly = true, collector }: {
|
|
510
|
+
page: Page
|
|
511
|
+
interactiveOnly?: boolean
|
|
512
|
+
collector: ScreenshotResult[]
|
|
513
|
+
}): Promise<void> {
|
|
514
|
+
// Show labels and get snapshot
|
|
515
|
+
const { snapshot, labelCount } = await showAriaRefLabels({ page, interactiveOnly })
|
|
516
|
+
|
|
517
|
+
// Generate unique filename with timestamp
|
|
518
|
+
const timestamp = Date.now()
|
|
519
|
+
const random = Math.random().toString(36).slice(2, 6)
|
|
520
|
+
const filename = `playwriter-screenshot-${timestamp}-${random}.jpg`
|
|
521
|
+
|
|
522
|
+
// Use ./tmp folder (gitignored) instead of system temp
|
|
523
|
+
const tmpDir = path.join(process.cwd(), 'tmp')
|
|
524
|
+
if (!fs.existsSync(tmpDir)) {
|
|
525
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
526
|
+
}
|
|
527
|
+
const screenshotPath = path.join(tmpDir, filename)
|
|
528
|
+
|
|
529
|
+
// Take screenshot
|
|
530
|
+
const buffer = await page.screenshot({ type: 'jpeg', quality: 80 })
|
|
531
|
+
|
|
532
|
+
// Save to file
|
|
533
|
+
fs.writeFileSync(screenshotPath, buffer)
|
|
534
|
+
|
|
535
|
+
// Convert to base64
|
|
536
|
+
const base64 = buffer.toString('base64')
|
|
537
|
+
|
|
538
|
+
// Hide labels
|
|
539
|
+
await hideAriaRefLabels({ page })
|
|
540
|
+
|
|
541
|
+
// Add to collector array
|
|
542
|
+
collector.push({
|
|
543
|
+
path: screenshotPath,
|
|
544
|
+
base64,
|
|
545
|
+
mimeType: 'image/jpeg',
|
|
546
|
+
snapshot,
|
|
547
|
+
labelCount,
|
|
548
|
+
})
|
|
549
|
+
}
|
|
Binary file
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
- generic [ref=e55]:
|
|
32
32
|
- text: "Google offered in:"
|
|
33
33
|
- link [ref=e56] [cursor=pointer]:
|
|
34
|
-
- /url: https://www.google.com/setprefs?sig=
|
|
34
|
+
- /url: https://www.google.com/setprefs?sig=0_U_LREl4THfXa2sQ-b49g0Jd45jk%3D&hl=it&source=homepage&sa=X&ved=0ahUKEwiYgJn4hfSRAxU00xoGHUTZBMEQ2ZgBCBU
|
|
35
35
|
- text: Italiano
|
|
36
36
|
- contentinfo [ref=e58]:
|
|
37
37
|
- generic [ref=e59]: Italy
|
|
Binary file
|