Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 117 additions & 20 deletions lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '../utils.js'
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
import ElementNotFound from './errors/ElementNotFound.js'
import MultipleElementsFound from './errors/MultipleElementsFound.js'
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
import Popup from './extras/Popup.js'
import Console from './extras/Console.js'
Expand Down Expand Up @@ -392,6 +393,7 @@ class Playwright extends Helper {
highlightElement: false,
storageState: undefined,
onResponse: null,
strict: false,
}

process.env.testIdAttribute = 'data-testid'
Expand Down Expand Up @@ -1753,7 +1755,12 @@ class Playwright extends Helper {
*/
async _locateElement(locator) {
const context = await this._getContext()
return findElement(context, locator)
const elements = await findElements.call(this, context, locator)
if (elements.length === 0) {
throw new ElementNotFound(locator, 'Element', 'was not found')
}
if (this.options.strict) assertOnlyOneElement(elements, locator)
return elements[0]
}

/**
Expand All @@ -1768,6 +1775,7 @@ class Playwright extends Helper {
const context = providedContext || (await this._getContext())
const els = await findCheckable.call(this, locator, context)
assertElementExists(els[0], locator, 'Checkbox or radio')
if (this.options.strict) assertOnlyOneElement(els, locator)
return els[0]
}

Expand Down Expand Up @@ -2240,6 +2248,7 @@ class Playwright extends Helper {
async fillField(field, value) {
const els = await findFields.call(this, field)
assertElementExists(els, field, 'Field')
if (this.options.strict) assertOnlyOneElement(els, field)
const el = els[0]

await el.clear()
Expand Down Expand Up @@ -2272,6 +2281,7 @@ class Playwright extends Helper {
async clearField(locator, options = {}) {
const els = await findFields.call(this, locator)
assertElementExists(els, locator, 'Field to clear')
if (this.options.strict) assertOnlyOneElement(els, locator)

const el = els[0]

Expand All @@ -2288,6 +2298,7 @@ class Playwright extends Helper {
async appendField(field, value) {
const els = await findFields.call(this, field)
assertElementExists(els, field, 'Field')
if (this.options.strict) assertOnlyOneElement(els, field)
await highlightActiveElement.call(this, els[0])
await els[0].press('End')
await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
Expand Down Expand Up @@ -2330,23 +2341,30 @@ class Playwright extends Helper {
* {{> selectOption }}
*/
async selectOption(select, option) {
const els = await findFields.call(this, select)
assertElementExists(els, select, 'Selectable field')
const el = els[0]

await highlightActiveElement.call(this, el)
let optionToSelect = ''
const context = await this.context
const matchedLocator = new Locator(select)

try {
optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
} catch (e) {
optionToSelect = option
// Strict locator
if (!matchedLocator.isFuzzy()) {
this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
const els = await this._locate(matchedLocator)
assertElementExists(els, select, 'Selectable element')
return proceedSelect.call(this, context, els[0], option)
}

if (!Array.isArray(option)) option = [optionToSelect]
// Fuzzy: try combobox
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
let els = await findByRole(context, { role: 'combobox', name: matchedLocator.value })
if (els?.length) return proceedSelect.call(this, context, els[0], option)

await el.selectOption(option)
return this._waitForAction()
// Fuzzy: try listbox
els = await findByRole(context, { role: 'listbox', name: matchedLocator.value })
if (els?.length) return proceedSelect.call(this, context, els[0], option)

// Fuzzy: try native select
els = await findFields.call(this, select)
assertElementExists(els, select, 'Selectable element')
return proceedSelect.call(this, context, els[0], option)
}

/**
Expand Down Expand Up @@ -4102,6 +4120,14 @@ async function handleRoleLocator(context, locator) {
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
}

async function findByRole(context, locator) {
if (!locator || !locator.role) return null
const options = {}
if (locator.name) options.name = locator.name
if (locator.exact !== undefined) options.exact = locator.exact
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
}

async function findElements(matcher, locator) {
// Check if locator is a Locator object with react/vue type, or a raw object with react/vue property
const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react
Expand Down Expand Up @@ -4184,34 +4210,53 @@ async function proceedClick(locator, context = null, options = {}) {
async function findClickable(matcher, locator) {
const matchedLocator = new Locator(locator)

if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
if (!matchedLocator.isFuzzy()) {
const els = await findElements.call(this, matcher, matchedLocator)
if (this.options.strict) assertOnlyOneElement(els, locator)
return els
}

let els
const literal = xpathLocator.literal(matchedLocator.value)

try {
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
if (els.length) return els
if (els.length) {
if (this.options.strict) assertOnlyOneElement(els, locator)
return els
}
} catch (err) {
// getByRole not supported or failed
}

try {
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
if (els.length) return els
if (els.length) {
if (this.options.strict) assertOnlyOneElement(els, locator)
return els
}
} catch (err) {
// getByRole not supported or failed
}

els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
if (els.length) return els
if (els.length) {
if (this.options.strict) assertOnlyOneElement(els, locator)
return els
}

els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
if (els.length) return els
if (els.length) {
if (this.options.strict) assertOnlyOneElement(els, locator)
return els
}

try {
els = await findElements.call(this, matcher, Locator.clickable.self(literal))
if (els.length) return els
if (els.length) {
if (this.options.strict) assertOnlyOneElement(els, locator)
return els
}
} catch (err) {
// Do nothing
}
Expand Down Expand Up @@ -4314,6 +4359,52 @@ async function findFields(locator) {
return this._locate({ css: locator })
}

async function proceedSelect(context, el, option) {
const role = await el.getAttribute('role')
const options = Array.isArray(option) ? option : [option]

if (role === 'combobox') {
this.debugSection('SelectOption', 'Expanding combobox')
await highlightActiveElement.call(this, el)
const [ariaOwns, ariaControls] = await Promise.all([el.getAttribute('aria-owns'), el.getAttribute('aria-controls')])
await el.click()
await this._waitForAction()

const listboxId = ariaOwns || ariaControls
let listbox = listboxId ? context.locator(`#${listboxId}`).first() : null
if (!listbox || !(await listbox.count())) listbox = context.getByRole('listbox').first()

for (const opt of options) {
const optEl = listbox.getByRole('option', { name: opt }).first()
this.debugSection('SelectOption', `Clicking: "${opt}"`)
await highlightActiveElement.call(this, optEl)
await optEl.click()
}
return this._waitForAction()
}

if (role === 'listbox') {
for (const opt of options) {
const optEl = el.getByRole('option', { name: opt }).first()
this.debugSection('SelectOption', `Clicking: "${opt}"`)
await highlightActiveElement.call(this, optEl)
await optEl.click()
}
return this._waitForAction()
}

await highlightActiveElement.call(this, el)
let optionToSelect = option
try {
optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
} catch (e) {
optionToSelect = option
}
if (!Array.isArray(option)) option = [optionToSelect]
await el.selectOption(option)
return this._waitForAction()
}

async function proceedSeeInField(assertType, field, value) {
const els = await findFields.call(this, field)
assertElementExists(els, field, 'Field')
Expand Down Expand Up @@ -4429,6 +4520,12 @@ function assertElementExists(res, locator, prefix, suffix) {
}
}

function assertOnlyOneElement(elements, locator) {
if (elements.length > 1) {
throw new MultipleElementsFound(locator, elements)
}
}

function $XPath(element, selector) {
const found = document.evaluate(selector, element || document.body, null, 5, null)
const res = []
Expand Down
135 changes: 135 additions & 0 deletions lib/helper/errors/MultipleElementsFound.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import Locator from '../../locator.js'

/**
* Error thrown when strict mode is enabled and multiple elements are found
* for a single-element locator operation (click, fillField, etc.)
*/
class MultipleElementsFound extends Error {
/**
* @param {Locator|string|object} locator - The locator used
* @param {Array<HTMLElement>} elements - Array of Playwright element handles found
*/
constructor(locator, elements) {
super(`Multiple elements (${elements.length}) found for "${locator}". Call fetchDetails() for full information.`)
this.name = 'MultipleElementsFound'
this.locator = locator
this.elements = elements
this.count = elements.length
this._detailsFetched = false
}

/**
* Fetch detailed information about the found elements asynchronously
* This updates the error message with XPath and element previews
*/
async fetchDetails() {
if (this._detailsFetched) return

try {
if (typeof this.locator === 'object' && !(this.locator instanceof Locator)) {
this.locator = JSON.stringify(this.locator)
}

const locatorObj = new Locator(this.locator)
const elementList = await this._generateElementList(this.elements, this.count)

this.message = `Multiple elements (${this.count}) found for "${locatorObj.toString()}" in strict mode.\n` +
elementList +
`\nUse a more specific locator or use grabWebElements() to handle multiple elements.`
} catch (err) {
this.message = `Multiple elements (${this.count}) found. Failed to fetch details: ${err.message}`
}

this._detailsFetched = true
}

/**
* Generate a formatted list of found elements with their XPath and preview
* @param {Array<HTMLElement>} elements
* @param {number} count
* @returns {Promise<string>}
*/
async _generateElementList(elements, count) {
const items = []
const maxToShow = Math.min(count, 10)

for (let i = 0; i < maxToShow; i++) {
const el = elements[i]
try {
const info = await this._getElementInfo(el)
items.push(` ${i + 1}. ${info.xpath} (${info.preview})`)
} catch (err) {
// Element might be detached or inaccessible
items.push(` ${i + 1}. [Unable to get element info: ${err.message}]`)
}
}

if (count > 10) {
items.push(` ... and ${count - 10} more`)
}

return items.join('\n')
}

/**
* Get XPath and preview for an element by running JavaScript in browser context
* @param {HTMLElement} element
* @returns {Promise<{xpath: string, preview: string}>}
*/
async _getElementInfo(element) {
return element.evaluate((el) => {
// Generate a unique XPath for this element
const getUniqueXPath = (element) => {
if (element.id) {
return `//*[@id="${element.id}"]`
}

const parts = []
let current = element

while (current && current.nodeType === Node.ELEMENT_NODE) {
let index = 0
let sibling = current.previousSibling

while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) {
index++
}
sibling = sibling.previousSibling
}

const tagName = current.tagName.toLowerCase()
const pathIndex = index > 0 ? `[${index + 1}]` : ''
parts.unshift(`${tagName}${pathIndex}`)

current = current.parentElement

// Stop at body to keep XPath reasonable
if (current && current.tagName === 'BODY') {
parts.unshift('body')
break
}
}

return '/' + parts.join('/')
}

// Get a preview of the element (tag, classes, id)
const getPreview = (element) => {
const tag = element.tagName.toLowerCase()
const id = element.id ? `#${element.id}` : ''
const classes = element.className
? '.' + element.className.split(' ').filter(c => c).join('.')
: ''
return `${tag}${id}${classes || ''}`
}

return {
xpath: getUniqueXPath(el),
preview: getPreview(el),
}
})
}
}

export default MultipleElementsFound
Loading
Loading