screenshotWithAccessibilityLabels
This commit is contained in:
@@ -95,24 +95,25 @@ server.close()
|
||||
|
||||
### Visual Aria Ref Labels
|
||||
|
||||
Playwriter includes Vimium-style visual labels that overlay interactive elements, making it easy for AI agents to identify and click elements from screenshots. These functions are available in the MCP context.
|
||||
Playwriter includes Vimium-style visual labels that overlay interactive elements, making it easy for AI agents to identify and click elements from screenshots. The `screenshotWithAccessibilityLabels` function is available in the MCP context.
|
||||
|
||||
```javascript
|
||||
// Show labels on all visible interactive elements
|
||||
const { snapshot, labelCount } = await showAriaRefLabels({ page })
|
||||
console.log(`Showing ${labelCount} labels`)
|
||||
// Take a screenshot with labels overlaid on interactive elements
|
||||
// Labels are shown, screenshot is captured, then labels are removed
|
||||
await screenshotWithAccessibilityLabels({ page })
|
||||
// Image and accessibility snapshot are automatically included in MCP response
|
||||
|
||||
// Take a screenshot with labels visible
|
||||
await page.screenshot({ path: '/tmp/labeled-page.png' })
|
||||
|
||||
// Use the snapshot to find elements, then interact using aria-ref selector
|
||||
// Use the snapshot from the response to find elements, then interact using aria-ref selector
|
||||
await page.locator('aria-ref=e5').click()
|
||||
|
||||
// Labels auto-hide after 30 seconds, or remove manually:
|
||||
await hideAriaRefLabels({ page })
|
||||
// Can take multiple screenshots in one execution
|
||||
await screenshotWithAccessibilityLabels({ page })
|
||||
await page.click('button')
|
||||
await screenshotWithAccessibilityLabels({ page })
|
||||
// Both images are included in the response
|
||||
```
|
||||
|
||||
**Important:** Labels auto-hide after 30 seconds to prevent stale labels. If the page HTML changes (navigation, dynamic content), call `showAriaRefLabels()` again to get fresh labels matching the current DOM.
|
||||
The function automatically shows labels, takes a screenshot, hides labels, and includes both the image and accessibility snapshot in the MCP response. Screenshots are saved to `./tmp/` with unique filenames.
|
||||
|
||||
**Features:**
|
||||
- **Role filtering** - Only shows labels for interactive elements (buttons, links, inputs, etc.)
|
||||
@@ -277,7 +278,7 @@ const source = await getReactSource({ locator: page.locator('aria-ref=e5') });
|
||||
**User Collaboration Features:**
|
||||
|
||||
- **Right-click → "Copy Playwriter Element Reference"** - Pin any element and reference it as `globalThis.playwriterPinnedElem1` in your automation code
|
||||
- **Vimium-style visual labels** - `showAriaRefLabels()` overlays clickable labels on all interactive elements
|
||||
- **Vimium-style visual labels** - `screenshotWithAccessibilityLabels()` captures screenshots with clickable labels on all interactive elements
|
||||
- **Tab group organization** - Connected tabs are grouped together with a green "playwriter" label
|
||||
- **Bypass automation detection** - Disconnect the extension temporarily to pass bot detection (e.g., Google login), then reconnect
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
- generic [ref=e55]:
|
||||
- text: "Google offered in:"
|
||||
- link [ref=e56] [cursor=pointer]:
|
||||
- /url: https://www.google.com/setprefs?sig=0_46HrevRB93iEOCE9jEsq18qNx-Q%3D&hl=it&source=homepage&sa=X&ved=0ahUKEwjGpZr4lPKRAxXcFTQIHQEtATMQ2ZgBCBU
|
||||
- /url: https://www.google.com/setprefs?sig=0_U_LREl4THfXa2sQ-b49g0Jd45jk%3D&hl=it&source=homepage&sa=X&ved=0ahUKEwiYgJn4hfSRAxU00xoGHUTZBMEQ2ZgBCBU
|
||||
- text: Italiano
|
||||
- contentinfo [ref=e58]:
|
||||
- generic [ref=e59]: Italy
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2307,6 +2307,76 @@ describe('MCP Server Tests', () => {
|
||||
console.log(`Screenshots saved to: ${assetsDir}`)
|
||||
}, 120000)
|
||||
|
||||
it('should take screenshot with accessibility labels via MCP execute tool', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button id="submit-btn">Submit Form</button>
|
||||
<a href="/about">About Us</a>
|
||||
<input type="text" placeholder="Enter your name" />
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
await page.bringToFront()
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
// Take screenshot with accessibility labels via MCP
|
||||
const result = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
let testPage;
|
||||
for (const p of context.pages()) {
|
||||
const html = await p.content();
|
||||
if (html.includes('submit-btn')) { testPage = p; break; }
|
||||
}
|
||||
if (!testPage) throw new Error('Test page not found');
|
||||
await screenshotWithAccessibilityLabels({ page: testPage });
|
||||
`,
|
||||
timeout: 15000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.isError).toBeFalsy()
|
||||
|
||||
// Verify response has both text and image content
|
||||
const content = result.content as any[]
|
||||
expect(content.length).toBe(2)
|
||||
|
||||
// Check text content
|
||||
const textContent = content.find(c => c.type === 'text')
|
||||
expect(textContent).toBeDefined()
|
||||
expect(textContent.text).toContain('Screenshot saved to:')
|
||||
expect(textContent.text).toContain('.jpg')
|
||||
expect(textContent.text).toContain('Labels shown:')
|
||||
expect(textContent.text).toContain('Accessibility snapshot:')
|
||||
expect(textContent.text).toContain('Submit Form')
|
||||
|
||||
// Check image content
|
||||
const imageContent = content.find(c => c.type === 'image')
|
||||
expect(imageContent).toBeDefined()
|
||||
expect(imageContent.mimeType).toBe('image/jpeg')
|
||||
expect(imageContent.data).toBeDefined()
|
||||
expect(imageContent.data.length).toBeGreaterThan(100) // base64 data should be substantial
|
||||
|
||||
// Verify the image is valid JPEG by checking base64
|
||||
const buffer = Buffer.from(imageContent.data, 'base64')
|
||||
const dimensions = imageSize(buffer)
|
||||
expect(dimensions.type).toBe('jpg')
|
||||
expect(dimensions.width).toBeGreaterThan(0)
|
||||
expect(dimensions.height).toBeGreaterThan(0)
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
+35
-14
@@ -21,7 +21,7 @@ import { Editor } from './editor.js'
|
||||
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
|
||||
import { getReactSource, type ReactSourceLocation } from './react-source.js'
|
||||
import { ScopedFS } from './scoped-fs.js'
|
||||
import { showAriaRefLabels, hideAriaRefLabels } from './aria-snapshot.js'
|
||||
import { screenshotWithAccessibilityLabels, type ScreenshotResult } from './aria-snapshot.js'
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
@@ -86,8 +86,7 @@ interface VMContext {
|
||||
getStylesForLocator: (options: { locator: any }) => Promise<StylesResult>
|
||||
formatStylesAsText: (styles: StylesResult) => string
|
||||
getReactSource: (options: { locator: any }) => Promise<ReactSourceLocation | null>
|
||||
showAriaRefLabels: (options: { page: Page; interactiveOnly?: boolean }) => Promise<{ snapshot: string; labelCount: number }>
|
||||
hideAriaRefLabels: (options: { page: Page }) => Promise<void>
|
||||
screenshotWithAccessibilityLabels: (options: { page: Page; interactiveOnly?: boolean }) => Promise<void>
|
||||
require: NodeRequire
|
||||
import: (specifier: string) => Promise<any>
|
||||
}
|
||||
@@ -864,6 +863,13 @@ server.tool(
|
||||
return getReactSource({ locator: options.locator, cdp })
|
||||
}
|
||||
|
||||
// Collector for screenshots taken during this execution
|
||||
const screenshotCollector: ScreenshotResult[] = []
|
||||
|
||||
const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => {
|
||||
return screenshotWithAccessibilityLabels({ ...options, collector: screenshotCollector })
|
||||
}
|
||||
|
||||
let vmContextObj: VMContextWithGlobals = {
|
||||
page,
|
||||
context,
|
||||
@@ -880,8 +886,7 @@ server.tool(
|
||||
getStylesForLocator: getStylesForLocatorFn,
|
||||
formatStylesAsText,
|
||||
getReactSource: getReactSourceFn,
|
||||
showAriaRefLabels,
|
||||
hideAriaRefLabels,
|
||||
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
||||
resetPlaywright: async () => {
|
||||
const { page: newPage, context: newContext } = await resetConnection()
|
||||
|
||||
@@ -901,8 +906,7 @@ server.tool(
|
||||
getStylesForLocator: getStylesForLocatorFn,
|
||||
formatStylesAsText,
|
||||
getReactSource: getReactSourceFn,
|
||||
showAriaRefLabels,
|
||||
hideAriaRefLabels,
|
||||
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
||||
resetPlaywright: vmContextObj.resetPlaywright,
|
||||
require: sandboxedRequire,
|
||||
// TODO --experimental-vm-modules is needed to make import work in vm
|
||||
@@ -943,6 +947,13 @@ server.tool(
|
||||
responseText += 'Code executed successfully (no output)'
|
||||
}
|
||||
|
||||
// Add screenshot info to response text
|
||||
for (const screenshot of screenshotCollector) {
|
||||
responseText += `\nScreenshot saved to: ${screenshot.path}\n`
|
||||
responseText += `Labels shown: ${screenshot.labelCount}\n\n`
|
||||
responseText += `Accessibility snapshot:\n${screenshot.snapshot}\n`
|
||||
}
|
||||
|
||||
const MAX_LENGTH = 6000
|
||||
let finalText = responseText.trim()
|
||||
if (finalText.length > MAX_LENGTH) {
|
||||
@@ -951,14 +962,24 @@ server.tool(
|
||||
`\n\n[Truncated to ${MAX_LENGTH} characters. Better manage your logs or paginate them to read the full logs]`
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: finalText,
|
||||
},
|
||||
],
|
||||
// Build content array with text and any collected screenshots
|
||||
const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [
|
||||
{
|
||||
type: 'text',
|
||||
text: finalText,
|
||||
},
|
||||
]
|
||||
|
||||
// Add all collected screenshots as images
|
||||
for (const screenshot of screenshotCollector) {
|
||||
content.push({
|
||||
type: 'image',
|
||||
data: screenshot.base64,
|
||||
mimeType: screenshot.mimeType,
|
||||
})
|
||||
}
|
||||
|
||||
return { content }
|
||||
} catch (error: any) {
|
||||
const errorStack = error.stack || error.message
|
||||
const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError'
|
||||
|
||||
+10
-11
@@ -206,24 +206,23 @@ const matches = await editor.grep({ regex: /console\.log/ });
|
||||
await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' });
|
||||
```
|
||||
|
||||
**showAriaRefLabels** - overlay Vimium-style visual labels on interactive elements. Useful for taking screenshots where you can see element references. Labels auto-hide after 30 seconds. Call again if page HTML changes or scrolls to get fresh labels. Use a timeout of 10 seconds at least.
|
||||
**screenshotWithAccessibilityLabels** - take a screenshot with Vimium-style visual labels overlaid on interactive elements. Shows labels, captures screenshot, then removes labels. The image and accessibility snapshot are automatically included in the response. Can be called multiple times to capture multiple screenshots. Use a timeout of 10 seconds at least.
|
||||
|
||||
```js
|
||||
const { snapshot, labelCount } = await showAriaRefLabels({ page });
|
||||
console.log(`Showing ${labelCount} labels`);
|
||||
await page.screenshot({ path: '/tmp/labeled-page.png' });
|
||||
// Use aria-ref from snapshot to interact
|
||||
await screenshotWithAccessibilityLabels({ page });
|
||||
// Image and accessibility snapshot are automatically included in response
|
||||
// Use aria-ref from snapshot to interact with elements
|
||||
await page.locator('aria-ref=e5').click();
|
||||
|
||||
// Can take multiple screenshots in one execution
|
||||
await screenshotWithAccessibilityLabels({ page });
|
||||
await page.click('button');
|
||||
await screenshotWithAccessibilityLabels({ page });
|
||||
// Both images are included in the response
|
||||
```
|
||||
|
||||
Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.
|
||||
|
||||
**hideAriaRefLabels** - manually remove labels before the 30-second auto-hide:
|
||||
|
||||
```js
|
||||
await hideAriaRefLabels({ page });
|
||||
```
|
||||
|
||||
## pinned elements
|
||||
|
||||
Users can right-click → "Copy Playwriter Element Reference" to store elements in `globalThis.playwriterPinnedElem1` (increments for each pin). The reference is copied to clipboard:
|
||||
|
||||
Reference in New Issue
Block a user