playwright-mcp
Version:
Playwright integration for ModelContext
329 lines (281 loc) • 9.73 kB
text/typescript
import { logger } from '../logger'
const ATTR_PRIORITIES: Record<string, number> = {
id: 1,
'data-testid': 2,
'data-test-id': 2,
'data-pw': 2,
'data-cy': 2,
'data-id': 2,
'data-name': 3,
name: 3,
'aria-label': 3,
title: 3,
placeholder: 4,
href: 4,
alt: 4,
'data-index': 5,
'data-role': 5,
role: 5,
}
const IMPORTANT_ATTRS = Object.keys(ATTR_PRIORITIES)
const _escapeSpecialCharacters = (str: string): string => {
// Only escape double quotes for CSS selectors
return str.replace(/"/g, '\\"')
}
const getNodeSimpleSelectors = (element: Element): string[] => {
const selectors: string[] = []
const tag = element.tagName.toLowerCase()
const attrSelectors = IMPORTANT_ATTRS.map((attr) => {
const value = element.getAttribute(attr)
if (!value) return null
return {
priority: ATTR_PRIORITIES[attr] || 999,
selector:
attr === 'id'
? `#${_escapeSpecialCharacters(value)}`
: `${tag}[${attr}="${_escapeSpecialCharacters(value)}"]`,
}
}).filter((item) => item !== null)
const otherSelectors = []
// Locate by class
const classList = element.classList
if (classList.length > 0) {
otherSelectors.push({
priority: 100,
selector: `${tag}.${Array.from(classList).join('.')}`,
})
}
const availableSelectors = [...attrSelectors, ...otherSelectors]
availableSelectors.sort((a, b) => a!.priority - b!.priority)
// Take top 5 selectors based on priority
const topSelectors = availableSelectors.slice(0, 5)
topSelectors.push({
priority: 999,
selector: tag,
})
// Add selectors in priority order
for (const item of topSelectors) {
selectors.push(item!.selector)
}
return selectors
}
const _getSiblingRelationshipSelectors = (dom: Document, element: Element): string[] => {
const selectors: string[] = []
const parent = element.parentElement
if (!parent || parent.tagName === 'BODY') {
return selectors
}
const siblings = Array.from(parent.children)
const elementIndex = siblings.indexOf(element)
const tagName = element.tagName.toLowerCase()
const selectorPrefixes: string[] = []
for (let i = 0; i < siblings.length; i++) {
if (i === elementIndex) continue
const sibling = siblings[i]
const siblingSimpleSelectors = getNodeSimpleSelectors(sibling)
siblingSimpleSelectors.forEach((siblingSelector) => {
selectorPrefixes.push(`${siblingSelector} ~ `)
})
}
const selectorSuffixes = [tagName, ...getNodeSimpleSelectors(element)]
selectorSuffixes.forEach((selectorSuffix) => {
selectorPrefixes.forEach((selectorPrefix) => {
selectors.push(`${selectorPrefix}${selectorSuffix}`)
})
})
return selectors
}
const _getChildRelationshipSelectors = (dom: Document, element: Element) => {
// BFS to get all children and their depth upto level 3
const children = []
const currentQueue = Array.from(element.children).map((child) => ({
child,
depth: 0,
}))
while (currentQueue.length > 0) {
const item = currentQueue.shift()
if (!item) continue
const { child, depth } = item
if (depth > 3) {
continue
}
children.push({ child, depth })
currentQueue.push(
...Array.from(child.children).map((child) => ({
child,
depth: depth + 1,
})),
)
}
const selectorSuffixes: string[] = []
children.forEach(({ child, depth }) => {
const childSelectors = getNodeSimpleSelectors(child)
const childIndex = Array.from(element.children).indexOf(child) + 1
childSelectors.forEach((childSelector) => {
if (depth === 0) {
// For now, disable `>` immediate child selector, since that doesn't work properly.
// In happy-dom, it's not supported - https://github.com/capricorn86/happy-dom/issues/1642
// In jsdom, it's giving DOM exception
// Example - Failed to validate selector
// div:has(> [data-testid="adult_count"])
// DOMException {}
// message = 'div.`makeFlex >[data-testid="adult_count"]' is not a valid selector
// code = 12
// Selector for parent element, using :has() to indicate parent contains this specific child
selectorSuffixes.push(`:has(${childSelector})`)
// Also add nth-child variant for more specificity if needed
selectorSuffixes.push(`:has(${childSelector}:nth-child(${childIndex}))`)
} else {
// Depth != 0, means it's a descendant, not direct child.
selectorSuffixes.push(`:has(${childSelector})`)
}
})
})
const selectorPrefixes = [
element.tagName.toLowerCase(),
...getNodeSimpleSelectors(element),
]
const selectors: string[] = []
selectorPrefixes.forEach((selectorPrefix) => {
selectorSuffixes.forEach((selectorSuffix) => {
selectors.push(`${selectorPrefix}${selectorSuffix}`)
})
})
return selectors
}
const getMatchCount = (dom: Document, selector: string): number => {
try {
return dom.querySelectorAll(selector).length
} catch {
return Number.POSITIVE_INFINITY // Invalid selector
}
}
const _getParentPathSelectors = (dom: Document, element: Element): string[] => {
// Build path from target to root
const path: Element[] = []
let current: Element | null = element
while (current && current.tagName !== 'HTML') {
path.push(current)
current = current.parentElement
}
logger.debug(
'Path',
path.map((node) => node.tagName),
)
// Pre-compute selectors for each node
const nodeSelectors: {
node: Element
selectors: string[]
}[] = path.map((node) => ({
node,
selectors: getNodeSimpleSelectors(node),
}))
if (!nodeSelectors.length) {
return []
}
const result: string[] = []
const targetNode = nodeSelectors[0].node
const targetSelectors = nodeSelectors[0].selectors
const targetSelectorsWithNthChild = targetSelectors.map((selector) => {
const index =
targetNode.parentElement
? Array.from(targetNode.parentElement.children).indexOf(targetNode) + 1
: 1
return `${selector}:nth-child(${index})`
})
const allTargetSelectors = [
...targetSelectors,
...targetSelectorsWithNthChild,
]
logger.debug('Target Selectors', allTargetSelectors)
for (const targetSelector of allTargetSelectors) {
const matches = getMatchCount(dom, targetSelector)
// Skip invalid selectors
if (matches === 0) continue
// If unique, add to results
if (matches === 1) {
result.push(targetSelector)
}
// Try combinations with ancestors
let currentSelector = targetSelector
let currentMatches = matches
let lastAddedNode = targetNode
for (let i = 1; i < nodeSelectors.length; i++) {
const ancestor = nodeSelectors[i].node
const ancestorSelectors = nodeSelectors[i].selectors
let bestSelector: string | null = null
let bestMatches = currentMatches
for (const ancestorSelector of ancestorSelectors) {
const descendantOperator =
Array.from(ancestor.children).indexOf(lastAddedNode) !== -1
? ' > '
: ' '
const possibleCombinedSelectors = [
`${ancestorSelector} ${descendantOperator} ${currentSelector}`,
]
if (ancestor.tagName != 'BODY' && ancestor.parentElement) {
const elementIndex =
Array.from(ancestor.parentElement.children).indexOf(ancestor) + 1
possibleCombinedSelectors.push(
`${ancestorSelector}:nth-child(${elementIndex}) ${descendantOperator} ${currentSelector}`,
)
}
logger.debug('Possible Combined Selectors', possibleCombinedSelectors)
for (const combinedSelector of possibleCombinedSelectors) {
const newMatches = getMatchCount(dom, combinedSelector)
// Skip invalid combinations
if (newMatches === 0) continue
else if (newMatches === 1) {
// If unique, add to results immediately
result.push(combinedSelector)
bestSelector = null // Skip updating current selector
} else if (newMatches < bestMatches) {
// Update best if it reduces matches
bestSelector = combinedSelector
bestMatches = newMatches
}
}
}
// Update current if we found a better (but not unique) selector
if (bestSelector && bestMatches < currentMatches) {
currentSelector = bestSelector
currentMatches = bestMatches
lastAddedNode = ancestor
}
}
}
return result
}
const validateSelector = (document: Document, element: Element, selector: string) => {
try {
const selectedElements = document.querySelectorAll(selector)
return selectedElements.length === 1 && selectedElements[0] === element
} catch (e) {
return false
}
}
const getSelectors = (document: Document, elementUUID: string): string[] => {
const element = document.querySelector(`[uuid="${elementUUID}"]`)
if (!element) {
throw new Error(`Element with UUID ${elementUUID} not found`)
}
const validSelectors: string[] = []
const selectorGenerators = [
() => _getParentPathSelectors(document, element),
() => _getChildRelationshipSelectors(document, element),
() => _getSiblingRelationshipSelectors(document, element)
]
for (const generator of selectorGenerators) {
const selectors = generator()
for (const selector of selectors) {
if (validateSelector(document, element, selector)) {
validSelectors.push(selector)
if (validSelectors.length >= 10) {
return validSelectors
}
}
}
}
return validSelectors
}
export { getSelectors }