refactor: replace Playwright internals with in-page dom-accessibility-api
- Add a11y-client.ts: browser-side code using dom-accessibility-api (41KB) - Single page.evaluate() call instead of multiple CDP roundtrips - Zero element handles - everything runs in page context - Stable refs from test IDs (data-testid, data-test-id, id) instead of e1, e2 - Add locator param to scope snapshots to subtrees - Fixes cross-frame 'Unable to adopt element handle' error (issue #39)
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"repository": "https://github.com/remorses/playwriter",
|
"repository": "https://github.com/remorses/playwriter",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf dist *.tsbuildinfo && mkdir dist && bun scripts/build-selector-generator.ts && bun scripts/build-bippy.ts && tsc && bun scripts/build-resources.ts",
|
"build": "rm -rf dist *.tsbuildinfo && mkdir dist && bun scripts/build-selector-generator.ts && bun scripts/build-bippy.ts && bun scripts/build-a11y-client.ts && tsc && bun scripts/build-resources.ts",
|
||||||
"prepublishOnly": "pnpm build",
|
"prepublishOnly": "pnpm build",
|
||||||
"watch": "tsc -w",
|
"watch": "tsc -w",
|
||||||
"cli": "vite-node src/cli.ts",
|
"cli": "vite-node src/cli.ts",
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
"@xmorse/cac": "^6.0.7",
|
"@xmorse/cac": "^6.0.7",
|
||||||
"diff": "^8.0.2",
|
"diff": "^8.0.2",
|
||||||
|
"dom-accessibility-api": "^0.7.1",
|
||||||
"hono": "^4.10.6",
|
"hono": "^4.10.6",
|
||||||
"kill-port-process": "^3.2.1",
|
"kill-port-process": "^3.2.1",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const distDir = path.join(__dirname, '..', 'dist')
|
||||||
|
const srcDir = path.join(__dirname, '..', 'src')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(distDir)) {
|
||||||
|
fs.mkdirSync(distDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Bundling a11y-client...')
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [path.join(srcDir, 'a11y-client.ts')],
|
||||||
|
target: 'browser',
|
||||||
|
format: 'iife',
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': '"development"',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Bundle errors:', result.logs)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundledCode = await result.outputs[0].text()
|
||||||
|
const outputPath = path.join(distDir, 'a11y-client.js')
|
||||||
|
fs.writeFileSync(outputPath, bundledCode)
|
||||||
|
console.log(`Saved to ${outputPath} (${Math.round(bundledCode.length / 1024)}kb)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
/**
|
||||||
|
* Browser-side accessibility snapshot code.
|
||||||
|
* Bundled and injected into page context via CDP.
|
||||||
|
* Uses dom-accessibility-api for spec-compliant accessible name computation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computeAccessibleName, getRole } from 'dom-accessibility-api'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface A11yElement {
|
||||||
|
ref: string
|
||||||
|
role: string
|
||||||
|
name: string
|
||||||
|
element: Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface A11ySnapshotResult {
|
||||||
|
snapshot: string
|
||||||
|
labelCount: number
|
||||||
|
refs: Array<{ ref: string; role: string; name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputeSnapshotOptions {
|
||||||
|
root: Element
|
||||||
|
interactiveOnly: boolean
|
||||||
|
renderLabels: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const LABELS_CONTAINER_ID = '__playwriter_labels__'
|
||||||
|
const LABELS_TIMER_KEY = '__playwriter_labels_timer__'
|
||||||
|
|
||||||
|
// Interactive roles - elements users can click, type into, or interact with
|
||||||
|
const INTERACTIVE_ROLES = new Set([
|
||||||
|
'button',
|
||||||
|
'link',
|
||||||
|
'textbox',
|
||||||
|
'combobox',
|
||||||
|
'searchbox',
|
||||||
|
'checkbox',
|
||||||
|
'radio',
|
||||||
|
'slider',
|
||||||
|
'spinbutton',
|
||||||
|
'switch',
|
||||||
|
'menuitem',
|
||||||
|
'menuitemcheckbox',
|
||||||
|
'menuitemradio',
|
||||||
|
'option',
|
||||||
|
'tab',
|
||||||
|
'treeitem',
|
||||||
|
// Media elements
|
||||||
|
'img',
|
||||||
|
'video',
|
||||||
|
'audio',
|
||||||
|
])
|
||||||
|
|
||||||
|
// CSS selectors for interactive elements
|
||||||
|
const INTERACTIVE_SELECTORS = [
|
||||||
|
'button',
|
||||||
|
'a[href]',
|
||||||
|
'input:not([type="hidden"])',
|
||||||
|
'select',
|
||||||
|
'textarea',
|
||||||
|
'[role="button"]',
|
||||||
|
'[role="link"]',
|
||||||
|
'[role="checkbox"]',
|
||||||
|
'[role="radio"]',
|
||||||
|
'[role="switch"]',
|
||||||
|
'[role="slider"]',
|
||||||
|
'[role="spinbutton"]',
|
||||||
|
'[role="combobox"]',
|
||||||
|
'[role="searchbox"]',
|
||||||
|
'[role="textbox"]',
|
||||||
|
'[role="menuitem"]',
|
||||||
|
'[role="menuitemcheckbox"]',
|
||||||
|
'[role="menuitemradio"]',
|
||||||
|
'[role="option"]',
|
||||||
|
'[role="tab"]',
|
||||||
|
'[role="treeitem"]',
|
||||||
|
'img[alt]',
|
||||||
|
'img[aria-label]',
|
||||||
|
'[role="img"]',
|
||||||
|
'video',
|
||||||
|
'audio',
|
||||||
|
// Contenteditable
|
||||||
|
'[contenteditable="true"]',
|
||||||
|
'[contenteditable=""]',
|
||||||
|
].join(', ')
|
||||||
|
|
||||||
|
// Color scheme for labels by role
|
||||||
|
const ROLE_COLORS: Record<string, [string, string, string]> = {
|
||||||
|
link: ['#FFF785', '#FFC542', '#E3BE23'],
|
||||||
|
button: ['#FFE0B2', '#FFCC80', '#FFB74D'],
|
||||||
|
textbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
||||||
|
combobox: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
||||||
|
searchbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
||||||
|
spinbutton: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
||||||
|
checkbox: ['#F8BBD0', '#F48FB1', '#EC407A'],
|
||||||
|
radio: ['#F8BBD0', '#F48FB1', '#EC407A'],
|
||||||
|
switch: ['#F8BBD0', '#F48FB1', '#EC407A'],
|
||||||
|
slider: ['#FFCCBC', '#FFAB91', '#FF8A65'],
|
||||||
|
menuitem: ['#FFAB91', '#FF8A65', '#FF7043'],
|
||||||
|
menuitemcheckbox: ['#FFAB91', '#FF8A65', '#FF7043'],
|
||||||
|
menuitemradio: ['#FFAB91', '#FF8A65', '#FF7043'],
|
||||||
|
tab: ['#FFE082', '#FFD54F', '#FFC107'],
|
||||||
|
option: ['#FFE082', '#FFD54F', '#FFC107'],
|
||||||
|
treeitem: ['#FFE082', '#FFD54F', '#FFC107'],
|
||||||
|
img: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
|
||||||
|
video: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
|
||||||
|
audio: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
|
||||||
|
}
|
||||||
|
const DEFAULT_COLORS: [string, string, string] = ['#FFF785', '#FFC542', '#E3BE23']
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Ref Generation - Prefer stable test IDs
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Test ID attributes to check, in priority order
|
||||||
|
const TEST_ID_ATTRS = [
|
||||||
|
'data-testid',
|
||||||
|
'data-test-id',
|
||||||
|
'data-test',
|
||||||
|
'data-cy', // Cypress
|
||||||
|
'data-pw', // Playwright
|
||||||
|
]
|
||||||
|
|
||||||
|
// Patterns that indicate auto-generated/unstable IDs
|
||||||
|
const UNSTABLE_ID_PATTERNS = [
|
||||||
|
/^:r[a-z0-9]+:$/i, // React useId() pattern like :r0:, :r1a:
|
||||||
|
/^radix-/i, // Radix UI
|
||||||
|
/^headlessui-/i, // Headless UI
|
||||||
|
/^react-select-/i, // React Select
|
||||||
|
/^rc-/i, // Ant Design
|
||||||
|
/^mui-/i, // Material UI
|
||||||
|
/^\d+$/, // Pure numbers
|
||||||
|
/^[a-f0-9-]{36}$/i, // UUIDs
|
||||||
|
/^[a-f0-9]{8,}$/i, // Long hex strings
|
||||||
|
]
|
||||||
|
|
||||||
|
function isStableId(id: string): boolean {
|
||||||
|
if (!id || id.length < 2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !UNSTABLE_ID_PATTERNS.some((pattern) => pattern.test(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStableRef(element: Element): string | null {
|
||||||
|
// Check test ID attributes first
|
||||||
|
for (const attr of TEST_ID_ATTRS) {
|
||||||
|
const value = element.getAttribute(attr)
|
||||||
|
if (value && value.length > 0) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check regular id if it looks stable
|
||||||
|
const id = element.getAttribute('id')
|
||||||
|
if (id && isStableId(id)) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Role Computation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function computeRole(element: Element): string {
|
||||||
|
// First try dom-accessibility-api
|
||||||
|
const computedRole = getRole(element)
|
||||||
|
if (computedRole) {
|
||||||
|
return computedRole
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for common elements
|
||||||
|
const tagName = element.tagName.toLowerCase()
|
||||||
|
const type = (element as HTMLInputElement).type?.toLowerCase() || ''
|
||||||
|
|
||||||
|
const roleMap: Record<string, string | Record<string, string>> = {
|
||||||
|
a: (element as HTMLAnchorElement).href ? 'link' : 'generic',
|
||||||
|
button: 'button',
|
||||||
|
input: {
|
||||||
|
button: 'button',
|
||||||
|
submit: 'button',
|
||||||
|
reset: 'button',
|
||||||
|
checkbox: 'checkbox',
|
||||||
|
radio: 'radio',
|
||||||
|
text: 'textbox',
|
||||||
|
email: 'textbox',
|
||||||
|
password: 'textbox',
|
||||||
|
search: 'searchbox',
|
||||||
|
tel: 'textbox',
|
||||||
|
url: 'textbox',
|
||||||
|
number: 'spinbutton',
|
||||||
|
range: 'slider',
|
||||||
|
},
|
||||||
|
select: 'combobox',
|
||||||
|
textarea: 'textbox',
|
||||||
|
img: 'img',
|
||||||
|
video: 'video',
|
||||||
|
audio: 'audio',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = roleMap[tagName]
|
||||||
|
if (typeof mapping === 'string') {
|
||||||
|
return mapping
|
||||||
|
}
|
||||||
|
if (typeof mapping === 'object' && type in mapping) {
|
||||||
|
return mapping[type]
|
||||||
|
}
|
||||||
|
if (tagName === 'input') {
|
||||||
|
return 'textbox'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'generic'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Visibility Checks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function isElementVisible(element: Element): boolean {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Skip elements with no size
|
||||||
|
if (rect.width === 0 || rect.height === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip elements outside viewport
|
||||||
|
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (rect.right < 0 || rect.left > window.innerWidth) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check computed style
|
||||||
|
const style = window.getComputedStyle(element)
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isElementCovered(element: Element, rect: DOMRect): boolean {
|
||||||
|
const centerX = rect.left + rect.width / 2
|
||||||
|
const centerY = rect.top + rect.height / 2
|
||||||
|
|
||||||
|
const stack = document.elementsFromPoint(centerX, centerY)
|
||||||
|
|
||||||
|
// Find our element in the stack
|
||||||
|
let targetIndex = -1
|
||||||
|
for (let i = 0; i < stack.length; i++) {
|
||||||
|
if (element.contains(stack[i]) || stack[i].contains(element) || stack[i] === element) {
|
||||||
|
targetIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
return true // Not found = covered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any opaque element is above our target
|
||||||
|
for (let i = 0; i < targetIndex; i++) {
|
||||||
|
const el = stack[i]
|
||||||
|
if ((el as HTMLElement).id === LABELS_CONTAINER_ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const elStyle = window.getComputedStyle(el)
|
||||||
|
if (elStyle.pointerEvents === 'none') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if element has opaque background
|
||||||
|
const bgAlpha = parseColorAlpha(elStyle.backgroundColor)
|
||||||
|
if (bgAlpha > 0.1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (elStyle.backgroundImage !== 'none') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColorAlpha(color: string): number {
|
||||||
|
if (color === 'transparent') {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const match = color.match(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+)\s*)?\)/)
|
||||||
|
if (match) {
|
||||||
|
return match[1] !== undefined ? parseFloat(match[1]) : 1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Label Rendering
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function renderLabels(elements: A11yElement[]): number {
|
||||||
|
const doc = document
|
||||||
|
const win = window as any
|
||||||
|
|
||||||
|
// Cancel any pending auto-hide timer
|
||||||
|
if (win[LABELS_TIMER_KEY]) {
|
||||||
|
win.clearTimeout(win[LABELS_TIMER_KEY])
|
||||||
|
win[LABELS_TIMER_KEY] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing labels
|
||||||
|
doc.getElementById(LABELS_CONTAINER_ID)?.remove()
|
||||||
|
|
||||||
|
// Create container
|
||||||
|
const container = doc.createElement('div')
|
||||||
|
container.id = LABELS_CONTAINER_ID
|
||||||
|
container.style.cssText = 'position:absolute;left:0;top:0;z-index:2147483647;pointer-events:none;'
|
||||||
|
|
||||||
|
// Inject styles
|
||||||
|
const style = doc.createElement('style')
|
||||||
|
style.textContent = `
|
||||||
|
.__pw_label__ {
|
||||||
|
position: absolute;
|
||||||
|
font: bold 12px Helvetica, Arial, sans-serif;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: black;
|
||||||
|
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
container.appendChild(style)
|
||||||
|
|
||||||
|
// Create SVG for connector lines
|
||||||
|
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
svg.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;overflow:visible;'
|
||||||
|
svg.setAttribute('width', `${doc.documentElement.scrollWidth}`)
|
||||||
|
svg.setAttribute('height', `${doc.documentElement.scrollHeight}`)
|
||||||
|
|
||||||
|
// Arrow markers
|
||||||
|
const defs = doc.createElementNS('http://www.w3.org/2000/svg', 'defs')
|
||||||
|
svg.appendChild(defs)
|
||||||
|
const markerCache: Record<string, string> = {}
|
||||||
|
|
||||||
|
function getArrowMarkerId(color: string): string {
|
||||||
|
if (markerCache[color]) {
|
||||||
|
return markerCache[color]
|
||||||
|
}
|
||||||
|
const markerId = `arrow-${color.replace('#', '')}`
|
||||||
|
const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'marker')
|
||||||
|
marker.setAttribute('id', markerId)
|
||||||
|
marker.setAttribute('viewBox', '0 0 10 10')
|
||||||
|
marker.setAttribute('refX', '9')
|
||||||
|
marker.setAttribute('refY', '5')
|
||||||
|
marker.setAttribute('markerWidth', '6')
|
||||||
|
marker.setAttribute('markerHeight', '6')
|
||||||
|
marker.setAttribute('orient', 'auto-start-reverse')
|
||||||
|
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||||
|
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z')
|
||||||
|
path.setAttribute('fill', color)
|
||||||
|
marker.appendChild(path)
|
||||||
|
defs.appendChild(marker)
|
||||||
|
markerCache[color] = markerId
|
||||||
|
return markerId
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(svg)
|
||||||
|
|
||||||
|
// Track placed labels for overlap detection
|
||||||
|
const placedLabels: Array<{ left: number; top: number; right: number; bottom: number }> = []
|
||||||
|
const LABEL_HEIGHT = 17
|
||||||
|
const LABEL_CHAR_WIDTH = 7
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
for (const { ref, role, element } of elements) {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Skip if covered
|
||||||
|
if (isElementCovered(element, rect)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate label position
|
||||||
|
const labelWidth = ref.length * LABEL_CHAR_WIDTH + 8
|
||||||
|
const labelLeft = rect.left
|
||||||
|
const labelTop = Math.max(0, rect.top - LABEL_HEIGHT)
|
||||||
|
const labelRect = {
|
||||||
|
left: labelLeft,
|
||||||
|
top: labelTop,
|
||||||
|
right: labelLeft + labelWidth,
|
||||||
|
bottom: labelTop + LABEL_HEIGHT,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check overlap
|
||||||
|
let overlaps = false
|
||||||
|
for (const placed of placedLabels) {
|
||||||
|
if (
|
||||||
|
labelRect.left < placed.right &&
|
||||||
|
labelRect.right > placed.left &&
|
||||||
|
labelRect.top < placed.bottom &&
|
||||||
|
labelRect.bottom > placed.top
|
||||||
|
) {
|
||||||
|
overlaps = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (overlaps) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get colors
|
||||||
|
const [gradTop, gradBottom, border] = ROLE_COLORS[role] || DEFAULT_COLORS
|
||||||
|
|
||||||
|
// Create label
|
||||||
|
const label = doc.createElement('div')
|
||||||
|
label.className = '__pw_label__'
|
||||||
|
label.textContent = ref
|
||||||
|
label.style.background = `linear-gradient(to bottom, ${gradTop} 0%, ${gradBottom} 100%)`
|
||||||
|
label.style.border = `1px solid ${border}`
|
||||||
|
label.style.left = `${win.scrollX + labelLeft}px`
|
||||||
|
label.style.top = `${win.scrollY + labelTop}px`
|
||||||
|
container.appendChild(label)
|
||||||
|
|
||||||
|
// Draw connector line
|
||||||
|
const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line')
|
||||||
|
const labelCenterX = win.scrollX + labelLeft + labelWidth / 2
|
||||||
|
const labelBottomY = win.scrollY + labelTop + LABEL_HEIGHT
|
||||||
|
const elementCenterX = win.scrollX + rect.left + rect.width / 2
|
||||||
|
const elementCenterY = win.scrollY + rect.top + rect.height / 2
|
||||||
|
line.setAttribute('x1', `${labelCenterX}`)
|
||||||
|
line.setAttribute('y1', `${labelBottomY}`)
|
||||||
|
line.setAttribute('x2', `${elementCenterX}`)
|
||||||
|
line.setAttribute('y2', `${elementCenterY}`)
|
||||||
|
line.setAttribute('stroke', border)
|
||||||
|
line.setAttribute('stroke-width', '1.5')
|
||||||
|
line.setAttribute('marker-end', `url(#${getArrowMarkerId(border)})`)
|
||||||
|
svg.appendChild(line)
|
||||||
|
|
||||||
|
placedLabels.push(labelRect)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.documentElement.appendChild(container)
|
||||||
|
|
||||||
|
// Auto-hide after 30 seconds
|
||||||
|
win[LABELS_TIMER_KEY] = win.setTimeout(() => {
|
||||||
|
doc.getElementById(LABELS_CONTAINER_ID)?.remove()
|
||||||
|
win[LABELS_TIMER_KEY] = null
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Snapshot Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function buildSnapshotLine(role: string, name: string, ref: string, indent: number): string {
|
||||||
|
const prefix = ' '.repeat(indent)
|
||||||
|
let line = `${prefix}- ${role}`
|
||||||
|
if (name) {
|
||||||
|
// Escape quotes in name
|
||||||
|
const escapedName = name.replace(/"/g, '\\"')
|
||||||
|
line += ` "${escapedName}"`
|
||||||
|
}
|
||||||
|
line += ` [ref=${ref}]`
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Entry Point
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function computeA11ySnapshot(options: ComputeSnapshotOptions): A11ySnapshotResult {
|
||||||
|
const { root, interactiveOnly, renderLabels: shouldRenderLabels } = options
|
||||||
|
|
||||||
|
// Query all interactive elements within root
|
||||||
|
const elements = root.querySelectorAll(INTERACTIVE_SELECTORS)
|
||||||
|
|
||||||
|
// Track refs for deduplication
|
||||||
|
const refCounts = new Map<string, number>()
|
||||||
|
const a11yElements: A11yElement[] = []
|
||||||
|
let fallbackCounter = 0
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
// Skip invisible elements
|
||||||
|
if (!isElementVisible(element)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute role
|
||||||
|
const role = computeRole(element)
|
||||||
|
|
||||||
|
// Filter to interactive only if requested
|
||||||
|
if (interactiveOnly && !INTERACTIVE_ROLES.has(role)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute accessible name
|
||||||
|
let name = ''
|
||||||
|
try {
|
||||||
|
name = computeAccessibleName(element) || ''
|
||||||
|
} catch {
|
||||||
|
// Fallback to basic name computation
|
||||||
|
name =
|
||||||
|
element.getAttribute('aria-label') ||
|
||||||
|
element.getAttribute('alt') ||
|
||||||
|
element.getAttribute('title') ||
|
||||||
|
(element.textContent || '').trim().slice(0, 100) ||
|
||||||
|
''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ref - prefer stable test IDs
|
||||||
|
let baseRef = getStableRef(element)
|
||||||
|
if (!baseRef) {
|
||||||
|
fallbackCounter++
|
||||||
|
baseRef = `e${fallbackCounter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicates by appending count
|
||||||
|
const count = refCounts.get(baseRef) || 0
|
||||||
|
refCounts.set(baseRef, count + 1)
|
||||||
|
const ref = count === 0 ? baseRef : `${baseRef}-${count + 1}`
|
||||||
|
|
||||||
|
a11yElements.push({ ref, role, name, element })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build snapshot string
|
||||||
|
const snapshotLines = a11yElements.map(({ ref, role, name }) => {
|
||||||
|
return buildSnapshotLine(role, name, ref, 0)
|
||||||
|
})
|
||||||
|
const snapshot = snapshotLines.join('\n')
|
||||||
|
|
||||||
|
// Render labels if requested
|
||||||
|
let labelCount = 0
|
||||||
|
if (shouldRenderLabels) {
|
||||||
|
labelCount = renderLabels(a11yElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return refs without element references (can't serialize DOM elements)
|
||||||
|
const refs = a11yElements.map(({ ref, role, name }) => ({ ref, role, name }))
|
||||||
|
|
||||||
|
return { snapshot, labelCount, refs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hide Labels
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function hideA11yLabels(): void {
|
||||||
|
const win = window as any
|
||||||
|
if (win[LABELS_TIMER_KEY]) {
|
||||||
|
win.clearTimeout(win[LABELS_TIMER_KEY])
|
||||||
|
win[LABELS_TIMER_KEY] = null
|
||||||
|
}
|
||||||
|
document.getElementById(LABELS_CONTAINER_ID)?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Expose on globalThis for injection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
;(globalThis as any).__a11y = {
|
||||||
|
computeA11ySnapshot,
|
||||||
|
hideA11yLabels,
|
||||||
|
}
|
||||||
+230
-536
@@ -1,6 +1,7 @@
|
|||||||
import type { Page, Locator, ElementHandle } from 'playwright-core'
|
import type { Page, Locator, ElementHandle } from 'playwright-core'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
// Import sharp at module level - resolves to null if not available
|
// Import sharp at module level - resolves to null if not available
|
||||||
const sharpPromise = import('sharp')
|
const sharpPromise = import('sharp')
|
||||||
@@ -11,27 +12,22 @@ const sharpPromise = import('sharp')
|
|||||||
// Aria Snapshot Format Documentation
|
// Aria Snapshot Format Documentation
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
//
|
//
|
||||||
// This module processes accessibility snapshots from Playwright's _snapshotForAI() method.
|
// This module generates accessibility snapshots using dom-accessibility-api
|
||||||
// The expected format is a YAML-like tree structure:
|
// running entirely in browser context. The output format is:
|
||||||
//
|
//
|
||||||
// ```
|
// ```
|
||||||
// - role "accessible name" [ref=eXX] [cursor=pointer] [active]:
|
// - role "accessible name" [ref=testid-or-e1]
|
||||||
// - childRole "child name" [ref=eYY]:
|
// - button "Submit" [ref=submit-btn]
|
||||||
// - /url: https://example.com
|
// - link "Home" [ref=nav-home]
|
||||||
// - text: "raw text content"
|
// - textbox "Search" [ref=e3]
|
||||||
// ```
|
// ```
|
||||||
//
|
//
|
||||||
// Key format assumptions:
|
// Refs are generated from stable test IDs when available:
|
||||||
// - Lines start with "- " followed by a role name (e.g., link, button, row, cell)
|
// - data-testid, data-test-id, data-test, data-cy, data-pw
|
||||||
// - Accessible names are in double quotes: "name"
|
// - Stable id attributes (excluding auto-generated ones)
|
||||||
// - When names contain colons, the whole entry is single-quoted: - 'role "name: with colon"':
|
// - Fallback: e1, e2, e3...
|
||||||
// - Element refs are in brackets: [ref=e123]
|
|
||||||
// - Attributes like [cursor=pointer], [active] may appear after the name
|
|
||||||
// - Child elements are indented (2 spaces per level)
|
|
||||||
// - URL metadata appears as: - /url: https://...
|
|
||||||
// - Raw text appears as: - text: "content"
|
|
||||||
//
|
//
|
||||||
// If Playwright changes this format, the parsing functions below will need updates.
|
// Duplicate refs get a suffix: submit-btn, submit-btn-2, submit-btn-3
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -208,7 +204,8 @@ function extractInteractiveFlat(lines: string[], interactiveRoles: Set<string>,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = trimmed.match(/^-\s+(\w+)(?:\s+"[^"]*")?(?:\s+\[[^\]]+\])*\s*\[ref=(\w+)\]/)
|
// Match ref pattern - now supports any ref format (not just e123)
|
||||||
|
const match = trimmed.match(/^-\s+(\w+)(?:\s+"[^"]*")?(?:\s+\[[^\]]+\])*\s*\[ref=([^\]]+)\]/)
|
||||||
if (!match || !interactiveRoles.has(match[1])) {
|
if (!match || !interactiveRoles.has(match[1])) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -294,7 +291,7 @@ function extractInteractiveWithStructure(
|
|||||||
// Clean line and strip refs from non-interactive
|
// Clean line and strip refs from non-interactive
|
||||||
let cleanedLine = cleanSnapshotLine(lines[i])
|
let cleanedLine = cleanSnapshotLine(lines[i])
|
||||||
if (!lineIsInteractive[i]) {
|
if (!lineIsInteractive[i]) {
|
||||||
cleanedLine = cleanedLine.replace(/\s*\[ref=\w+\]/g, '')
|
cleanedLine = cleanedLine.replace(/\s*\[ref=[^\]]+\]/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push(cleanedLine)
|
result.push(cleanedLine)
|
||||||
@@ -422,7 +419,8 @@ function parseSnapshotLine(line: string): { role: string; name: string | null; r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Match: - role "name" [ref=xxx]: or - role [ref=xxx]: or - role "name": or - role:
|
// Match: - role "name" [ref=xxx]: or - role [ref=xxx]: or - role "name": or - role:
|
||||||
const match = trimmed.match(/^-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+\[ref=(\w+)\])?/)
|
// Updated to support any ref format (not just e123)
|
||||||
|
const match = trimmed.match(/^-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+\[ref=([^\]]+)\])?/)
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { role: '', name: null, ref: null }
|
return { role: '', name: null, ref: null }
|
||||||
@@ -525,7 +523,31 @@ function rebuildLines(node: SnapshotNode, result: string[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Original Aria Snapshot Types and Functions
|
// A11y Client Code Loading
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let a11yClientCode: string | null = null
|
||||||
|
|
||||||
|
function getA11yClientCode(): string {
|
||||||
|
if (a11yClientCode) {
|
||||||
|
return a11yClientCode
|
||||||
|
}
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const a11yClientPath = path.join(currentDir, '..', 'dist', 'a11y-client.js')
|
||||||
|
a11yClientCode = fs.readFileSync(a11yClientPath, 'utf-8')
|
||||||
|
return a11yClientCode
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureA11yClient(page: Page): Promise<void> {
|
||||||
|
const hasA11y = await page.evaluate(() => !!(globalThis as any).__a11y)
|
||||||
|
if (!hasA11y) {
|
||||||
|
const code = getA11yClientCode()
|
||||||
|
await page.evaluate(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface AriaRef {
|
export interface AriaRef {
|
||||||
@@ -545,27 +567,18 @@ export interface ScreenshotResult {
|
|||||||
export interface AriaSnapshotResult {
|
export interface AriaSnapshotResult {
|
||||||
snapshot: string
|
snapshot: string
|
||||||
refToElement: Map<string, { role: string; name: string }>
|
refToElement: Map<string, { role: string; name: string }>
|
||||||
refHandles: Array<{ ref: string; handle: ElementHandle }>
|
/**
|
||||||
|
* Get a CSS selector for a ref. Use with page.locator().
|
||||||
|
* For stable test IDs, returns [data-testid="..."] or [id="..."]
|
||||||
|
* For fallback refs (e1, e2), returns a role-based selector.
|
||||||
|
*/
|
||||||
|
getSelectorForRef: (ref: string) => string | null
|
||||||
getRefsForLocators: (locators: Array<Locator | ElementHandle>) => Promise<Array<AriaRef | null>>
|
getRefsForLocators: (locators: Array<Locator | ElementHandle>) => Promise<Array<AriaRef | null>>
|
||||||
getRefForLocator: (locator: Locator | ElementHandle) => Promise<AriaRef | null>
|
getRefForLocator: (locator: Locator | ElementHandle) => Promise<AriaRef | null>
|
||||||
getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null>
|
getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ElementLike {
|
// Roles that represent interactive elements
|
||||||
tagName: string
|
|
||||||
textContent: string | null
|
|
||||||
getAttribute: (name: string) => string | null
|
|
||||||
hasAttribute: (name: string) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InputElementLike extends ElementLike {
|
|
||||||
type?: string
|
|
||||||
placeholder?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const LABELS_CONTAINER_ID = '__playwriter_labels__'
|
|
||||||
|
|
||||||
// Roles that represent interactive elements (clickable, typeable) and media elements
|
|
||||||
const INTERACTIVE_ROLES = new Set([
|
const INTERACTIVE_ROLES = new Set([
|
||||||
'button',
|
'button',
|
||||||
'link',
|
'link',
|
||||||
@@ -583,214 +596,117 @@ const INTERACTIVE_ROLES = new Set([
|
|||||||
'option',
|
'option',
|
||||||
'tab',
|
'tab',
|
||||||
'treeitem',
|
'treeitem',
|
||||||
// Media elements - useful for visual tasks
|
|
||||||
'img',
|
'img',
|
||||||
'video',
|
'video',
|
||||||
'audio',
|
'audio',
|
||||||
])
|
])
|
||||||
|
|
||||||
// Color categories for different role types - warm color scheme
|
// ============================================================================
|
||||||
// Format: [gradient-top, gradient-bottom, border]
|
// Main Functions
|
||||||
const ROLE_COLORS: Record<string, [string, string, string]> = {
|
// ============================================================================
|
||||||
// Links - yellow (Vimium-style)
|
|
||||||
link: ['#FFF785', '#FFC542', '#E3BE23'],
|
|
||||||
// Buttons - orange
|
|
||||||
button: ['#FFE0B2', '#FFCC80', '#FFB74D'],
|
|
||||||
// Text inputs - coral/red
|
|
||||||
textbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
|
||||||
combobox: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
|
||||||
searchbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
|
||||||
spinbutton: ['#FFCDD2', '#EF9A9A', '#E57373'],
|
|
||||||
// Checkboxes/Radios/Switches - warm pink
|
|
||||||
checkbox: ['#F8BBD0', '#F48FB1', '#EC407A'],
|
|
||||||
radio: ['#F8BBD0', '#F48FB1', '#EC407A'],
|
|
||||||
switch: ['#F8BBD0', '#F48FB1', '#EC407A'],
|
|
||||||
// Sliders - peach
|
|
||||||
slider: ['#FFCCBC', '#FFAB91', '#FF8A65'],
|
|
||||||
// Menu items - salmon
|
|
||||||
menuitem: ['#FFAB91', '#FF8A65', '#FF7043'],
|
|
||||||
menuitemcheckbox: ['#FFAB91', '#FF8A65', '#FF7043'],
|
|
||||||
menuitemradio: ['#FFAB91', '#FF8A65', '#FF7043'],
|
|
||||||
// Tabs/Options - amber
|
|
||||||
tab: ['#FFE082', '#FFD54F', '#FFC107'],
|
|
||||||
option: ['#FFE082', '#FFD54F', '#FFC107'],
|
|
||||||
treeitem: ['#FFE082', '#FFD54F', '#FFC107'],
|
|
||||||
// Media elements - light blue
|
|
||||||
img: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
|
|
||||||
video: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
|
|
||||||
audio: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default yellow for unknown roles
|
|
||||||
const DEFAULT_COLORS: [string, string, string] = ['#FFF785', '#FFC542', '#E3BE23']
|
|
||||||
|
|
||||||
// Use String.raw for CSS syntax highlighting in editors
|
|
||||||
const css = String.raw
|
|
||||||
|
|
||||||
const LABEL_STYLES = css`
|
|
||||||
.__pw_label__ {
|
|
||||||
position: absolute;
|
|
||||||
font: bold 12px Helvetica, Arial, sans-serif;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: black;
|
|
||||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const CONTAINER_STYLES = css`
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2147483647;
|
|
||||||
pointer-events: none;
|
|
||||||
`
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an accessibility snapshot with utilities to look up aria refs for elements.
|
* Get an accessibility snapshot with utilities to look up refs for elements.
|
||||||
* Uses Playwright's internal aria-ref selector engine.
|
* Uses dom-accessibility-api running entirely in browser context.
|
||||||
|
*
|
||||||
|
* Refs are generated from stable test IDs when available (data-testid, data-test-id, etc.)
|
||||||
|
* or fall back to e1, e2, e3...
|
||||||
|
*
|
||||||
|
* @param page - Playwright page
|
||||||
|
* @param locator - Optional locator to scope the snapshot to a subtree
|
||||||
|
* @param refFilter - Optional filter for which elements get refs
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const { snapshot, getRefsForLocators } = await getAriaSnapshot({ page })
|
* const { snapshot, getSelectorForRef } = await getAriaSnapshot({ page })
|
||||||
* const refs = await getRefsForLocators([page.locator('button'), page.locator('a')])
|
* // Snapshot shows refs like [ref=submit-btn] or [ref=e5]
|
||||||
* // refs[0].ref is e.g. "e5" - use page.locator('aria-ref=e5') to select
|
* const selector = getSelectorForRef('submit-btn')
|
||||||
|
* await page.locator(selector).click()
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export async function getAriaSnapshot({ page, refFilter }: {
|
export async function getAriaSnapshot({ page, locator, refFilter }: {
|
||||||
page: Page
|
page: Page
|
||||||
|
locator?: Locator
|
||||||
refFilter?: (info: { role: string; name: string }) => boolean
|
refFilter?: (info: { role: string; name: string }) => boolean
|
||||||
}): Promise<AriaSnapshotResult> {
|
}): Promise<AriaSnapshotResult> {
|
||||||
const snapshotMethod = (page as any)._snapshotForAI
|
await ensureA11yClient(page)
|
||||||
if (!snapshotMethod) {
|
|
||||||
throw new Error('_snapshotForAI not available. Ensure you are using Playwright.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await snapshotMethod.call(page)
|
// Determine root element
|
||||||
// Sanitize to remove unpaired surrogates that break JSON encoding for Claude API
|
const rootHandle = locator ? await locator.elementHandle() : null
|
||||||
const rawStr = typeof snapshot === 'string' ? snapshot : (snapshot.full || JSON.stringify(snapshot, null, 2))
|
|
||||||
const snapshotStr = rawStr.toWellFormed?.() ?? rawStr
|
|
||||||
|
|
||||||
const snapshotRefInfo = extractRefInfoFromSnapshot(snapshotStr)
|
const result = await page.evaluate(
|
||||||
const snapshotRefs = [...snapshotRefInfo.entries()]
|
({ root, interactiveOnly }) => {
|
||||||
.filter(([_, info]) => !refFilter || refFilter(info))
|
const a11y = (globalThis as any).__a11y
|
||||||
.map(([ref]) => ref)
|
if (!a11y) {
|
||||||
|
throw new Error('a11y client not loaded')
|
||||||
|
}
|
||||||
|
const rootElement = root || document.body
|
||||||
|
return a11y.computeA11ySnapshot({
|
||||||
|
root: rootElement,
|
||||||
|
interactiveOnly,
|
||||||
|
renderLabels: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: rootHandle,
|
||||||
|
interactiveOnly: !!refFilter,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Discover refs by probing aria-ref=e1, e2, e3... until 10 consecutive misses
|
// Build refToElement map
|
||||||
const refToElement = new Map<string, { role: string; name: string }>()
|
const refToElement = new Map<string, { role: string; name: string }>()
|
||||||
const refHandles: Array<{ ref: string; handle: ElementHandle }> = []
|
for (const { ref, role, name } of result.refs) {
|
||||||
|
if (!refFilter || refFilter({ role, name })) {
|
||||||
|
refToElement.set(ref, { role, name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchRefInfo = async (ref: string): Promise<{ ref: string; handle: ElementHandle; info: { role: string; name: string } } | null> => {
|
// Filter snapshot if refFilter provided
|
||||||
try {
|
let snapshot = result.snapshot
|
||||||
const locator = page.locator(`aria-ref=${ref}`)
|
if (refFilter) {
|
||||||
const handle = await locator.elementHandle({ timeout: 1000 })
|
const lines = snapshot.split('\n').filter((line) => {
|
||||||
if (!handle) {
|
const match = line.match(/\[ref=([^\]]+)\]/)
|
||||||
return null
|
if (!match) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
const info = await handle.evaluate((el) => {
|
const ref = match[1]
|
||||||
const element = el as ElementLike
|
return refToElement.has(ref)
|
||||||
const tagName = element.tagName.toLowerCase()
|
})
|
||||||
const roleAttribute = element.getAttribute('role')
|
snapshot = lines.join('\n')
|
||||||
const inputElement = element as InputElementLike
|
}
|
||||||
const inputType = inputElement.type || ''
|
|
||||||
const placeholder = inputElement.placeholder || ''
|
/**
|
||||||
const role = roleAttribute || {
|
* Get a CSS selector for a ref.
|
||||||
a: element.hasAttribute('href') ? 'link' : 'generic',
|
* For stable test IDs: [data-testid="value"] or [id="value"]
|
||||||
button: 'button',
|
* For fallback refs: uses role + name matching
|
||||||
input: {
|
*/
|
||||||
button: 'button',
|
const getSelectorForRef = (ref: string): string | null => {
|
||||||
checkbox: 'checkbox',
|
const info = refToElement.get(ref)
|
||||||
radio: 'radio',
|
if (!info) {
|
||||||
text: 'textbox',
|
|
||||||
search: 'searchbox',
|
|
||||||
number: 'spinbutton',
|
|
||||||
range: 'slider',
|
|
||||||
}[inputType] || 'textbox',
|
|
||||||
select: 'combobox',
|
|
||||||
textarea: 'textbox',
|
|
||||||
img: 'img',
|
|
||||||
nav: 'navigation',
|
|
||||||
main: 'main',
|
|
||||||
header: 'banner',
|
|
||||||
footer: 'contentinfo',
|
|
||||||
}[tagName] || 'generic'
|
|
||||||
const name = element.getAttribute('aria-label') || element.textContent?.trim() || placeholder || ''
|
|
||||||
return { role, name }
|
|
||||||
})
|
|
||||||
return { ref, handle, info }
|
|
||||||
} catch {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const fetchRefHandle = async (ref: string): Promise<{ ref: string; handle: ElementHandle } | null> => {
|
// Check if ref looks like a stable test ID (not e1, e2, etc.)
|
||||||
try {
|
if (!/^e\d+$/.test(ref)) {
|
||||||
const locator = page.locator(`aria-ref=${ref}`)
|
// Try common test ID attributes
|
||||||
const handle = await locator.elementHandle({ timeout: 1000 })
|
return `[data-testid="${ref}"], [data-test-id="${ref}"], [data-test="${ref}"], [data-cy="${ref}"], [data-pw="${ref}"], [id="${ref}"]`
|
||||||
if (!handle) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return { ref, handle }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For fallback refs, use role-based selector
|
||||||
|
// This is less reliable but works for simple cases
|
||||||
|
const escapedName = info.name.replace(/"/g, '\\"')
|
||||||
|
return `[role="${info.role}"][aria-label="${escapedName}"], ${info.role}:has-text("${escapedName}")`
|
||||||
}
|
}
|
||||||
|
|
||||||
const addRefInfo = ({ ref, handle, info }: { ref: string; handle: ElementHandle; info: { role: string; name: string } }) => {
|
/**
|
||||||
refToElement.set(ref, info)
|
* Find refs for locators by matching in browser context.
|
||||||
refHandles.push({ ref, handle })
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
const probeRefsSequentially = async () => {
|
|
||||||
let consecutiveMisses = 0
|
|
||||||
let refNum = 1
|
|
||||||
while (consecutiveMisses < 10) {
|
|
||||||
const ref = `e${refNum++}`
|
|
||||||
const result = await fetchRefInfo(ref)
|
|
||||||
if (!result) {
|
|
||||||
consecutiveMisses++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
consecutiveMisses = 0
|
|
||||||
addRefInfo(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const probeRefsFromSnapshot = async (refs: string[]) => {
|
|
||||||
const concurrency = 20
|
|
||||||
const chunks = refs.reduce((acc, ref, index) => {
|
|
||||||
const chunkIndex = Math.floor(index / concurrency)
|
|
||||||
if (!acc[chunkIndex]) {
|
|
||||||
acc[chunkIndex] = []
|
|
||||||
}
|
|
||||||
acc[chunkIndex].push(ref)
|
|
||||||
return acc
|
|
||||||
}, [] as string[][])
|
|
||||||
|
|
||||||
await chunks.reduce(async (previous, chunk) => {
|
|
||||||
await previous
|
|
||||||
const results = await Promise.all(chunk.map(async (ref) => fetchRefHandle(ref)))
|
|
||||||
const successfulResults = results.filter((result): result is { ref: string; handle: ElementHandle } => result !== null)
|
|
||||||
successfulResults.map((result) => {
|
|
||||||
const info = snapshotRefInfo.get(result.ref) || { role: 'generic', name: '' }
|
|
||||||
addRefInfo({ ...result, info })
|
|
||||||
})
|
|
||||||
}, Promise.resolve())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshotRefs.length > 0) {
|
|
||||||
await probeRefsFromSnapshot(snapshotRefs)
|
|
||||||
} else {
|
|
||||||
await probeRefsSequentially()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find refs for multiple locators in a single evaluate call
|
|
||||||
const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => {
|
const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => {
|
||||||
if (locators.length === 0 || refHandles.length === 0) {
|
if (locators.length === 0) {
|
||||||
return locators.map(() => null)
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get handles for target locators
|
||||||
const targetHandles = await Promise.all(
|
const targetHandles = await Promise.all(
|
||||||
locators.map(async (loc) => {
|
locators.map(async (loc) => {
|
||||||
try {
|
try {
|
||||||
@@ -803,379 +719,151 @@ export async function getAriaSnapshot({ page, refFilter }: {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Match in browser context
|
||||||
const matchingRefs = await page.evaluate(
|
const matchingRefs = await page.evaluate(
|
||||||
({ targets, candidates }) => targets.map((target) => {
|
({ targets, refData }) => {
|
||||||
if (!target) return null
|
return targets.map((target) => {
|
||||||
return candidates.find(({ element }) => element === target)?.ref ?? null
|
if (!target) {
|
||||||
}),
|
return null
|
||||||
{ targets: targetHandles, candidates: refHandles.map(({ ref, handle }) => ({ ref, element: handle })) }
|
}
|
||||||
|
|
||||||
|
// Try to find this element's ref by checking test ID attributes
|
||||||
|
const testIdAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-pw']
|
||||||
|
for (const attr of testIdAttrs) {
|
||||||
|
const value = (target as any).getAttribute(attr)
|
||||||
|
if (value && refData.some((r: any) => r.ref === value || r.ref.startsWith(value))) {
|
||||||
|
const match = refData.find((r: any) => r.ref === value || r.ref.startsWith(value))
|
||||||
|
return match ? match.ref : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check id attribute
|
||||||
|
const id = (target as any).getAttribute('id')
|
||||||
|
if (id) {
|
||||||
|
const match = refData.find((r: any) => r.ref === id || r.ref.startsWith(id))
|
||||||
|
if (match) {
|
||||||
|
return match.ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targets: targetHandles,
|
||||||
|
refData: result.refs,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return matchingRefs.map((ref) => {
|
return matchingRefs.map((ref) => {
|
||||||
if (!ref) return null
|
if (!ref) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const info = refToElement.get(ref)
|
const info = refToElement.get(ref)
|
||||||
return info ? { ...info, ref } : null
|
return info ? { ...info, ref } : null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
snapshot: snapshotStr,
|
snapshot,
|
||||||
refToElement,
|
refToElement,
|
||||||
refHandles,
|
getSelectorForRef,
|
||||||
getRefsForLocators,
|
getRefsForLocators,
|
||||||
getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0],
|
getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0],
|
||||||
getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null,
|
getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRefInfoFromSnapshot(snapshot: string): Map<string, { role: string; name: string }> {
|
|
||||||
const lines = snapshot.split('\n')
|
|
||||||
return lines.reduce((map, line) => {
|
|
||||||
if (!line.trim()) {
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
const parsed = parseSnapshotLine(line)
|
|
||||||
if (!parsed.ref || map.has(parsed.ref)) {
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
map.set(parsed.ref, { role: parsed.role, name: parsed.name ?? '' })
|
|
||||||
return map
|
|
||||||
}, new Map<string, { role: string; name: string }>())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show Vimium-style labels on interactive elements.
|
* Show Vimium-style labels on interactive elements.
|
||||||
* Labels are yellow badges positioned above each element showing the aria ref (e.g., "e1", "e2").
|
* Labels are colored badges positioned above each element showing the ref.
|
||||||
* Use with screenshots so agents can see which elements are interactive.
|
* Use with screenshots so agents can see which elements are interactive.
|
||||||
*
|
*
|
||||||
* Labels auto-hide after 30 seconds to prevent stale labels remaining on the page.
|
* Labels auto-hide after 30 seconds to prevent stale labels.
|
||||||
* Call this function again if the page HTML changes to get fresh labels.
|
* Call this function again if the page HTML changes to get fresh labels.
|
||||||
*
|
*
|
||||||
* By default, only shows labels for truly interactive roles (button, link, textbox, etc.)
|
* @param page - Playwright page
|
||||||
* to reduce visual clutter. Set `interactiveOnly: false` to show all elements with refs.
|
* @param locator - Optional locator to scope labels to a subtree
|
||||||
|
* @param interactiveOnly - Only show labels for interactive elements (default: true)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const { snapshot, labelCount } = await showAriaRefLabels({ page })
|
* const { snapshot, labelCount } = await showAriaRefLabels({ page })
|
||||||
* await page.screenshot({ path: '/tmp/screenshot.png' })
|
* await page.screenshot({ path: '/tmp/screenshot.png' })
|
||||||
* // Agent sees [e5] label on "Submit" button
|
* // Agent sees [submit-btn] label on "Submit" button
|
||||||
* await page.locator('aria-ref=e5').click()
|
* await page.locator('[data-testid="submit-btn"]').click()
|
||||||
* // Labels auto-hide after 30 seconds, or call hideAriaRefLabels() manually
|
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export async function showAriaRefLabels({ page, interactiveOnly = true, logger }: {
|
export async function showAriaRefLabels({ page, locator, interactiveOnly = true, logger }: {
|
||||||
page: Page
|
page: Page
|
||||||
|
locator?: Locator
|
||||||
interactiveOnly?: boolean
|
interactiveOnly?: boolean
|
||||||
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
|
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
snapshot: string
|
snapshot: string
|
||||||
labelCount: number
|
labelCount: number
|
||||||
}> {
|
}> {
|
||||||
const getSnapshotStart = Date.now()
|
const startTime = Date.now()
|
||||||
const { snapshot, refHandles, refToElement } = await getAriaSnapshot({
|
await ensureA11yClient(page)
|
||||||
page,
|
|
||||||
refFilter: interactiveOnly ? (info) => { return INTERACTIVE_ROLES.has(info.role) } : undefined,
|
|
||||||
})
|
|
||||||
const log = logger?.info ?? logger?.error
|
const log = logger?.info ?? logger?.error
|
||||||
if (log) {
|
if (log) {
|
||||||
log(`getAriaSnapshot: ${Date.now() - getSnapshotStart}ms`)
|
log(`ensureA11yClient: ${Date.now() - startTime}ms`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to only interactive elements if requested
|
// Determine root element
|
||||||
const filteredRefs = refHandles
|
const rootHandle = locator ? await locator.elementHandle() : null
|
||||||
|
|
||||||
// Build refs with role info for color coding
|
const computeStart = Date.now()
|
||||||
const refsWithRoles = filteredRefs.map(({ ref, handle }) => ({
|
const result = await page.evaluate(
|
||||||
ref,
|
({ root, interactiveOnly: intOnly }) => {
|
||||||
element: handle,
|
const a11y = (globalThis as any).__a11y
|
||||||
role: refToElement.get(ref)?.role || 'generic',
|
if (!a11y) {
|
||||||
}))
|
throw new Error('a11y client not loaded')
|
||||||
|
|
||||||
// Single evaluate call: create container, styles, and all labels
|
|
||||||
// ElementHandles get unwrapped to DOM elements in browser context
|
|
||||||
const labelCount = await page.evaluate(
|
|
||||||
// Using 'any' for browser types since this runs in browser context
|
|
||||||
function ({ refs, containerId, containerStyles, labelStyles, roleColors, defaultColors }: {
|
|
||||||
refs: Array<{
|
|
||||||
ref: string
|
|
||||||
role: string
|
|
||||||
element: any // Element in browser context
|
|
||||||
}>
|
|
||||||
containerId: string
|
|
||||||
containerStyles: string
|
|
||||||
labelStyles: string
|
|
||||||
roleColors: Record<string, [string, string, string]>
|
|
||||||
defaultColors: [string, string, string]
|
|
||||||
}) {
|
|
||||||
// Polyfill esbuild's __name helper which gets injected by vite-node but doesn't exist in browser
|
|
||||||
;(globalThis as any).__name ||= (fn: any) => fn
|
|
||||||
const doc = (globalThis as any).document
|
|
||||||
const win = globalThis as any
|
|
||||||
|
|
||||||
// Cancel any pending auto-hide timer from previous call
|
|
||||||
const timerKey = '__playwriter_labels_timer__'
|
|
||||||
if (win[timerKey]) {
|
|
||||||
win.clearTimeout(win[timerKey])
|
|
||||||
win[timerKey] = null
|
|
||||||
}
|
}
|
||||||
|
const rootElement = root || document.body
|
||||||
// Remove existing labels if present (idempotent)
|
return a11y.computeA11ySnapshot({
|
||||||
doc.getElementById(containerId)?.remove()
|
root: rootElement,
|
||||||
|
interactiveOnly: intOnly,
|
||||||
// Create container - absolute positioned, max z-index, no pointer events
|
renderLabels: true,
|
||||||
const container = doc.createElement('div')
|
})
|
||||||
container.id = containerId
|
|
||||||
container.style.cssText = containerStyles
|
|
||||||
|
|
||||||
// Inject base label CSS
|
|
||||||
const style = doc.createElement('style')
|
|
||||||
style.textContent = labelStyles
|
|
||||||
container.appendChild(style)
|
|
||||||
|
|
||||||
// Track placed label rectangles for overlap detection
|
|
||||||
// Each rect is { left, top, right, bottom } in viewport coordinates
|
|
||||||
const placedLabels: Array<{ left: number; top: number; right: number; bottom: number }> = []
|
|
||||||
|
|
||||||
// Estimate label dimensions (12px font + padding)
|
|
||||||
const LABEL_HEIGHT = 17
|
|
||||||
const LABEL_CHAR_WIDTH = 7 // approximate width per character
|
|
||||||
|
|
||||||
// Parse alpha from rgb/rgba color string (getComputedStyle always returns these formats)
|
|
||||||
function getColorAlpha(color: string): number {
|
|
||||||
if (color === 'transparent') {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
// Match rgba(r, g, b, a) or rgb(r, g, b)
|
|
||||||
const match = color.match(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+)\s*)?\)/)
|
|
||||||
if (match) {
|
|
||||||
return match[1] !== undefined ? parseFloat(match[1]) : 1
|
|
||||||
}
|
|
||||||
return 1 // Default to opaque for unrecognized formats
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if an element has an opaque background that would block elements behind it
|
|
||||||
function isOpaqueElement(el: any): boolean {
|
|
||||||
const style = win.getComputedStyle(el)
|
|
||||||
|
|
||||||
// Check element opacity
|
|
||||||
const opacity = parseFloat(style.opacity)
|
|
||||||
if (opacity < 0.1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check background-color alpha
|
|
||||||
const bgAlpha = getColorAlpha(style.backgroundColor)
|
|
||||||
if (bgAlpha > 0.1) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if has background-image (usually opaque)
|
|
||||||
if (style.backgroundImage !== 'none') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if element is visible (not covered by opaque overlay)
|
|
||||||
function isElementVisible(element: any, rect: any): boolean {
|
|
||||||
const centerX = rect.left + rect.width / 2
|
|
||||||
const centerY = rect.top + rect.height / 2
|
|
||||||
|
|
||||||
// Get all elements at this point, from top to bottom
|
|
||||||
const stack = doc.elementsFromPoint(centerX, centerY) as any[]
|
|
||||||
|
|
||||||
// Find our target element in the stack
|
|
||||||
let targetIndex = -1
|
|
||||||
for (let i = 0; i < stack.length; i++) {
|
|
||||||
if (element.contains(stack[i]) || stack[i].contains(element)) {
|
|
||||||
targetIndex = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Element not in stack at all - not visible
|
|
||||||
if (targetIndex === -1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any opaque element is above our target
|
|
||||||
for (let i = 0; i < targetIndex; i++) {
|
|
||||||
const el = stack[i]
|
|
||||||
// Skip our own overlay container
|
|
||||||
if (el.id === containerId) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Skip pointer-events: none elements (decorative overlays)
|
|
||||||
if (win.getComputedStyle(el).pointerEvents === 'none') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// If this element is opaque, our target is blocked
|
|
||||||
if (isOpaqueElement(el)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if two rectangles overlap
|
|
||||||
function rectsOverlap(
|
|
||||||
a: { left: number; top: number; right: number; bottom: number },
|
|
||||||
b: { left: number; top: number; right: number; bottom: number }
|
|
||||||
) {
|
|
||||||
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create SVG for connector lines
|
|
||||||
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
||||||
svg.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;overflow:visible;'
|
|
||||||
svg.setAttribute('width', `${doc.documentElement.scrollWidth}`)
|
|
||||||
svg.setAttribute('height', `${doc.documentElement.scrollHeight}`)
|
|
||||||
|
|
||||||
// Create defs for arrow markers (one per color)
|
|
||||||
const defs = doc.createElementNS('http://www.w3.org/2000/svg', 'defs')
|
|
||||||
svg.appendChild(defs)
|
|
||||||
const markerCache: Record<string, string> = {}
|
|
||||||
|
|
||||||
function getArrowMarkerId(color: string): string {
|
|
||||||
if (markerCache[color]) {
|
|
||||||
return markerCache[color]
|
|
||||||
}
|
|
||||||
const markerId = `arrow-${color.replace('#', '')}`
|
|
||||||
const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'marker')
|
|
||||||
marker.setAttribute('id', markerId)
|
|
||||||
marker.setAttribute('viewBox', '0 0 10 10')
|
|
||||||
marker.setAttribute('refX', '9')
|
|
||||||
marker.setAttribute('refY', '5')
|
|
||||||
marker.setAttribute('markerWidth', '6')
|
|
||||||
marker.setAttribute('markerHeight', '6')
|
|
||||||
marker.setAttribute('orient', 'auto-start-reverse')
|
|
||||||
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
||||||
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z')
|
|
||||||
path.setAttribute('fill', color)
|
|
||||||
marker.appendChild(path)
|
|
||||||
defs.appendChild(marker)
|
|
||||||
markerCache[color] = markerId
|
|
||||||
return markerId
|
|
||||||
}
|
|
||||||
|
|
||||||
container.appendChild(svg)
|
|
||||||
|
|
||||||
// Create label for each interactive element
|
|
||||||
let count = 0
|
|
||||||
for (const { ref, role, element } of refs) {
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
|
|
||||||
// Skip elements with no size (hidden)
|
|
||||||
if (rect.width === 0 || rect.height === 0) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip elements that are covered by opaque overlays
|
|
||||||
if (!isElementVisible(element, rect)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate label position and dimensions
|
|
||||||
const labelWidth = ref.length * LABEL_CHAR_WIDTH + 8 // +8 for padding
|
|
||||||
const labelLeft = rect.left
|
|
||||||
const labelTop = Math.max(0, rect.top - LABEL_HEIGHT)
|
|
||||||
const labelRect = {
|
|
||||||
left: labelLeft,
|
|
||||||
top: labelTop,
|
|
||||||
right: labelLeft + labelWidth,
|
|
||||||
bottom: labelTop + LABEL_HEIGHT,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if this label would overlap with any already-placed label
|
|
||||||
let overlaps = false
|
|
||||||
for (const placed of placedLabels) {
|
|
||||||
if (rectsOverlap(labelRect, placed)) {
|
|
||||||
overlaps = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (overlaps) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get colors for this role
|
|
||||||
const [gradTop, gradBottom, border] = roleColors[role] || defaultColors
|
|
||||||
|
|
||||||
// Place the label
|
|
||||||
const label = doc.createElement('div')
|
|
||||||
label.className = '__pw_label__'
|
|
||||||
label.textContent = ref
|
|
||||||
label.style.background = `linear-gradient(to bottom, ${gradTop} 0%, ${gradBottom} 100%)`
|
|
||||||
label.style.border = `1px solid ${border}`
|
|
||||||
|
|
||||||
// Position above element, accounting for scroll
|
|
||||||
label.style.left = `${win.scrollX + labelLeft}px`
|
|
||||||
label.style.top = `${win.scrollY + labelTop}px`
|
|
||||||
|
|
||||||
container.appendChild(label)
|
|
||||||
|
|
||||||
// Draw connector line from label bottom-center to element center with arrow
|
|
||||||
const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line')
|
|
||||||
const labelCenterX = win.scrollX + labelLeft + labelWidth / 2
|
|
||||||
const labelBottomY = win.scrollY + labelTop + LABEL_HEIGHT
|
|
||||||
const elementCenterX = win.scrollX + rect.left + rect.width / 2
|
|
||||||
const elementCenterY = win.scrollY + rect.top + rect.height / 2
|
|
||||||
line.setAttribute('x1', `${labelCenterX}`)
|
|
||||||
line.setAttribute('y1', `${labelBottomY}`)
|
|
||||||
line.setAttribute('x2', `${elementCenterX}`)
|
|
||||||
line.setAttribute('y2', `${elementCenterY}`)
|
|
||||||
line.setAttribute('stroke', border)
|
|
||||||
line.setAttribute('stroke-width', '1.5')
|
|
||||||
line.setAttribute('marker-end', `url(#${getArrowMarkerId(border)})`)
|
|
||||||
svg.appendChild(line)
|
|
||||||
|
|
||||||
placedLabels.push(labelRect)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.documentElement.appendChild(container)
|
|
||||||
|
|
||||||
// Auto-hide labels after 30 seconds to prevent stale labels
|
|
||||||
// Store timer ID so it can be cancelled if showAriaRefLabels is called again
|
|
||||||
win[timerKey] = win.setTimeout(function() {
|
|
||||||
doc.getElementById(containerId)?.remove()
|
|
||||||
win[timerKey] = null
|
|
||||||
}, 30000)
|
|
||||||
|
|
||||||
return count
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refs: refsWithRoles.map(({ ref, role, element }) => ({ ref, role, element })),
|
root: rootHandle,
|
||||||
containerId: LABELS_CONTAINER_ID,
|
interactiveOnly,
|
||||||
containerStyles: CONTAINER_STYLES,
|
|
||||||
labelStyles: LABEL_STYLES,
|
|
||||||
roleColors: ROLE_COLORS,
|
|
||||||
defaultColors: DEFAULT_COLORS,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return { snapshot, labelCount }
|
if (log) {
|
||||||
|
log(`computeA11ySnapshot: ${Date.now() - computeStart}ms (${result.labelCount} labels)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot: result.snapshot,
|
||||||
|
labelCount: result.labelCount,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove all aria ref labels from the page.
|
* Remove all aria ref labels from the page.
|
||||||
*/
|
*/
|
||||||
export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> {
|
export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> {
|
||||||
await page.evaluate((id) => {
|
await page.evaluate(() => {
|
||||||
const doc = (globalThis as any).document
|
const a11y = (globalThis as any).__a11y
|
||||||
const win = globalThis as any
|
if (a11y) {
|
||||||
|
a11y.hideA11yLabels()
|
||||||
// Cancel any pending auto-hide timer
|
} else {
|
||||||
const timerKey = '__playwriter_labels_timer__'
|
// Fallback if client not loaded
|
||||||
if (win[timerKey]) {
|
const doc = document
|
||||||
win.clearTimeout(win[timerKey])
|
const win = window as any
|
||||||
win[timerKey] = null
|
const timerKey = '__playwriter_labels_timer__'
|
||||||
|
if (win[timerKey]) {
|
||||||
|
win.clearTimeout(win[timerKey])
|
||||||
|
win[timerKey] = null
|
||||||
|
}
|
||||||
|
doc.getElementById('__playwriter_labels__')?.remove()
|
||||||
}
|
}
|
||||||
|
})
|
||||||
doc.getElementById(id)?.remove()
|
|
||||||
}, LABELS_CONTAINER_ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1183,24 +871,27 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void>
|
|||||||
* Shows Vimium-style labels, captures the screenshot, then removes the labels.
|
* Shows Vimium-style labels, captures the screenshot, then removes the labels.
|
||||||
* The screenshot is automatically included in the MCP response.
|
* The screenshot is automatically included in the MCP response.
|
||||||
*
|
*
|
||||||
|
* @param page - Playwright page
|
||||||
|
* @param locator - Optional locator to scope labels to a subtree
|
||||||
* @param collector - Array to collect screenshots (passed by MCP execute tool)
|
* @param collector - Array to collect screenshots (passed by MCP execute tool)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* await screenshotWithAccessibilityLabels({ page })
|
* await screenshotWithAccessibilityLabels({ page })
|
||||||
* // Screenshot is automatically included in the MCP response
|
* // Screenshot is automatically included in the MCP response
|
||||||
* // Use aria-ref from the snapshot to interact with elements
|
* // Use ref from the snapshot to interact with elements
|
||||||
* await page.locator('aria-ref=e5').click()
|
* await page.locator('[data-testid="submit-btn"]').click()
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export async function screenshotWithAccessibilityLabels({ page, interactiveOnly = true, collector, logger }: {
|
export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = true, collector, logger }: {
|
||||||
page: Page
|
page: Page
|
||||||
|
locator?: Locator
|
||||||
interactiveOnly?: boolean
|
interactiveOnly?: boolean
|
||||||
collector: ScreenshotResult[]
|
collector: ScreenshotResult[]
|
||||||
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
|
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const showLabelsStart = Date.now()
|
const showLabelsStart = Date.now()
|
||||||
const { snapshot, labelCount } = await showAriaRefLabels({ page, interactiveOnly, logger })
|
const { snapshot, labelCount } = await showAriaRefLabels({ page, locator, interactiveOnly, logger })
|
||||||
const log = logger?.info ?? logger?.error
|
const log = logger?.info ?? logger?.error
|
||||||
if (log) {
|
if (log) {
|
||||||
log(`showAriaRefLabels: ${Date.now() - showLabelsStart}ms`)
|
log(`showAriaRefLabels: ${Date.now() - showLabelsStart}ms`)
|
||||||
@@ -1280,3 +971,6 @@ export async function screenshotWithAccessibilityLabels({ page, interactiveOnly
|
|||||||
labelCount,
|
labelCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export for backward compatibility
|
||||||
|
export { getAriaSnapshot as getAriaSnapshotWithRefs }
|
||||||
|
|||||||
+62
-62
@@ -3,7 +3,7 @@
|
|||||||
* Used by both MCP and CLI to execute Playwright code with persistent state.
|
* Used by both MCP and CLI to execute Playwright code with persistent state.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
|
import { Page, Browser, BrowserContext, chromium, Locator } from 'playwright-core'
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
@@ -21,7 +21,7 @@ import { Editor } from './editor.js'
|
|||||||
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
|
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
|
||||||
import { getReactSource, type ReactSourceLocation } from './react-source.js'
|
import { getReactSource, type ReactSourceLocation } from './react-source.js'
|
||||||
import { ScopedFS } from './scoped-fs.js'
|
import { ScopedFS } from './scoped-fs.js'
|
||||||
import { screenshotWithAccessibilityLabels, formatSnapshot, DEFAULT_SNAPSHOT_FORMAT, type ScreenshotResult, type SnapshotFormat } from './aria-snapshot.js'
|
import { screenshotWithAccessibilityLabels, formatSnapshot, DEFAULT_SNAPSHOT_FORMAT, getAriaSnapshot, type ScreenshotResult, type SnapshotFormat } from './aria-snapshot.js'
|
||||||
export type { SnapshotFormat }
|
export type { SnapshotFormat }
|
||||||
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
|
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
|
||||||
import { startRecording, stopRecording, isRecording, cancelRecording } from './screen-recording.js'
|
import { startRecording, stopRecording, isRecording, cancelRecording } from './screen-recording.js'
|
||||||
@@ -406,75 +406,75 @@ export class PlaywrightExecutor {
|
|||||||
|
|
||||||
const accessibilitySnapshot = async (options: {
|
const accessibilitySnapshot = async (options: {
|
||||||
page: Page
|
page: Page
|
||||||
|
/** Optional locator to scope the snapshot to a subtree */
|
||||||
|
locator?: Locator
|
||||||
search?: string | RegExp
|
search?: string | RegExp
|
||||||
showDiffSinceLastCall?: boolean
|
showDiffSinceLastCall?: boolean
|
||||||
/** Snapshot format: 'raw', 'compact', 'interactive', 'interactive-dedup' (default) */
|
/** Snapshot format: 'raw', 'compact', 'interactive', 'interactive-dedup' (default) */
|
||||||
format?: SnapshotFormat
|
format?: SnapshotFormat
|
||||||
}) => {
|
}) => {
|
||||||
const { page: targetPage, search, showDiffSinceLastCall = false, format = DEFAULT_SNAPSHOT_FORMAT } = options
|
const { page: targetPage, locator, search, showDiffSinceLastCall = false, format = DEFAULT_SNAPSHOT_FORMAT } = options
|
||||||
if ((targetPage as any)._snapshotForAI) {
|
|
||||||
const snapshot = await (targetPage as any)._snapshotForAI()
|
|
||||||
const rawStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2)
|
|
||||||
const sanitizedStr = rawStr.toWellFormed?.() ?? rawStr
|
|
||||||
// Apply format transformation
|
|
||||||
const snapshotStr = formatSnapshot(sanitizedStr, format, this.logger)
|
|
||||||
|
|
||||||
if (showDiffSinceLastCall) {
|
// Use new in-page implementation via getAriaSnapshot
|
||||||
const previousSnapshot = this.lastSnapshots.get(targetPage)
|
const { snapshot: rawSnapshot } = await getAriaSnapshot({ page: targetPage, locator })
|
||||||
if (!previousSnapshot) {
|
const sanitizedStr = rawSnapshot.toWellFormed?.() ?? rawSnapshot
|
||||||
this.lastSnapshots.set(targetPage, snapshotStr)
|
// Apply format transformation
|
||||||
return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.'
|
const snapshotStr = formatSnapshot(sanitizedStr, format, this.logger)
|
||||||
}
|
|
||||||
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', { context: 3 })
|
if (showDiffSinceLastCall) {
|
||||||
if (patch.split('\n').length <= 4) {
|
const previousSnapshot = this.lastSnapshots.get(targetPage)
|
||||||
return 'No changes detected since last snapshot'
|
if (!previousSnapshot) {
|
||||||
}
|
this.lastSnapshots.set(targetPage, snapshotStr)
|
||||||
return patch
|
return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.'
|
||||||
}
|
}
|
||||||
|
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', { context: 3 })
|
||||||
this.lastSnapshots.set(targetPage, snapshotStr)
|
if (patch.split('\n').length <= 4) {
|
||||||
|
return 'No changes detected since last snapshot'
|
||||||
if (!search) {
|
|
||||||
return snapshotStr
|
|
||||||
}
|
}
|
||||||
|
return patch
|
||||||
const lines = snapshotStr.split('\n')
|
|
||||||
const matchIndices: number[] = []
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i]
|
|
||||||
const isMatch = isRegExp(search) ? search.test(line) : line.includes(search)
|
|
||||||
if (isMatch) {
|
|
||||||
matchIndices.push(i)
|
|
||||||
if (matchIndices.length >= 10) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchIndices.length === 0) {
|
|
||||||
return 'No matches found'
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTEXT_LINES = 5
|
|
||||||
const includedLines = new Set<number>()
|
|
||||||
for (const idx of matchIndices) {
|
|
||||||
const start = Math.max(0, idx - CONTEXT_LINES)
|
|
||||||
const end = Math.min(lines.length - 1, idx + CONTEXT_LINES)
|
|
||||||
for (let i = start; i <= end; i++) {
|
|
||||||
includedLines.add(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedIndices = [...includedLines].sort((a, b) => a - b)
|
|
||||||
const result: string[] = []
|
|
||||||
for (let i = 0; i < sortedIndices.length; i++) {
|
|
||||||
const lineIdx = sortedIndices[i]
|
|
||||||
if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) {
|
|
||||||
result.push('---')
|
|
||||||
}
|
|
||||||
result.push(lines[lineIdx])
|
|
||||||
}
|
|
||||||
return result.join('\n')
|
|
||||||
}
|
}
|
||||||
throw new Error('accessibilitySnapshot is not available on this page')
|
|
||||||
|
this.lastSnapshots.set(targetPage, snapshotStr)
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
return snapshotStr
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = snapshotStr.split('\n')
|
||||||
|
const matchIndices: number[] = []
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const isMatch = isRegExp(search) ? search.test(line) : line.includes(search)
|
||||||
|
if (isMatch) {
|
||||||
|
matchIndices.push(i)
|
||||||
|
if (matchIndices.length >= 10) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchIndices.length === 0) {
|
||||||
|
return 'No matches found'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTEXT_LINES = 5
|
||||||
|
const includedLines = new Set<number>()
|
||||||
|
for (const idx of matchIndices) {
|
||||||
|
const start = Math.max(0, idx - CONTEXT_LINES)
|
||||||
|
const end = Math.min(lines.length - 1, idx + CONTEXT_LINES)
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
includedLines.add(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedIndices = [...includedLines].sort((a, b) => a - b)
|
||||||
|
const result: string[] = []
|
||||||
|
for (let i = 0; i < sortedIndices.length; i++) {
|
||||||
|
const lineIdx = sortedIndices[i]
|
||||||
|
if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) {
|
||||||
|
result.push('---')
|
||||||
|
}
|
||||||
|
result.push(lines[lineIdx])
|
||||||
|
}
|
||||||
|
return result.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLocatorStringForElement = async (element: any) => {
|
const getLocatorStringForElement = async (element: any) => {
|
||||||
|
|||||||
@@ -578,12 +578,13 @@ describe('Snapshot & Screenshot Tests', () => {
|
|||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
const page = await browserContext.newPage()
|
const page = await browserContext.newPage()
|
||||||
|
// Use data-testid for stable refs, regular id for the button
|
||||||
await page.setContent(`
|
await page.setContent(`
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<button id="submit-btn">Submit Form</button>
|
<button data-testid="submit-btn">Submit Form</button>
|
||||||
<a href="/about">About Us</a>
|
<a href="/about" data-testid="about-link">About Us</a>
|
||||||
<input type="text" placeholder="Enter your name" />
|
<input type="text" placeholder="Enter your name" data-testid="name-input" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`)
|
`)
|
||||||
@@ -612,55 +613,42 @@ describe('Snapshot & Screenshot Tests', () => {
|
|||||||
expect(ariaResult.snapshot).toBeDefined()
|
expect(ariaResult.snapshot).toBeDefined()
|
||||||
expect(ariaResult.snapshot.length).toBeGreaterThan(0)
|
expect(ariaResult.snapshot.length).toBeGreaterThan(0)
|
||||||
expect(ariaResult.snapshot).toContain('Submit Form')
|
expect(ariaResult.snapshot).toContain('Submit Form')
|
||||||
|
// New implementation uses stable test IDs as refs
|
||||||
|
expect(ariaResult.snapshot).toContain('[ref=submit-btn]')
|
||||||
|
expect(ariaResult.snapshot).toContain('[ref=about-link]')
|
||||||
|
expect(ariaResult.snapshot).toContain('[ref=name-input]')
|
||||||
|
|
||||||
expect(ariaResult.refToElement.size).toBeGreaterThan(0)
|
expect(ariaResult.refToElement.size).toBeGreaterThan(0)
|
||||||
console.log('RefToElement map size:', ariaResult.refToElement.size)
|
console.log('RefToElement map size:', ariaResult.refToElement.size)
|
||||||
console.log('RefToElement entries:', [...ariaResult.refToElement.entries()])
|
console.log('RefToElement entries:', [...ariaResult.refToElement.entries()])
|
||||||
|
|
||||||
const btnViaAriaRef = cdpPage!.locator('aria-ref=e2')
|
// Verify refs are stable test IDs
|
||||||
const btnTextViaRef = await btnViaAriaRef.textContent()
|
expect(ariaResult.refToElement.has('submit-btn')).toBe(true)
|
||||||
console.log('Button text via aria-ref=e2:', btnTextViaRef)
|
expect(ariaResult.refToElement.has('about-link')).toBe(true)
|
||||||
|
expect(ariaResult.refToElement.has('name-input')).toBe(true)
|
||||||
|
|
||||||
|
// Use getSelectorForRef to get CSS selector for a ref
|
||||||
|
const btnSelector = ariaResult.getSelectorForRef('submit-btn')
|
||||||
|
expect(btnSelector).toBeDefined()
|
||||||
|
console.log('Button selector:', btnSelector)
|
||||||
|
|
||||||
|
// Verify the selector works
|
||||||
|
const btnViaSelector = cdpPage!.locator('[data-testid="submit-btn"]')
|
||||||
|
const btnTextViaRef = await btnViaSelector.textContent()
|
||||||
|
console.log('Button text via selector:', btnTextViaRef)
|
||||||
expect(btnTextViaRef).toBe('Submit Form')
|
expect(btnTextViaRef).toBe('Submit Form')
|
||||||
|
|
||||||
const submitBtn = cdpPage!.locator('#submit-btn')
|
// Test role and name
|
||||||
const btnAriaRef = await ariaResult.getRefForLocator(submitBtn)
|
const btnInfo = ariaResult.refToElement.get('submit-btn')
|
||||||
console.log('Button ariaRef:', btnAriaRef)
|
expect(btnInfo?.role).toBe('button')
|
||||||
expect(btnAriaRef).toBeDefined()
|
expect(btnInfo?.name).toBe('Submit Form')
|
||||||
expect(btnAriaRef?.role).toBe('button')
|
|
||||||
expect(btnAriaRef?.name).toBe('Submit Form')
|
|
||||||
expect(btnAriaRef?.ref).toMatch(/^e\d+$/)
|
|
||||||
|
|
||||||
const btnFromRef = cdpPage!.locator(`aria-ref=${btnAriaRef?.ref}`)
|
const linkInfo = ariaResult.refToElement.get('about-link')
|
||||||
const btnText = await btnFromRef.textContent()
|
expect(linkInfo?.role).toBe('link')
|
||||||
expect(btnText).toBe('Submit Form')
|
expect(linkInfo?.name).toBe('About Us')
|
||||||
|
|
||||||
const btnRefStr = await ariaResult.getRefStringForLocator(submitBtn)
|
const inputInfo = ariaResult.refToElement.get('name-input')
|
||||||
console.log('Button ref string:', btnRefStr)
|
expect(inputInfo?.role).toBe('textbox')
|
||||||
expect(btnRefStr).toBe(btnAriaRef?.ref)
|
|
||||||
|
|
||||||
const aboutLink = cdpPage!.locator('a')
|
|
||||||
const linkAriaRef = await ariaResult.getRefForLocator(aboutLink)
|
|
||||||
console.log('Link ariaRef:', linkAriaRef)
|
|
||||||
expect(linkAriaRef).toBeDefined()
|
|
||||||
expect(linkAriaRef?.role).toBe('link')
|
|
||||||
expect(linkAriaRef?.name).toBe('About Us')
|
|
||||||
|
|
||||||
const linkFromRef = cdpPage!.locator(`aria-ref=${linkAriaRef?.ref}`)
|
|
||||||
const linkText = await linkFromRef.textContent()
|
|
||||||
expect(linkText).toBe('About Us')
|
|
||||||
|
|
||||||
const inputField = cdpPage!.locator('input')
|
|
||||||
const inputAriaRef = await ariaResult.getRefForLocator(inputField)
|
|
||||||
console.log('Input ariaRef:', inputAriaRef)
|
|
||||||
expect(inputAriaRef).toBeDefined()
|
|
||||||
expect(inputAriaRef?.role).toBe('textbox')
|
|
||||||
|
|
||||||
const batchRefs = await ariaResult.getRefsForLocators([submitBtn, aboutLink, inputField])
|
|
||||||
console.log('Batch refs:', batchRefs)
|
|
||||||
expect(batchRefs).toHaveLength(3)
|
|
||||||
expect(batchRefs[0]?.ref).toBe(btnAriaRef?.ref)
|
|
||||||
expect(batchRefs[1]?.ref).toBe(linkAriaRef?.ref)
|
|
||||||
expect(batchRefs[2]?.ref).toBe(inputAriaRef?.ref)
|
|
||||||
|
|
||||||
await browser.close()
|
await browser.close()
|
||||||
await page.close()
|
await page.close()
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"types": ["node", "chrome"]
|
"types": ["node", "chrome"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/a11y-client.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+8
@@ -73,6 +73,9 @@ importers:
|
|||||||
diff:
|
diff:
|
||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.0.2
|
version: 8.0.2
|
||||||
|
dom-accessibility-api:
|
||||||
|
specifier: ^0.7.1
|
||||||
|
version: 0.7.1
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.10.6
|
specifier: ^4.10.6
|
||||||
version: 4.10.6
|
version: 4.10.6
|
||||||
@@ -2959,6 +2962,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
dom-accessibility-api@0.7.1:
|
||||||
|
resolution: {integrity: sha512-vdnCeZD+3wZ+8h8xXL/ZtBlvvoobOFyPzSiIfO6sGOZDqjFx4aLMAjZhl4rawj5xYz3UwP6Tgvyh0iH4IOCVnQ==}
|
||||||
|
|
||||||
dom-serializer@0.2.2:
|
dom-serializer@0.2.2:
|
||||||
resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
|
resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
|
||||||
|
|
||||||
@@ -8217,6 +8223,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
path-type: 4.0.0
|
path-type: 4.0.0
|
||||||
|
|
||||||
|
dom-accessibility-api@0.7.1: {}
|
||||||
|
|
||||||
dom-serializer@0.2.2:
|
dom-serializer@0.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user