Skip to content
Merged
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
143 changes: 123 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 @@ -4078,6 +4096,12 @@ function buildLocatorString(locator) {
if (locator.isXPath()) {
return `xpath=${locator.value}`
}
if (locator.isShadow()) {
// Convert shadow locator to CSS with >> chaining operator
// Playwright pierces shadow DOM by default, >> chains selectors
// { shadow: ['my-app', 'my-form', 'button'] } => 'my-app >> my-form >> button'
return locator.value.join(' >> ')
}
return locator.simplify()
}

Expand All @@ -4102,6 +4126,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 +4216,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 +4365,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 +4526,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
Loading