feat: shorten a11y labels and add ref resolver

This commit is contained in:
Tommy D. Rossi
2026-02-03 11:22:56 +01:00
parent 0341705677
commit 2b7cabb1c7
10 changed files with 312 additions and 505 deletions
+2 -1
View File
@@ -28,11 +28,11 @@
"devDependencies": { "devDependencies": {
"@browserbasehq/stagehand": "^3.0.5", "@browserbasehq/stagehand": "^3.0.5",
"@mizchi/selector-generator": "1.50.0-next", "@mizchi/selector-generator": "1.50.0-next",
"@mozilla/readability": "^0.6.0",
"@types/chrome": "^0.0.315", "@types/chrome": "^0.0.315",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/ui": "^4.0.8", "@vitest/ui": "^4.0.8",
"@mozilla/readability": "^0.6.0",
"bippy": "^0.5.27", "bippy": "^0.5.27",
"devtools-protocol": "^0.0.1568893", "devtools-protocol": "^0.0.1568893",
"image-size": "^2.0.2", "image-size": "^2.0.2",
@@ -46,6 +46,7 @@
"@hono/node-ws": "^1.2.0", "@hono/node-ws": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"@xmorse/cac": "^6.0.7", "@xmorse/cac": "^6.0.7",
"async-sema": "^3.1.1",
"diff": "^8.0.2", "diff": "^8.0.2",
"dom-accessibility-api": "^0.7.1", "dom-accessibility-api": "^0.7.1",
"hono": "^4.10.6", "hono": "^4.10.6",
+53 -429
View File
@@ -1,32 +1,19 @@
/** /**
* Browser-side accessibility snapshot code. * Browser-side accessibility label renderer.
* Bundled and injected into page context via CDP. * 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' export interface LabelBox {
x: number
y: number
width: number
height: number
}
// ============================================================================ export interface A11yLabel {
// Types
// ============================================================================
export interface A11yElement {
ref: string ref: string
role: string role: string
name: string box: LabelBox
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
} }
// ============================================================================ // ============================================================================
@@ -36,281 +23,38 @@ export interface ComputeSnapshotOptions {
const LABELS_CONTAINER_ID = '__playwriter_labels__' const LABELS_CONTAINER_ID = '__playwriter_labels__'
const LABELS_TIMER_KEY = '__playwriter_labels_timer__' 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 // Color scheme for labels by role
const ROLE_COLORS: Record<string, [string, string, string]> = { const ROLE_COLORS: Record<string, [string, string, string]> = {
link: ['#FFF785', '#FFC542', '#E3BE23'], link: ['#FFF785', '#FFC542', '#E3BE23'],
button: ['#FFE0B2', '#FFCC80', '#FFB74D'], button: ['#FFE0B2', '#FFCC80', '#FFB74D'],
textbox: ['#FFCDD2', '#EF9A9A', '#E57373'], textbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
combobox: ['#FFCDD2', '#EF9A9A', '#E57373'], combobox: ['#F8BBD0', '#F48FB1', '#F06292'],
searchbox: ['#FFCDD2', '#EF9A9A', '#E57373'], searchbox: ['#F8BBD0', '#F48FB1', '#F06292'],
spinbutton: ['#FFCDD2', '#EF9A9A', '#E57373'], checkbox: ['#C8E6C9', '#A5D6A7', '#81C784'],
checkbox: ['#F8BBD0', '#F48FB1', '#EC407A'], radio: ['#C8E6C9', '#A5D6A7', '#81C784'],
radio: ['#F8BBD0', '#F48FB1', '#EC407A'], slider: ['#BBDEFB', '#90CAF9', '#64B5F6'],
switch: ['#F8BBD0', '#F48FB1', '#EC407A'], spinbutton: ['#BBDEFB', '#90CAF9', '#64B5F6'],
slider: ['#FFCCBC', '#FFAB91', '#FF8A65'], switch: ['#D1C4E9', '#B39DDB', '#9575CD'],
menuitem: ['#FFAB91', '#FF8A65', '#FF7043'], menuitem: ['#FFE0B2', '#FFCC80', '#FFB74D'],
menuitemcheckbox: ['#FFAB91', '#FF8A65', '#FF7043'], menuitemcheckbox: ['#FFE0B2', '#FFCC80', '#FFB74D'],
menuitemradio: ['#FFAB91', '#FF8A65', '#FF7043'], menuitemradio: ['#FFE0B2', '#FFCC80', '#FFB74D'],
tab: ['#FFE082', '#FFD54F', '#FFC107'], option: ['#FFE0B2', '#FFCC80', '#FFB74D'],
option: ['#FFE082', '#FFD54F', '#FFC107'], tab: ['#FFE0B2', '#FFCC80', '#FFB74D'],
treeitem: ['#FFE082', '#FFD54F', '#FFC107'], treeitem: ['#FFE0B2', '#FFCC80', '#FFB74D'],
img: ['#B3E5FC', '#81D4FA', '#4FC3F7'], img: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
video: ['#B3E5FC', '#81D4FA', '#4FC3F7'], video: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
audio: ['#B3E5FC', '#81D4FA', '#4FC3F7'], audio: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
} }
const DEFAULT_COLORS: [string, string, string] = ['#FFF785', '#FFC542', '#E3BE23']
// ============================================================================ const DEFAULT_COLORS: [string, string, string] = ['#FFF9C4', '#FFF59D', '#FFEB3B']
// 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
]
function getStableRef(element: Element): { value: string; attr: string } | null {
const id = element.getAttribute('id')
if (id) {
return { value: id, attr: 'id' }
}
// Check test ID attributes
for (const attr of TEST_ID_ATTRS) {
const value = element.getAttribute(attr)
if (value && value.length > 0) {
return { value, attr }
}
}
return null
}
function escapeLocatorValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function buildLocatorFromStable(stable: { value: string; attr: string }): string {
const escaped = escapeLocatorValue(stable.value)
return `[${stable.attr}="${escaped}"]`
}
function buildBaseLocator({ role, name, stable }: { role: string; name: string; stable: { value: string; attr: string } | null }): string {
if (stable) {
return buildLocatorFromStable(stable)
}
const trimmedName = name.trim()
if (trimmedName.length > 0) {
const escapedName = escapeLocatorValue(trimmedName)
return `role=${role}[name="${escapedName}"]`
}
return `role=${role}`
}
// ============================================================================
// 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 // Label Rendering
// ============================================================================ // ============================================================================
function renderLabels(elements: A11yElement[]): number { function renderA11yLabels(labels: A11yLabel[]): number {
const doc = document const doc = document
const win = window as any const win = window as typeof window & { [LABELS_TIMER_KEY]?: number | null }
// Cancel any pending auto-hide timer // Cancel any pending auto-hide timer
if (win[LABELS_TIMER_KEY]) { if (win[LABELS_TIMER_KEY]) {
@@ -381,19 +125,31 @@ function renderLabels(elements: A11yElement[]): number {
const LABEL_HEIGHT = 17 const LABEL_HEIGHT = 17
const LABEL_CHAR_WIDTH = 7 const LABEL_CHAR_WIDTH = 7
let count = 0 const viewportLeft = win.scrollX
for (const { ref, role, element } of elements) { const viewportTop = win.scrollY
const rect = element.getBoundingClientRect() const viewportRight = viewportLeft + win.innerWidth
const viewportBottom = viewportTop + win.innerHeight
// Skip if covered let count = 0
if (isElementCovered(element, rect)) { for (const { ref, role, box } of labels) {
const rectLeft = box.x
const rectTop = box.y
const rectRight = rectLeft + box.width
const rectBottom = rectTop + box.height
if (box.width <= 0 || box.height <= 0) {
continue
}
// Skip if outside viewport
if (rectRight < viewportLeft || rectLeft > viewportRight || rectBottom < viewportTop || rectTop > viewportBottom) {
continue continue
} }
// Calculate label position // Calculate label position
const labelWidth = ref.length * LABEL_CHAR_WIDTH + 8 const labelWidth = ref.length * LABEL_CHAR_WIDTH + 8
const labelLeft = rect.left const labelLeft = rectLeft
const labelTop = Math.max(0, rect.top - LABEL_HEIGHT) const labelTop = Math.max(0, rectTop - LABEL_HEIGHT)
const labelRect = { const labelRect = {
left: labelLeft, left: labelLeft,
top: labelTop, top: labelTop,
@@ -427,16 +183,16 @@ function renderLabels(elements: A11yElement[]): number {
label.textContent = ref label.textContent = ref
label.style.background = `linear-gradient(to bottom, ${gradTop} 0%, ${gradBottom} 100%)` label.style.background = `linear-gradient(to bottom, ${gradTop} 0%, ${gradBottom} 100%)`
label.style.border = `1px solid ${border}` label.style.border = `1px solid ${border}`
label.style.left = `${win.scrollX + labelLeft}px` label.style.left = `${labelLeft}px`
label.style.top = `${win.scrollY + labelTop}px` label.style.top = `${labelTop}px`
container.appendChild(label) container.appendChild(label)
// Draw connector line // Draw connector line
const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line') const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line')
const labelCenterX = win.scrollX + labelLeft + labelWidth / 2 const labelCenterX = labelLeft + labelWidth / 2
const labelBottomY = win.scrollY + labelTop + LABEL_HEIGHT const labelBottomY = labelTop + LABEL_HEIGHT
const elementCenterX = win.scrollX + rect.left + rect.width / 2 const elementCenterX = rectLeft + box.width / 2
const elementCenterY = win.scrollY + rect.top + rect.height / 2 const elementCenterY = rectTop + box.height / 2
line.setAttribute('x1', `${labelCenterX}`) line.setAttribute('x1', `${labelCenterX}`)
line.setAttribute('y1', `${labelBottomY}`) line.setAttribute('y1', `${labelBottomY}`)
line.setAttribute('x2', `${elementCenterX}`) line.setAttribute('x2', `${elementCenterX}`)
@@ -461,144 +217,12 @@ function renderLabels(elements: A11yElement[]): number {
return count return count
} }
// ============================================================================
// Snapshot Generation
// ============================================================================
function buildSnapshotLine(role: string, name: string, locator: string | null): string {
let line = `- ${role}`
if (name) {
const escapedName = name.replace(/"/g, '\\"')
line += ` "${escapedName}"`
}
if (locator) {
line += ` ${locator}`
}
return line
}
// ============================================================================
// Main Entry Point
// ============================================================================
export function computeA11ySnapshot(options: ComputeSnapshotOptions): A11ySnapshotResult {
const { root, interactiveOnly, renderLabels: shouldRenderLabels } = options
// Track refs for deduplication
const refCounts = new Map<string, number>()
const a11yElements: A11yElement[] = []
const refs: Array<{ ref: string; role: string; name: string }> = []
let fallbackCounter = 0
const getAccessibleName = (element: Element): string => {
try {
return computeAccessibleName(element) || ''
} catch {
return (
element.getAttribute('aria-label') ||
element.getAttribute('alt') ||
element.getAttribute('title') ||
(element.textContent || '').trim().slice(0, 100) ||
''
)
}
}
const createRefForElement = (element: Element): { ref: string; stable: { value: string; attr: string } | null } => {
const stable = getStableRef(element)
let baseRef = stable?.value
if (!baseRef) {
fallbackCounter++
baseRef = `e${fallbackCounter}`
}
const count = refCounts.get(baseRef) || 0
refCounts.set(baseRef, count + 1)
const ref = count === 0 ? baseRef : `${baseRef}-${count + 1}`
return { ref, stable }
}
const cssSelector = interactiveOnly ? INTERACTIVE_SELECTORS : '*'
const elements = root.matches(cssSelector)
? [root, ...Array.from(root.querySelectorAll(cssSelector))]
: Array.from(root.querySelectorAll(cssSelector))
const baseLocatorByElement = new WeakMap<Element, string>()
const includedElements = elements.reduce<Array<{ element: Element; role: string; name: string; isInteractive: boolean }>>((acc, element) => {
if (!isElementVisible(element)) {
return acc
}
const role = computeRole(element)
const name = getAccessibleName(element)
const hasName = name.trim().length > 0
const isInteractive = INTERACTIVE_ROLES.has(role)
const shouldInclude = interactiveOnly
? isInteractive
: isInteractive || hasName
if (!shouldInclude) {
return acc
}
if (isInteractive) {
const { ref, stable } = createRefForElement(element)
const baseLocator = buildBaseLocator({ role, name, stable })
baseLocatorByElement.set(element, baseLocator)
a11yElements.push({ ref, role, name, element })
refs.push({ ref, role, name })
}
acc.push({ element, role, name, isInteractive })
return acc
}, [])
const locatorCounts = includedElements.reduce<Map<string, number>>((acc, entry) => {
if (!entry.isInteractive) {
return acc
}
const baseLocator = baseLocatorByElement.get(entry.element)
if (!baseLocator) {
return acc
}
acc.set(baseLocator, (acc.get(baseLocator) ?? 0) + 1)
return acc
}, new Map<string, number>())
const locatorIndices = new Map<string, number>()
const snapshotLines = includedElements.map((entry) => {
let locator: string | null = null
if (entry.isInteractive) {
const baseLocator = baseLocatorByElement.get(entry.element)
if (baseLocator) {
const count = locatorCounts.get(baseLocator) ?? 0
const index = locatorIndices.get(baseLocator) ?? 0
locatorIndices.set(baseLocator, index + 1)
locator = count > 1 ? `${baseLocator} >> nth=${index}` : baseLocator
}
}
return buildSnapshotLine(entry.role, entry.name, locator)
})
const snapshot = snapshotLines.join('\n')
let labelCount = 0
if (shouldRenderLabels) {
labelCount = renderLabels(a11yElements)
}
return { snapshot, labelCount, refs }
}
// ============================================================================ // ============================================================================
// Hide Labels // Hide Labels
// ============================================================================ // ============================================================================
export function hideA11yLabels(): void { export function hideA11yLabels(): void {
const win = window as any const win = window as typeof window & { [LABELS_TIMER_KEY]?: number | null }
if (win[LABELS_TIMER_KEY]) { if (win[LABELS_TIMER_KEY]) {
win.clearTimeout(win[LABELS_TIMER_KEY]) win.clearTimeout(win[LABELS_TIMER_KEY])
win[LABELS_TIMER_KEY] = null win[LABELS_TIMER_KEY] = null
@@ -610,7 +234,7 @@ export function hideA11yLabels(): void {
// Expose on globalThis for injection // Expose on globalThis for injection
// ============================================================================ // ============================================================================
;(globalThis as any).__a11y = { ;(globalThis as { __a11y?: { renderA11yLabels: (labels: A11yLabel[]) => number; hideA11yLabels: () => void } }).__a11y = {
computeA11ySnapshot, renderA11yLabels,
hideA11yLabels, hideA11yLabels,
} }
+173 -39
View File
@@ -4,6 +4,8 @@ import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import type { Protocol } from 'devtools-protocol' import type { Protocol } from 'devtools-protocol'
import { Sema } from 'async-sema'
import type { ICDPSession } from './cdp-session.js'
import { getCDPSessionForPage } from './cdp-session.js' import { getCDPSessionForPage } from './cdp-session.js'
// Import sharp at module level - resolves to null if not available // Import sharp at module level - resolves to null if not available
@@ -73,12 +75,15 @@ export interface AriaRef {
role: string role: string
name: string name: string
ref: string ref: string
backendNodeId?: Protocol.DOM.BackendNodeId
} }
export type AriaSnapshotNode = { export type AriaSnapshotNode = {
role: string role: string
name: string name: string
locator?: string locator?: string
ref?: string
backendNodeId?: Protocol.DOM.BackendNodeId
children: AriaSnapshotNode[] children: AriaSnapshotNode[]
} }
@@ -93,6 +98,7 @@ export interface ScreenshotResult {
export interface AriaSnapshotResult { export interface AriaSnapshotResult {
snapshot: string snapshot: string
tree: AriaSnapshotNode[] tree: AriaSnapshotNode[]
refs: AriaRef[]
refToElement: Map<string, { role: string; name: string }> refToElement: Map<string, { role: string; name: string }>
refToSelector: Map<string, string> refToSelector: Map<string, string>
/** /**
@@ -106,6 +112,27 @@ export interface AriaSnapshotResult {
getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null> getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null>
} }
type LabelBox = {
x: number
y: number
width: number
height: number
}
type AriaLabel = {
ref: string
role: string
box: LabelBox
}
export function buildShortRefMap({ refs }: { refs: AriaRef[] }): Map<string, string> {
const map = new Map<string, string>()
refs.forEach((entry, index) => {
map.set(entry.ref, `e${index + 1}`)
})
return map
}
// Roles that represent interactive elements // Roles that represent interactive elements
const INTERACTIVE_ROLES = new Set([ const INTERACTIVE_ROLES = new Set([
'button', 'button',
@@ -133,6 +160,8 @@ const LABEL_ROLES = new Set([
'labeltext', 'labeltext',
]) ])
const MAX_LABEL_POSITION_CONCURRENCY = 24
const CONTEXT_ROLES = new Set([ const CONTEXT_ROLES = new Set([
'navigation', 'navigation',
'main', 'main',
@@ -255,6 +284,8 @@ type SnapshotNode = {
role: string role: string
name: string name: string
baseLocator?: string baseLocator?: string
ref?: string
backendNodeId?: Protocol.DOM.BackendNodeId
children: SnapshotNode[] children: SnapshotNode[]
} }
@@ -333,6 +364,8 @@ function finalizeSnapshotOutput(lines: SnapshotLine[], nodes: SnapshotNode[]): {
role: item.role, role: item.role,
name: item.name, name: item.name,
locator, locator,
ref: item.ref,
backendNodeId: item.backendNodeId,
children, children,
} }
}) })
@@ -434,16 +467,17 @@ function isTextRole(role: string): boolean {
* await page.locator(selector).click() * await page.locator(selector).click()
* ``` * ```
*/ */
export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interactiveOnly = false }: { export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interactiveOnly = false, cdp }: {
page: Page page: Page
locator?: Locator locator?: Locator
refFilter?: (info: { role: string; name: string }) => boolean refFilter?: (info: { role: string; name: string }) => boolean
wsUrl?: string wsUrl?: string
interactiveOnly?: boolean interactiveOnly?: boolean
cdp?: ICDPSession
}): Promise<AriaSnapshotResult> { }): Promise<AriaSnapshotResult> {
const cdp = await getCDPSessionForPage({ page, wsUrl }) const session = cdp || await getCDPSessionForPage({ page, wsUrl })
await cdp.send('DOM.enable') await session.send('DOM.enable')
await cdp.send('Accessibility.enable') await session.send('Accessibility.enable')
const scopeAttr = 'data-pw-scope' const scopeAttr = 'data-pw-scope'
const scopeValue = crypto.randomUUID() const scopeValue = crypto.randomUUID()
let scopeApplied = false let scopeApplied = false
@@ -456,7 +490,7 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
scopeApplied = true scopeApplied = true
} }
const { nodes: domNodes } = await cdp.send('DOM.getFlattenedDocument', { depth: -1, pierce: true }) const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', { depth: -1, pierce: true }) as Protocol.DOM.GetFlattenedDocumentResponse
const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes) const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes)
let scopeRootNodeId: Protocol.DOM.NodeId | null = null let scopeRootNodeId: Protocol.DOM.NodeId | null = null
@@ -475,7 +509,7 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById) ? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById)
: null : null
const { nodes: axNodes } = await cdp.send('Accessibility.getFullAXTree') const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree') as Protocol.Accessibility.GetFullAXTreeResponse
const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>() const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>()
for (const node of axNodes) { for (const node of axNodes) {
@@ -513,7 +547,7 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
const refCounts = new Map<string, number>() const refCounts = new Map<string, number>()
let fallbackCounter = 0 let fallbackCounter = 0
const refs: Array<{ ref: string; role: string; name: string; selector?: string }> = [] const refs: Array<AriaRef & { selector?: string }> = []
const createRefForNode = (node: Protocol.Accessibility.AXNode, role: string, name: string): string | null => { const createRefForNode = (node: Protocol.Accessibility.AXNode, role: string, name: string): string | null => {
if (!INTERACTIVE_ROLES.has(role)) { if (!INTERACTIVE_ROLES.has(role)) {
@@ -537,7 +571,7 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
selector = buildLocatorFromStable(stable) selector = buildLocatorFromStable(stable)
} }
refs.push({ ref, role, name, selector }) refs.push({ ref, role, name, selector, backendNodeId: node.backendDOMNodeId })
return ref return ref
} }
@@ -648,11 +682,12 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
} }
let baseLocator: string | undefined let baseLocator: string | undefined
let ref: string | null = null
if (includeInteractive) { if (includeInteractive) {
const domInfo = node.backendDOMNodeId ? domByBackendId.get(node.backendDOMNodeId) : undefined const domInfo = node.backendDOMNodeId ? domByBackendId.get(node.backendDOMNodeId) : undefined
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
baseLocator = buildBaseLocator({ role, name, stable }) baseLocator = buildBaseLocator({ role, name, stable })
createRefForNode(node, role, name) ref = createRefForNode(node, role, name)
} }
const line = buildSnapshotLine({ const line = buildSnapshotLine({
@@ -666,6 +701,8 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
role, role,
name: nameToUse, name: nameToUse,
baseLocator, baseLocator,
ref: ref ?? undefined,
backendNodeId: node.backendDOMNodeId,
children: childNodes, children: childNodes,
} }
const names = new Set(childNames) const names = new Set(childNames)
@@ -799,6 +836,7 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
return { return {
snapshot, snapshot,
tree: result.tree, tree: result.tree,
refs: result.refs,
refToElement, refToElement,
refToSelector, refToSelector,
getSelectorForRef, getSelectorForRef,
@@ -812,7 +850,82 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
element.removeAttribute(attr) element.removeAttribute(attr)
}, scopeAttr) }, scopeAttr)
} }
await cdp.detach() if (!cdp) {
await session.detach()
}
}
}
function buildBoxFromQuad(quad?: number[]): LabelBox | null {
if (!quad || quad.length < 8) {
return null
}
const xs = [quad[0], quad[2], quad[4], quad[6]]
const ys = [quad[1], quad[3], quad[5], quad[7]]
const left = Math.min(...xs)
const right = Math.max(...xs)
const top = Math.min(...ys)
const bottom = Math.max(...ys)
return {
x: left,
y: top,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
}
}
function isTruthy<T>(value: T): value is NonNullable<T> {
return Boolean(value)
}
async function getLabelBoxesForRefs({
page,
refs,
wsUrl,
maxConcurrency = MAX_LABEL_POSITION_CONCURRENCY,
logger,
cdp,
}: {
page: Page
refs: AriaRef[]
wsUrl?: string
maxConcurrency?: number
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
cdp?: ICDPSession
}): Promise<AriaLabel[]> {
const session = cdp || await getCDPSessionForPage({ page, wsUrl })
const sema = new Sema(maxConcurrency)
const labelRefs = refs.filter((ref) => {
return Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role)
})
try {
const labels = await Promise.all(
labelRefs.map(async (ref) => {
if (!ref.backendNodeId) {
return null
}
await sema.acquire()
try {
const response = await session.send('DOM.getBoxModel', { backendNodeId: ref.backendNodeId }) as Protocol.DOM.GetBoxModelResponse
const box = buildBoxFromQuad(response.model.border)
if (!box) {
return null
}
return { ref: ref.ref, role: ref.role, box }
} catch (error) {
logger?.error?.('[playwriter] getBoxModel failed for', ref.ref, error)
return null
} finally {
sema.release()
}
})
)
return labels.filter(isTruthy)
} finally {
if (!cdp) {
await session.detach()
}
} }
} }
@@ -836,10 +949,11 @@ export async function getAriaSnapshot({ page, locator, refFilter, wsUrl, interac
* await page.locator('[data-testid="submit-btn"]').click() * await page.locator('[data-testid="submit-btn"]').click()
* ``` * ```
*/ */
export async function showAriaRefLabels({ page, locator, interactiveOnly = true, logger }: { export async function showAriaRefLabels({ page, locator, interactiveOnly = true, wsUrl, logger }: {
page: Page page: Page
locator?: Locator locator?: Locator
interactiveOnly?: boolean interactiveOnly?: boolean
wsUrl?: string
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void } logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
}): Promise<{ }): Promise<{
snapshot: string snapshot: string
@@ -853,36 +967,55 @@ export async function showAriaRefLabels({ page, locator, interactiveOnly = true,
log(`ensureA11yClient: ${Date.now() - startTime}ms`) log(`ensureA11yClient: ${Date.now() - startTime}ms`)
} }
// Determine root element const snapshotStart = Date.now()
const rootHandle = locator ? await locator.elementHandle() : null const cdp = await getCDPSessionForPage({ page, wsUrl })
const computeStart = Date.now() try {
const result = await page.evaluate( const { snapshot, refs } = await getAriaSnapshot({ page, locator, interactiveOnly, wsUrl, cdp })
({ root, interactiveOnly: intOnly }) => { const shortRefMap = buildShortRefMap({ refs })
const a11y = (globalThis as any).__a11y if (log) {
if (!a11y) { log(`getAriaSnapshot: ${Date.now() - snapshotStart}ms`)
throw new Error('a11y client not loaded')
}
const rootElement = root || document.body
return a11y.computeA11ySnapshot({
root: rootElement,
interactiveOnly: intOnly,
renderLabels: true,
})
},
{
root: rootHandle,
interactiveOnly,
} }
)
if (log) { const rootHandle = locator ? await locator.elementHandle() : null
log(`computeA11ySnapshot: ${Date.now() - computeStart}ms (${result.labelCount} labels)`)
}
return { const labelsStart = Date.now()
snapshot: result.snapshot, const labels = await getLabelBoxesForRefs({ page, refs, wsUrl, logger, cdp })
labelCount: result.labelCount, const shortLabels = labels.map((label) => {
return {
...label,
ref: shortRefMap.get(label.ref) ?? label.ref,
}
})
if (log) {
log(`getLabelBoxesForRefs: ${Date.now() - labelsStart}ms (${labels.length} boxes)`)
}
const renderStart = Date.now()
const labelCount = await page.evaluate(({ entries, root, interactiveOnly: intOnly }) => {
const a11y = (globalThis as {
__a11y?: {
renderA11yLabels?: (labels: typeof entries) => number
computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => { labelCount: number }
}
}).__a11y
if (a11y?.renderA11yLabels) {
return a11y.renderA11yLabels(entries)
}
if (a11y?.computeA11ySnapshot) {
const rootElement = root || document.body
return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true }).labelCount
}
throw new Error('a11y client not loaded')
}, { entries: shortLabels, root: rootHandle, interactiveOnly })
if (log) {
log(`renderA11yLabels: ${Date.now() - renderStart}ms (${labelCount} labels)`)
}
return { snapshot, labelCount }
} finally {
await cdp.detach()
} }
} }
@@ -925,15 +1058,16 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void>
* await page.locator('[data-testid="submit-btn"]').click() * await page.locator('[data-testid="submit-btn"]').click()
* ``` * ```
*/ */
export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = true, collector, logger }: { export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = true, wsUrl, collector, logger }: {
page: Page page: Page
locator?: Locator locator?: Locator
interactiveOnly?: boolean interactiveOnly?: boolean
wsUrl?: string
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, locator, interactiveOnly, logger }) const { snapshot, labelCount } = await showAriaRefLabels({ page, locator, interactiveOnly, wsUrl, 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`)
+26 -2
View File
@@ -23,6 +23,7 @@ import { getReactSource, type ReactSourceLocation } from './react-source.js'
import { ScopedFS } from './scoped-fs.js' import { ScopedFS } from './scoped-fs.js'
import { import {
screenshotWithAccessibilityLabels, screenshotWithAccessibilityLabels,
buildShortRefMap,
getAriaSnapshot, getAriaSnapshot,
type ScreenshotResult, type ScreenshotResult,
type SnapshotFormat, type SnapshotFormat,
@@ -152,6 +153,7 @@ export class PlaywrightExecutor {
private userState: Record<string, any> = {} private userState: Record<string, any> = {}
private browserLogs: Map<string, string[]> = new Map() private browserLogs: Map<string, string[]> = new Map()
private lastSnapshots: WeakMap<Page, string> = new WeakMap() private lastSnapshots: WeakMap<Page, string> = new WeakMap()
private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
private cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap() private cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
private scopedFs: ScopedFS private scopedFs: ScopedFS
private sandboxedRequire: NodeRequire private sandboxedRequire: NodeRequire
@@ -451,7 +453,7 @@ export class PlaywrightExecutor {
const { page: targetPage, locator, search, showDiffSinceLastCall = false, interactiveOnly = true } = options const { page: targetPage, locator, search, showDiffSinceLastCall = false, interactiveOnly = true } = options
// Use new in-page implementation via getAriaSnapshot // Use new in-page implementation via getAriaSnapshot
const { snapshot: rawSnapshot } = await getAriaSnapshot({ const { snapshot: rawSnapshot, refs, getSelectorForRef } = await getAriaSnapshot({
page: targetPage, page: targetPage,
locator, locator,
wsUrl: getCdpUrl(this.cdpConfig), wsUrl: getCdpUrl(this.cdpConfig),
@@ -459,6 +461,17 @@ export class PlaywrightExecutor {
}) })
const snapshotStr = rawSnapshot.toWellFormed?.() ?? rawSnapshot const snapshotStr = rawSnapshot.toWellFormed?.() ?? rawSnapshot
const refToLocator = new Map<string, string>()
const shortRefMap = buildShortRefMap({ refs })
for (const entry of refs) {
const locatorStr = getSelectorForRef(entry.ref)
if (locatorStr) {
const shortRef = shortRefMap.get(entry.ref) ?? entry.ref
refToLocator.set(shortRef, locatorStr)
}
}
this.lastRefToLocator.set(targetPage, refToLocator)
if (showDiffSinceLastCall) { if (showDiffSinceLastCall) {
const previousSnapshot = this.lastSnapshots.get(targetPage) const previousSnapshot = this.lastSnapshots.get(targetPage)
if (!previousSnapshot) { if (!previousSnapshot) {
@@ -477,7 +490,7 @@ export class PlaywrightExecutor {
this.lastSnapshots.set(targetPage, snapshotStr) this.lastSnapshots.set(targetPage, snapshotStr)
if (!search) { if (!search) {
return snapshotStr return `${snapshotStr}\n\nuse refToLocator({ ref: 'e3' }) to get locators for ref strings.`
} }
const lines = snapshotStr.split('\n') const lines = snapshotStr.split('\n')
@@ -517,6 +530,15 @@ export class PlaywrightExecutor {
return result.join('\n') return result.join('\n')
} }
const refToLocator = (options: { ref: string; page?: Page }): string | null => {
const targetPage = options.page || page
const map = this.lastRefToLocator.get(targetPage)
if (!map) {
return null
}
return map.get(options.ref) ?? null
}
const getLocatorStringForElement = async (element: any) => { const getLocatorStringForElement = async (element: any) => {
if (!element || typeof element.evaluate !== 'function') { if (!element || typeof element.evaluate !== 'function') {
throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle') throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle')
@@ -622,6 +644,7 @@ export class PlaywrightExecutor {
const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => { const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => {
return screenshotWithAccessibilityLabels({ return screenshotWithAccessibilityLabels({
...options, ...options,
wsUrl: getCdpUrl(this.cdpConfig),
collector: screenshotCollector, collector: screenshotCollector,
logger: { logger: {
info: (...args) => { info: (...args) => {
@@ -658,6 +681,7 @@ export class PlaywrightExecutor {
state: this.userState, state: this.userState,
console: customConsole, console: customConsole,
accessibilitySnapshot, accessibilitySnapshot,
refToLocator,
getCleanHTML, getCleanHTML,
getPageMarkdown, getPageMarkdown,
getLocatorStringForElement, getLocatorStringForElement,
+9 -3
View File
@@ -1,9 +1,15 @@
import { expect, test } from 'vitest' import { expect, test } from 'vitest'
import { readFileSync } from 'fs' import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { formatHtmlForPrompt } from './htmlrewrite.js' import { formatHtmlForPrompt } from './htmlrewrite.js'
test('formatHtmlForPrompt', async () => { test('formatHtmlForPrompt', async () => {
const html = readFileSync(new URL('./assets/framer.html', import.meta.url), 'utf-8') const assetUrl = new URL('./assets/framer.html', import.meta.url)
const assetPath = fileURLToPath(assetUrl)
if (!fs.existsSync(assetPath)) {
return
}
const html = fs.readFileSync(assetPath, 'utf-8')
const newHtml = await formatHtmlForPrompt({ html }) const newHtml = await formatHtmlForPrompt({ html })
expect(newHtml).toMatchInlineSnapshot( expect(newHtml).toMatchInlineSnapshot(
` `
@@ -7951,7 +7957,7 @@ test('keeps all common e2e test ID attributes', async () => {
}) })
test('processes x.com.html with size savings', async () => { test('processes x.com.html with size savings', async () => {
const html = readFileSync(new URL('./assets/x.com.html', import.meta.url), 'utf-8') const html = fs.readFileSync(new URL('./assets/x.com.html', import.meta.url), 'utf-8')
const result = await formatHtmlForPrompt({ html }) const result = await formatHtmlForPrompt({ html })
const resultWithStyles = await formatHtmlForPrompt({ html, keepStyles: true }) const resultWithStyles = await formatHtmlForPrompt({ html, keepStyles: true })
+7 -7
View File
@@ -94,7 +94,7 @@ describe('Relay Core Tests', () => {
[ [
{ {
"text": "Console output: "text": "Console output:
[log] 'Page title:' 'Example Domain' [log] Page title: Example Domain
[return value] { url: 'https://example.com/', title: 'Example Domain' }", [return value] { url: 'https://example.com/', title: 'Example Domain' }",
"type": "text", "type": "text",
@@ -683,12 +683,12 @@ describe('Relay Core Tests', () => {
// Inline snapshot of cleaned HTML // Inline snapshot of cleaned HTML
expect(text).toMatchInlineSnapshot(` expect(text).toMatchInlineSnapshot(`
"[return value] '<div data-testid="main">\\n' + "[return value] <div data-testid="main">
' <h1>Hello World</h1>\\n' + <h1>Hello World</h1>
' <button aria-label="Click me">Submit</button>\\n' + <button aria-label="Click me">Submit</button>
' <a href="/about" title="About page">About</a>\\n' + <a href="/about" title="About page">About</a>
' <input type="text" placeholder="Enter name">\\n' + <input type="text" placeholder="Enter name">
'</div>\\n'" </div>"
`) `)
// Should NOT contain script/style tags (they're removed) // Should NOT contain script/style tags (they're removed)
+9 -1
View File
@@ -232,7 +232,7 @@ After any action (click, submit, navigate), verify what happened:
console.log('url:', page.url()); console.log(await accessibilitySnapshot({ page }).then(x => x.split('\n').slice(0, 30).join('\n'))); console.log('url:', page.url()); console.log(await accessibilitySnapshot({ page }).then(x => x.split('\n').slice(0, 30).join('\n')));
``` ```
For visually complex pages (grids, galleries, dashboards), use `screenshotWithAccessibilityLabels({ page })` instead to understand spatial layout. For visually complex pages (grids, galleries, dashboards), use `screenshotWithAccessibilityLabels({ page })` instead to understand spatial layout. Label refs are short `eN` strings (e.g. `e3`).
If nothing changed, try `await waitForPageLoad({ page, timeout: 3000 })` or you may have clicked the wrong element. If nothing changed, try `await waitForPageLoad({ page, timeout: 3000 })` or you may have clicked the wrong element.
@@ -265,6 +265,14 @@ Each interactive line ends with a Playwright locator you can pass to `page.locat
If multiple elements share the same locator, a `>> nth=N` suffix is added (0-based) If multiple elements share the same locator, a `>> nth=N` suffix is added (0-based)
to make it unique. to make it unique.
If a screenshot shows ref labels like `e3`, resolve them using the last snapshot:
```js
const snapshot = await accessibilitySnapshot({ page })
const locator = refToLocator({ ref: 'e3' })
await page.locator(locator!).click()
```
```js ```js
await page.locator('[id="nav-home"]').click() await page.locator('[id="nav-home"]').click()
await page.locator('[data-testid="docs-link"]').click() await page.locator('[data-testid="docs-link"]').click()
+5 -6
View File
@@ -761,6 +761,8 @@ describe('Snapshot & Screenshot Tests', () => {
} }
} }
const wsUrl = getCdpUrl({ port: TEST_PORT })
for (const { name, url, page } of pages) { for (const { name, url, page } of pages) {
console.log(`[labels] start ${name}`) console.log(`[labels] start ${name}`)
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes(new URL(url).hostname)) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes(new URL(url).hostname))
@@ -770,12 +772,12 @@ describe('Snapshot & Screenshot Tests', () => {
} }
console.log(`[labels] show labels ${name}`) console.log(`[labels] show labels ${name}`)
const { snapshot, labelCount } = await withTimeout( const { labelCount } = await withTimeout(
`showAriaRefLabels(${name})`, `showAriaRefLabels(${name})`,
async () => { async () => {
return await showAriaRefLabels({ page: cdpPage }) return await showAriaRefLabels({ page: cdpPage, wsUrl })
}, },
30000 60000
) )
console.log(`${name}: ${labelCount} labels shown`) console.log(`${name}: ${labelCount} labels shown`)
if (name !== 'google') { if (name !== 'google') {
@@ -794,9 +796,6 @@ describe('Snapshot & Screenshot Tests', () => {
fs.writeFileSync(screenshotPath, screenshot) fs.writeFileSync(screenshotPath, screenshot)
console.log(`Screenshot saved: ${screenshotPath}`) console.log(`Screenshot saved: ${screenshotPath}`)
const snapshotPath = path.join(assetsDir, `aria-labels-${name}-snapshot.txt`)
fs.writeFileSync(snapshotPath, snapshot)
console.log(`[labels] count dom labels ${name}`) console.log(`[labels] count dom labels ${name}`)
const labelElements = await withTimeout( const labelElements = await withTimeout(
`countLabels(${name})`, `countLabels(${name})`,
@@ -1,16 +1,16 @@
Console output: Console output:
[log] '# Test Article Title\n' + [log] # Test Article Title
'\n' +
'Home About\n' + Home About
'Test Article Title\n' + Test Article Title
'\n' +
'This is the first paragraph of the article content.\n' + This is the first paragraph of the article content.
'\n' +
'This is the second paragraph with more details about the topic.\n' + This is the second paragraph with more details about the topic.
'\n' +
'The article continues with important information here.\n' + The article continues with important information here.
'\n' +
'Related Posts\n' + Related Posts
'Post 1\n' + Post 1
'Post 2\n' + Post 2
'Copyright 2024' Copyright 2024
+13 -2
View File
@@ -86,6 +86,9 @@ importers:
'@xmorse/cac': '@xmorse/cac':
specifier: ^6.0.7 specifier: ^6.0.7
version: 6.0.7 version: 6.0.7
async-sema:
specifier: ^3.1.1
version: 3.1.1
diff: diff:
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
@@ -7673,6 +7676,14 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6) vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)
'@vitest/mocker@4.0.8(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6))':
dependencies:
'@vitest/spy': 4.0.8
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)
'@vitest/mocker@4.0.8(vite@7.2.2(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6))': '@vitest/mocker@4.0.8(vite@7.2.2(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6))':
dependencies: dependencies:
'@vitest/spy': 4.0.8 '@vitest/spy': 4.0.8
@@ -7724,7 +7735,7 @@ snapshots:
sirv: 3.0.2 sirv: 3.0.2
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.0.3 tinyrainbow: 3.0.3
vitest: 4.0.8(@types/node@25.2.0)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6) vitest: 4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6)
'@vitest/utils@4.0.16': '@vitest/utils@4.0.16':
dependencies: dependencies:
@@ -10577,7 +10588,7 @@ snapshots:
vitest@4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6): vitest@4.0.8(@types/node@24.10.1)(@vitest/ui@4.0.8)(jiti@2.6.1)(jsdom@27.2.0(bufferutil@4.0.9))(lightningcss@1.30.2)(tsx@4.20.6):
dependencies: dependencies:
'@vitest/expect': 4.0.8 '@vitest/expect': 4.0.8
'@vitest/mocker': 4.0.8(vite@7.2.2(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)) '@vitest/mocker': 4.0.8(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6))
'@vitest/pretty-format': 4.0.8 '@vitest/pretty-format': 4.0.8
'@vitest/runner': 4.0.8 '@vitest/runner': 4.0.8
'@vitest/snapshot': 4.0.8 '@vitest/snapshot': 4.0.8