diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 6c43226fe..d3afcd24c 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -30,7 +30,7 @@ import ElementNotFound from './errors/ElementNotFound.js' import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js' import Popup from './extras/Popup.js' import Console from './extras/Console.js' -import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js' +import { buildLocatorString, findElements, findElement, getVisibleElements, findReact, findVue, findByPlaywrightLocator, findByRole } from './extras/PlaywrightLocator.js' let playwright let perfTiming @@ -2424,17 +2424,28 @@ class Playwright extends Helper { * */ async grabTextFrom(locator) { - locator = this._contextLocator(locator) + const originalLocator = locator + const matchedLocator = new Locator(locator) + + if (!matchedLocator.isFuzzy()) { + const els = await this._locate(matchedLocator) + assertElementExists(els, locator) + const text = await els[0].innerText() + this.debugSection('Text', text) + return text + } + + const contextAwareLocator = this._contextLocator(matchedLocator.value) let text try { - text = await this.page.textContent(locator) + text = await this.page.textContent(contextAwareLocator) } catch (err) { if (err.message.includes('Timeout') || err.message.includes('exceeded')) { - throw new Error(`Element ${new Locator(locator).toString()} was not found by text|CSS|XPath`) + throw new Error(`Element ${new Locator(originalLocator).toString()} was not found by text|CSS|XPath`) } throw err } - assertElementExists(text, locator) + assertElementExists(text, contextAwareLocator) this.debugSection('Text', text) return text } @@ -3821,47 +3832,6 @@ class Playwright extends Helper { export default Playwright -function buildLocatorString(locator) { - if (locator.isCustom()) { - return `${locator.type}=${locator.value}` - } - if (locator.isXPath()) { - return `xpath=${locator.value}` - } - return locator.simplify() -} - -async function findElements(matcher, locator) { - if (locator.react) return findReact(matcher, locator) - if (locator.vue) return findVue(matcher, locator) - if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) - locator = new Locator(locator, 'css') - - return matcher.locator(buildLocatorString(locator)).all() -} - -async function findElement(matcher, locator) { - if (locator.react) return findReact(matcher, locator) - if (locator.vue) return findVue(matcher, locator) - if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) - locator = new Locator(locator, 'css') - - return matcher.locator(buildLocatorString(locator)).first() -} - -async function getVisibleElements(elements) { - const visibleElements = [] - for (const element of elements) { - if (await element.isVisible()) { - visibleElements.push(element) - } - } - if (visibleElements.length === 0) { - return elements - } - return visibleElements -} - async function proceedClick(locator, context = null, options = {}) { let matcher = await this._getContext() if (context) { @@ -3898,15 +3868,26 @@ async function proceedClick(locator, context = null, options = {}) { } async function findClickable(matcher, locator) { - if (locator.react) return findReact(matcher, locator) - if (locator.vue) return findVue(matcher, locator) - if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) + const matchedLocator = new Locator(locator) - locator = new Locator(locator) - if (!locator.isFuzzy()) return findElements.call(this, matcher, locator) + if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator) let els - const literal = xpathLocator.literal(locator.value) + const literal = xpathLocator.literal(matchedLocator.value) + + try { + els = await matcher.getByRole('button', { name: matchedLocator.value }).all() + if (els.length) return els + } catch (err) { + // getByRole not supported or failed + } + + try { + els = await matcher.getByRole('link', { name: matchedLocator.value }).all() + if (els.length) return els + } catch (err) { + // getByRole not supported or failed + } els = await findElements.call(this, matcher, Locator.clickable.narrow(literal)) if (els.length) return els @@ -3921,7 +3902,7 @@ async function findClickable(matcher, locator) { // Do nothing } - return findElements.call(this, matcher, locator.value) // by css or xpath + return findElements.call(this, matcher, matchedLocator.value) // by css or xpath } async function proceedSee(assertType, text, context, strict = false) { @@ -3962,10 +3943,10 @@ async function findCheckable(locator, context) { const matchedLocator = new Locator(locator) if (!matchedLocator.isFuzzy()) { - return findElements.call(this, contextEl, matchedLocator.simplify()) + return findElements.call(this, contextEl, matchedLocator) } - const literal = xpathLocator.literal(locator) + const literal = xpathLocator.literal(matchedLocator.value) let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal)) if (els.length) { return els @@ -3974,7 +3955,7 @@ async function findCheckable(locator, context) { if (els.length) { return els } - return findElements.call(this, contextEl, locator) + return findElements.call(this, contextEl, matchedLocator.value) } async function proceedIsChecked(assertType, option) { diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 074f1a0d7..761b40c05 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -979,6 +979,12 @@ class Puppeteer extends Helper { return this._locate(locator) } + async grabWebElement(locator) { + const els = await this._locate(locator) + assertElementExists(els, locator) + return els[0] + } + /** * Switch focus to a particular tab by its number. It waits tabs loading and then switch tab * @@ -1305,8 +1311,16 @@ class Puppeteer extends Helper { */ async checkOption(field, context = null) { const elm = await this._locateCheckable(field, context) - const curentlyChecked = await elm.getProperty('checked').then(checkedProperty => checkedProperty.jsonValue()) - // Only check if NOT currently checked + let curentlyChecked = await elm + .getProperty('checked') + .then(checkedProperty => checkedProperty.jsonValue()) + .catch(() => null) + + if (!curentlyChecked) { + const ariaChecked = await elm.evaluate(el => el.getAttribute('aria-checked')) + curentlyChecked = ariaChecked === 'true' + } + if (!curentlyChecked) { await elm.click() return this._waitForAction() @@ -1318,8 +1332,16 @@ class Puppeteer extends Helper { */ async uncheckOption(field, context = null) { const elm = await this._locateCheckable(field, context) - const curentlyChecked = await elm.getProperty('checked').then(checkedProperty => checkedProperty.jsonValue()) - // Only uncheck if currently checked + let curentlyChecked = await elm + .getProperty('checked') + .then(checkedProperty => checkedProperty.jsonValue()) + .catch(() => null) + + if (!curentlyChecked) { + const ariaChecked = await elm.evaluate(el => el.getAttribute('aria-checked')) + curentlyChecked = ariaChecked === 'true' + } + if (curentlyChecked) { await elm.click() return this._waitForAction() @@ -2739,19 +2761,22 @@ class Puppeteer extends Helper { } async function findElements(matcher, locator) { - if (locator.react) return findReactElements.call(this, locator) - locator = new Locator(locator, 'css') - if (!locator.isXPath()) return matcher.$$(locator.simplify()) + const matchedLocator = new Locator(locator, 'css') + + if (matchedLocator.type === 'react') return findReactElements.call(this, matchedLocator) + if (matchedLocator.isRole()) return findByRole.call(this, matcher, matchedLocator) + + if (!matchedLocator.isXPath()) return matcher.$$(matchedLocator.simplify()) // Handle backward compatibility for different Puppeteer versions // Puppeteer >= 19.4.0 uses xpath/ syntax, older versions use $x try { // Try the new xpath syntax first (for Puppeteer >= 19.4.0) - return await matcher.$$(`xpath/${locator.value}`) + return await matcher.$$(`xpath/${matchedLocator.value}`) } catch (error) { // Fall back to the old $x method for older Puppeteer versions if (matcher.$x && typeof matcher.$x === 'function') { - return await matcher.$x(locator.value) + return await matcher.$x(matchedLocator.value) } // If both methods fail, re-throw the original error throw error @@ -2785,12 +2810,12 @@ async function proceedClick(locator, context = null, options = {}) { } async function findClickable(matcher, locator) { - if (locator.react) return findReactElements.call(this, locator) - locator = new Locator(locator) - if (!locator.isFuzzy()) return findElements.call(this, matcher, locator) + const matchedLocator = new Locator(locator) + + if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator) let els - const literal = xpathLocator.literal(locator.value) + const literal = xpathLocator.literal(matchedLocator.value) els = await findElements.call(this, matcher, Locator.clickable.narrow(literal)) if (els.length) return els @@ -2805,7 +2830,15 @@ async function findClickable(matcher, locator) { // Do nothing } - return findElements.call(this, matcher, locator.value) // by css or xpath + // Try ARIA selector for accessible name + try { + els = await matcher.$$(`::-p-aria(${matchedLocator.value})`) + if (els.length) return els + } catch (err) { + // ARIA selector not supported or failed + } + + return findElements.call(this, matcher, matchedLocator.value) // by css or xpath } async function proceedSee(assertType, text, context, strict = false) { @@ -2849,10 +2882,10 @@ async function findCheckable(locator, context) { const matchedLocator = new Locator(locator) if (!matchedLocator.isFuzzy()) { - return findElements.call(this, contextEl, matchedLocator.simplify()) + return findElements.call(this, contextEl, matchedLocator) } - const literal = xpathLocator.literal(locator) + const literal = xpathLocator.literal(matchedLocator.value) let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal)) if (els.length) { return els @@ -2861,15 +2894,39 @@ async function findCheckable(locator, context) { if (els.length) { return els } - return findElements.call(this, contextEl, locator) + + // Try ARIA selector for accessible name + try { + els = await contextEl.$$(`::-p-aria(${matchedLocator.value})`) + if (els.length) return els + } catch (err) { + // ARIA selector not supported or failed + } + + return findElements.call(this, contextEl, matchedLocator.value) } async function proceedIsChecked(assertType, option) { let els = await findCheckable.call(this, option) assertElementExists(els, option, 'Checkable') - els = await Promise.all(els.map(el => el.getProperty('checked'))) - els = await Promise.all(els.map(el => el.jsonValue())) - const selected = els.reduce((prev, cur) => prev || cur) + + const checkedStates = await Promise.all( + els.map(async el => { + const checked = await el + .getProperty('checked') + .then(p => p.jsonValue()) + .catch(() => null) + + if (checked) { + return checked + } + + const ariaChecked = await el.evaluate(el => el.getAttribute('aria-checked')) + return ariaChecked === 'true' + }), + ) + + const selected = checkedStates.reduce((prev, cur) => prev || cur) return truth(`checkable ${option}`, 'to be checked')[assertType](selected) } @@ -2884,7 +2941,7 @@ async function findFields(locator) { if (!matchedLocator.isFuzzy()) { return this._locate(matchedLocator) } - const literal = xpathLocator.literal(locator) + const literal = xpathLocator.literal(matchedLocator.value) let els = await this._locate({ xpath: Locator.field.labelEquals(literal) }) if (els.length) { @@ -2899,7 +2956,17 @@ async function findFields(locator) { if (els.length) { return els } - return this._locate({ css: locator }) + + // Try ARIA selector for accessible name + try { + const page = await this.context + els = await page.$$(`::-p-aria(${matchedLocator.value})`) + if (els.length) return els + } catch (err) { + // ARIA selector not supported or failed + } + + return this._locate({ css: matchedLocator.value }) } async function proceedDragAndDrop(sourceLocator, destinationLocator) { @@ -2973,19 +3040,30 @@ async function proceedSeeInField(assertType, field, value) { } return proceedMultiple(els[0]) } - const fieldVal = await el.getProperty('value').then(el => el.jsonValue()) + + let fieldVal = await el.getProperty('value').then(el => el.jsonValue()) + + if (fieldVal === undefined || fieldVal === null) { + fieldVal = await el.evaluate(el => el.textContent || el.innerText) + } + return stringIncludes(`fields by ${field}`)[assertType](value, fieldVal) } async function filterFieldsByValue(elements, value, onlySelected) { const matches = [] for (const element of elements) { - const val = await element.getProperty('value').then(el => el.jsonValue()) + let val = await element.getProperty('value').then(el => el.jsonValue()) + + if (val === undefined || val === null) { + val = await element.evaluate(el => el.textContent || el.innerText) + } + let isSelected = true if (onlySelected) { isSelected = await elementSelected(element) } - if ((value == null || val.indexOf(value) > -1) && isSelected) { + if ((value == null || (val && val.indexOf(value) > -1)) && isSelected) { matches.push(element) } } @@ -3147,7 +3225,9 @@ function _waitForElement(locator, options) { } } -async function findReactElements(locator, props = {}, state = {}) { +async function findReactElements(locator) { + const resolved = toLocatorConfig(locator, 'react') + // Use createRequire to access require.resolve in ESM const { createRequire } = await import('module') const require = createRequire(import.meta.url) @@ -3193,9 +3273,9 @@ async function findReactElements(locator, props = {}, state = {}) { return [...nodes] }, { - selector: locator.react, - props: locator.props || {}, - state: locator.state || {}, + selector: resolved.react, + props: resolved.props || {}, + state: resolved.state || {}, }, ) @@ -3212,4 +3292,53 @@ async function findReactElements(locator, props = {}, state = {}) { return result } +async function findByRole(matcher, locator) { + const resolved = toLocatorConfig(locator, 'role') + const roleSelector = buildRoleSelector(resolved) + + if (!resolved.text && !resolved.name) { + return matcher.$$(roleSelector) + } + + const allElements = await matcher.$$(roleSelector) + const filtered = [] + const accessibleName = resolved.text ?? resolved.name + const matcherFn = createRoleTextMatcher(accessibleName, resolved.exact === true) + + for (const el of allElements) { + const texts = await el.evaluate(e => { + const ariaLabel = e.hasAttribute('aria-label') ? e.getAttribute('aria-label') : '' + const labelText = e.id ? document.querySelector(`label[for="${e.id}"]`)?.textContent.trim() || '' : '' + const placeholder = e.getAttribute('placeholder') || '' + const innerText = e.innerText ? e.innerText.trim() : '' + return [ariaLabel || labelText, placeholder, innerText] + }) + + if (texts.some(text => matcherFn(text))) filtered.push(el) + } + + return filtered +} + +function toLocatorConfig(locator, key) { + const matchedLocator = new Locator(locator, key) + if (matchedLocator.locator) return matchedLocator.locator + return { [key]: matchedLocator.value } +} + +function buildRoleSelector(resolved) { + return `::-p-aria([role="${resolved.role}"])` +} + +function createRoleTextMatcher(expected, exactMatch) { + if (expected instanceof RegExp) { + return value => expected.test(value || '') + } + const target = String(expected) + if (exactMatch) { + return value => value === target + } + return value => typeof value === 'string' && value.includes(target) +} + export { Puppeteer as default } diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 8ca0754a9..c1b357b61 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -890,6 +890,17 @@ class WebDriver extends Helper { return els } + // special locator type for ARIA roles + if (locator.role) { + return this._locateByRole(locator) + } + + // Handle role locators passed as Locator instances + const matchedLocator = new Locator(locator) + if (matchedLocator.isRole()) { + return this._locateByRole(matchedLocator.locator) + } + if (!this.options.smartWait || !smartWait) { if (this._isCustomLocator(locator)) { const locatorObj = new Locator(locator) @@ -960,6 +971,34 @@ class WebDriver extends Helper { return findFields.call(this, locator).then(res => res) } + /** + * Locate elements by ARIA role using WebdriverIO accessibility selectors + * + * @param {object} locator - role locator object { role: string, text?: string, exact?: boolean } + */ + async _locateByRole(locator) { + const role = locator.role + + if (!locator.text) { + return this.browser.$$(`[role="${role}"]`) + } + + const elements = await this.browser.$$(`[role="${role}"]`) + const filteredElements = [] + const matchFn = locator.exact === true + ? t => t === locator.text + : t => t && t.includes(locator.text) + + for (const element of elements) { + const texts = await getElementTextAttributes.call(this, element) + if (texts.some(matchFn)) { + filteredElements.push(element) + } + } + + return filteredElements + } + /** * {{> grabWebElements }} * @@ -1239,7 +1278,8 @@ class WebDriver extends Helper { const elementId = getElementId(elem) highlightActiveElement.call(this, elem) - const isSelected = await this.browser.isElementSelected(elementId) + const isSelected = await isElementChecked(this.browser, elementId) + if (isSelected) return Promise.resolve(true) return this.browser[clickMethod](elementId) } @@ -1259,7 +1299,8 @@ class WebDriver extends Helper { const elementId = getElementId(elem) highlightActiveElement.call(this, elem) - const isSelected = await this.browser.isElementSelected(elementId) + const isSelected = await isElementChecked(this.browser, elementId) + if (!isSelected) return Promise.resolve(true) return this.browser[clickMethod](elementId) } @@ -2317,12 +2358,14 @@ class WebDriver extends Helper { res = usingFirstElement(res) assertElementExists(res, locator) - return res.waitForClickable({ - timeout: waitTimeout * 1000, - timeoutMsg: `element ${res.selector} still not clickable after ${waitTimeout} sec`, - }).catch(e => { - throw wrapError(e) - }) + return res + .waitForClickable({ + timeout: waitTimeout * 1000, + timeoutMsg: `element ${res.selector} still not clickable after ${waitTimeout} sec`, + }) + .catch(e => { + throw wrapError(e) + }) } /** @@ -2457,23 +2500,25 @@ class WebDriver extends Helper { async waitNumberOfVisibleElements(locator, num, sec = null) { const aSec = sec || this.options.waitForTimeoutInSeconds - return this.browser.waitUntil( - async () => { - const res = await this._res(locator) - if (!res || res.length === 0) return false - let selected = await forEachAsync(res, async el => el.isDisplayed()) - - if (!Array.isArray(selected)) selected = [selected] - selected = selected.filter(val => val === true) - return selected.length === num - }, - { - timeout: aSec * 1000, - timeoutMsg: `The number of elements (${new Locator(locator)}) is not ${num} after ${aSec} sec`, - }, - ).catch(e => { - throw wrapError(e) - }) + return this.browser + .waitUntil( + async () => { + const res = await this._res(locator) + if (!res || res.length === 0) return false + let selected = await forEachAsync(res, async el => el.isDisplayed()) + + if (!Array.isArray(selected)) selected = [selected] + selected = selected.filter(val => val === true) + return selected.length === num + }, + { + timeout: aSec * 1000, + timeoutMsg: `The number of elements (${new Locator(locator)}) is not ${num} after ${aSec} sec`, + }, + ) + .catch(e => { + throw wrapError(e) + }) } /** @@ -2826,6 +2871,7 @@ async function findClickable(locator, locateFn) { } if (locator.isAccessibilityId() && !this.isWeb) return locateFn(locator, true) + if (locator.isRole()) return locateFn(locator, true) if (!locator.isFuzzy()) return locateFn(locator, true) let els @@ -2840,6 +2886,14 @@ async function findClickable(locator, locateFn) { els = await locateFn(Locator.clickable.self(literal)) if (els.length) return els + // Try ARIA selector for accessible name + try { + els = await this.browser.$$(`aria/${locator.value}`) + if (els.length) return els + } catch (e) { + // ARIA selector not supported or failed + } + return await locateFn(locator.value) // by css or xpath } @@ -2851,6 +2905,7 @@ async function findFields(locator) { } if (locator.isAccessibilityId() && !this.isWeb) return this._locate(locator, true) + if (locator.isRole()) return this._locate(locator, true) if (!locator.isFuzzy()) return this._locate(locator, true) const literal = xpathLocator.literal(locator.value) @@ -2862,6 +2917,15 @@ async function findFields(locator) { els = await this._locate(Locator.field.byName(literal)) if (els.length) return els + + // Try ARIA selector for accessible name + try { + els = await this.browser.$$(`aria/${locator.value}`) + if (els.length) return els + } catch (e) { + // ARIA selector not supported or failed + } + return await this._locate(locator.value) // by css or xpath } @@ -2888,13 +2952,19 @@ async function proceedSeeField(assertType, field, value) { } } - const proceedSingle = el => - el.getValue().then(res => { - if (res === null) { - throw new Error(`Element ${el.selector} has no value attribute`) - } - stringIncludes(`fields by ${field}`)[assertType](value, res) - }) + const proceedSingle = async el => { + let res = await el.getValue() + + if (res === null) { + res = await el.getText() + } + + if (res === null || res === undefined) { + throw new Error(`Element ${el.selector} has no value attribute`) + } + + stringIncludes(`fields by ${field}`)[assertType](value, res) + } const filterBySelected = async elements => filterAsync(elements, async el => this.browser.isElementSelected(getElementId(el))) @@ -2956,10 +3026,31 @@ async function proceedSeeCheckbox(assertType, field) { const res = await findFields.call(this, field) assertElementExists(res, field, 'Field') - const selected = await forEachAsync(res, async el => this.browser.isElementSelected(getElementId(el))) + const selected = await forEachAsync(res, async el => { + const elementId = getElementId(el) + return isElementChecked(this.browser, elementId) + }) + return truth(`checkable field "${field}"`, 'to be checked')[assertType](selected) } +async function getElementTextAttributes(element) { + const elementId = getElementId(element) + const ariaLabel = await this.browser.getElementAttribute(elementId, 'aria-label').catch(() => '') + const placeholder = await this.browser.getElementAttribute(elementId, 'placeholder').catch(() => '') + const innerText = await this.browser.getElementText(elementId).catch(() => '') + return [ariaLabel, placeholder, innerText] +} + +async function isElementChecked(browser, elementId) { + let isChecked = await browser.isElementSelected(elementId) + if (!isChecked) { + const ariaChecked = await browser.getElementAttribute(elementId, 'aria-checked') + isChecked = ariaChecked === 'true' + } + return isChecked +} + async function findCheckable(locator, locateFn) { let els locator = new Locator(locator) @@ -2969,6 +3060,7 @@ async function findCheckable(locator, locateFn) { } if (locator.isAccessibilityId() && !this.isWeb) return locateFn(locator, true) + if (locator.isRole()) return locateFn(locator, true) if (!locator.isFuzzy()) return locateFn(locator, true) const literal = xpathLocator.literal(locator.value) @@ -2977,6 +3069,14 @@ async function findCheckable(locator, locateFn) { els = await locateFn(Locator.checkable.byName(literal)) if (els.length) return els + // Try ARIA selector for accessible name + try { + els = await this.browser.$$(`aria/${locator.value}`) + if (els.length) return els + } catch (e) { + // ARIA selector not supported or failed + } + return await locateFn(locator.value) // by css or xpath } diff --git a/lib/helper/extras/PlaywrightLocator.js b/lib/helper/extras/PlaywrightLocator.js new file mode 100644 index 000000000..c7122939c --- /dev/null +++ b/lib/helper/extras/PlaywrightLocator.js @@ -0,0 +1,110 @@ +import Locator from '../../locator.js' + +function buildLocatorString(locator) { + if (locator.isCustom()) { + return `${locator.type}=${locator.value}` + } + if (locator.isXPath()) { + return `xpath=${locator.value}` + } + return locator.simplify() +} + +async function findElements(matcher, locator) { + const matchedLocator = new Locator(locator, 'css') + + if (matchedLocator.type === 'react') return findReact(matcher, matchedLocator) + if (matchedLocator.type === 'vue') return findVue(matcher, matchedLocator) + if (matchedLocator.type === 'pw') return findByPlaywrightLocator(matcher, matchedLocator) + if (matchedLocator.isRole()) return findByRole(matcher, matchedLocator) + + return matcher.locator(buildLocatorString(matchedLocator)).all() +} + +async function findElement(matcher, locator) { + const matchedLocator = new Locator(locator, 'css') + + if (matchedLocator.type === 'react') return findReact(matcher, matchedLocator) + if (matchedLocator.type === 'vue') return findVue(matcher, matchedLocator) + if (matchedLocator.type === 'pw') return findByPlaywrightLocator(matcher, matchedLocator, { first: true }) + if (matchedLocator.isRole()) return findByRole(matcher, matchedLocator, { first: true }) + + return matcher.locator(buildLocatorString(matchedLocator)).first() +} + +async function getVisibleElements(elements) { + const visibleElements = [] + for (const element of elements) { + if (await element.isVisible()) { + visibleElements.push(element) + } + } + if (visibleElements.length === 0) { + return elements + } + return visibleElements +} + +async function findReact(matcher, locator) { + const details = locator.locator ?? { react: locator.value } + let locatorString = `_react=${details.react}` + + if (details.props) { + locatorString += propBuilder(details.props) + } + + return matcher.locator(locatorString).all() +} + +async function findVue(matcher, locator) { + const details = locator.locator ?? { vue: locator.value } + let locatorString = `_vue=${details.vue}` + + if (details.props) { + locatorString += propBuilder(details.props) + } + + return matcher.locator(locatorString).all() +} + +async function findByPlaywrightLocator(matcher, locator, { first = false } = {}) { + const details = locator.locator ?? { pw: locator.value } + const locatorValue = details.pw + + const handle = matcher.locator(locatorValue) + return first ? handle.first() : handle.all() +} + +async function findByRole(matcher, locator, { first = false } = {}) { + const details = locator.locator ?? { role: locator.value } + const { role, text, name, exact, includeHidden, ...rest } = details + const options = { ...rest } + + if (includeHidden !== undefined) options.includeHidden = includeHidden + + const accessibleName = name ?? text + if (accessibleName !== undefined) { + options.name = accessibleName + if (exact === true) options.exact = true + } + + const roleLocator = matcher.getByRole(role, options) + return first ? roleLocator.first() : roleLocator.all() +} + +function propBuilder(props) { + let _props = '' + + for (const [key, value] of Object.entries(props)) { + if (typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + _props += `[${key}.${k} = "${v}"]` + } + } else { + _props += `[${key} = "${value}"]` + } + } + return _props +} + +export { buildLocatorString, findElements, findElement, getVisibleElements, findReact, findVue, findByPlaywrightLocator, findByRole } diff --git a/lib/helper/extras/PlaywrightReactVueLocator.js b/lib/helper/extras/PlaywrightReactVueLocator.js deleted file mode 100644 index 8253d035c..000000000 --- a/lib/helper/extras/PlaywrightReactVueLocator.js +++ /dev/null @@ -1,43 +0,0 @@ -async function findReact(matcher, locator) { - let _locator = `_react=${locator.react}` - let props = '' - - if (locator.props) { - props += propBuilder(locator.props) - _locator += props - } - return matcher.locator(_locator).all() -} - -async function findVue(matcher, locator) { - let _locator = `_vue=${locator.vue}` - let props = '' - - if (locator.props) { - props += propBuilder(locator.props) - _locator += props - } - return matcher.locator(_locator).all() -} - -async function findByPlaywrightLocator(matcher, locator) { - if (locator && locator.toString().includes(process.env.testIdAttribute)) return matcher.getByTestId(locator.pw.value.split('=')[1]) - return matcher.locator(locator.pw).all() -} - -function propBuilder(props) { - let _props = '' - - for (const [key, value] of Object.entries(props)) { - if (typeof value === 'object') { - for (const [k, v] of Object.entries(value)) { - _props += `[${key}.${k} = "${v}"]` - } - } else { - _props += `[${key} = "${value}"]` - } - } - return _props -} - -export { findReact, findVue, findByPlaywrightLocator } diff --git a/lib/locator.js b/lib/locator.js index 46dd83455..b8eda835c 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -5,7 +5,7 @@ import { createRequire } from 'module' const require = createRequire(import.meta.url) let cssToXPath -const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow', 'pw'] +const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow', 'pw', 'role'] /** @class */ class Locator { /** @@ -78,6 +78,8 @@ class Locator { return { shadow: this.value } case 'pw': return { pw: this.value } + case 'role': + return `[role="${this.value}"]` } return this.value } @@ -129,6 +131,13 @@ class Locator { return this.type === 'pw' } + /** + * @returns {boolean} + */ + isRole() { + return this.type === 'role' + } + /** * @returns {boolean} */ @@ -437,6 +446,7 @@ Locator.clickable = { `.//*[@aria-label = ${literal}]`, `.//*[@title = ${literal}]`, `.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`, + `.//*[@role='button'][normalize-space(.)=${literal}]`, ]), /** @@ -598,6 +608,16 @@ function isPlaywrightLocator(locator) { return locator.includes('_react') || locator.includes('_vue') } +/** + * @private + * check if the locator is a role locator + * @param {{role: string}} locator + * @returns {boolean} + */ +function isRoleLocator(locator) { + return locator.role !== undefined && typeof locator.role === 'string' && Object.keys(locator).length >= 1 +} + /** * @private * @param {CodeceptJS.LocatorOrString} locator diff --git a/package.json b/package.json index a20833c55..cce3d4c8c 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "@wdio/selenium-standalone-service": "8.15.0", "@wdio/utils": "9.15.0", "@xmldom/xmldom": "0.9.8", + "bunosh": "latest", "chai": "^4.5.0", "chai-as-promised": "7.1.2", "chai-subset": "1.6.0", @@ -165,7 +166,7 @@ "puppeteer": "24.8.0", "qrcode-terminal": "0.12.0", "rosie": "2.1.1", - "runok": "0.9.3", + "runok": "^0.9.3", "semver": "7.7.2", "sinon": "21.0.0", "sinon-chai": "3.7.0", diff --git a/test/data/app/view/form/role_elements.php b/test/data/app/view/form/role_elements.php new file mode 100644 index 000000000..2f5c1b304 --- /dev/null +++ b/test/data/app/view/form/role_elements.php @@ -0,0 +1,226 @@ + + + + Role Elements Test + + + +
+

Role Elements Test Form

+ +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+
Submit Form
+
Cancel
+
Reset
+
+
+ +
+

Form Submitted!

+

Form data submitted

+
+
+
+ + + + diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 6779daa46..cc3987033 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -1767,4 +1767,208 @@ export function tests() { expect(wsMessages.length).to.equal(afterWsMessages.length) }) }) + + describe('role locators', () => { + it('should locate elements by role', async () => { + await I.amOnPage('/form/role_elements') + + // Test basic role locators + await I.seeElement({ role: 'button' }) + await I.seeElement({ role: 'combobox' }) + await I.seeElement({ role: 'textbox' }) + await I.seeElement({ role: 'searchbox' }) + await I.seeElement({ role: 'checkbox' }) + + // Test count of elements with same role + await I.seeNumberOfVisibleElements({ role: 'button' }, 3) + await I.seeNumberOfVisibleElements({ role: 'combobox' }, 4) + await I.seeNumberOfVisibleElements({ role: 'checkbox' }, 2) + }) + + it('should locate elements by role with text filter', async () => { + await I.amOnPage('/form/role_elements') + + // Test role with text (exact match) + await I.seeElement({ role: 'button', text: 'Submit Form' }) + await I.seeElement({ role: 'button', text: 'Cancel' }) + await I.seeElement({ role: 'button', text: 'Reset' }) + + // Test role with text (partial match) + await I.seeElement({ role: 'combobox', text: 'Title' }) + await I.seeElement({ role: 'combobox', text: 'Name' }) + await I.seeElement({ role: 'combobox', text: 'Category' }) + + // Test role with exact text match + await I.seeElement({ role: 'combobox', text: 'Title', exact: true }) + await I.dontSeeElement({ role: 'combobox', text: 'title', exact: true }) // case sensitive + + // Test non-existing elements + await I.dontSeeElement({ role: 'button', text: 'Non Existent Button' }) + await I.dontSeeElement({ role: 'combobox', text: 'Non Existent Field' }) + }) + + it('should interact with elements located by role', async () => { + await I.amOnPage('/form/role_elements') + + // Fill combobox by role and text + await I.fillField({ role: 'combobox', text: 'Title' }, 'Test Title') + await I.seeInField({ role: 'combobox', text: 'Title' }, 'Test Title') + + // Fill textbox by role + await I.fillField({ role: 'textbox', text: 'your@email.com' }, 'test@example.com') + await I.seeInField({ role: 'textbox', text: 'your@email.com' }, 'test@example.com') + + // Fill another textbox + await I.fillField({ role: 'textbox', text: 'Enter your message' }, 'This is a test message') + await I.seeInField({ role: 'textbox', text: 'Enter your message' }, 'This is a test message') + + // Click button by role and text + await I.click({ role: 'button', text: 'Submit Form' }) + await I.see('Form Submitted!') + await I.see('Form data submitted') + }) + + it('should work with different role locator combinations', async () => { + await I.amOnPage('/form/role_elements') + + // Test searchbox role + await I.fillField({ role: 'searchbox' }, 'search query') + await I.seeInField({ role: 'searchbox' }, 'search query') + + // Test checkbox interaction + await I.dontSeeCheckboxIsChecked({ role: 'checkbox' }) + await I.checkOption({ role: 'checkbox' }) + await I.seeCheckboxIsChecked({ role: 'checkbox' }) + await I.uncheckOption({ role: 'checkbox' }) + await I.dontSeeCheckboxIsChecked({ role: 'checkbox' }) + + // Test specific checkbox by text + await I.checkOption({ role: 'checkbox', text: 'Subscribe to newsletter' }) + await I.seeCheckboxIsChecked({ role: 'checkbox', text: 'Subscribe to newsletter' }) + await I.dontSeeCheckboxIsChecked({ role: 'checkbox', text: 'I agree to the terms and conditions' }) + }) + + it('should grab elements by role', async () => { + await I.amOnPage('/form/role_elements') + + // Test grabbing multiple elements + const buttons = await I.grabWebElements({ role: 'button' }) + assert.equal(buttons.length, 3) + + const comboboxes = await I.grabWebElements({ role: 'combobox' }) + assert.equal(comboboxes.length, 4) + + // Test grabbing specific element + if (!isHelper('WebDriver')) { + const submitButton = await I.grabWebElement({ role: 'button', text: 'Submit Form' }) + assert.ok(submitButton) + } + + // Test grabbing text from role elements + const buttonText = await I.grabTextFrom({ role: 'button', text: 'Cancel' }) + assert.equal(buttonText, 'Cancel') + + // Test grabbing attributes from role elements + const titlePlaceholder = await I.grabAttributeFrom({ role: 'combobox', text: 'Title' }, 'placeholder') + assert.equal(titlePlaceholder, 'Title') + }) + + it('should work with multiple elements of same role', async () => { + await I.amOnPage('/form/role_elements') + + // Test filling specific combobox by text when there are multiple + await I.fillField({ role: 'combobox', text: 'Name' }, 'John Doe') + await I.fillField({ role: 'combobox', text: 'Category' }, 'Technology') + await I.fillField({ role: 'combobox', text: 'Title' }, 'Software Engineer') + + // Verify each field has the correct value + await I.seeInField({ role: 'combobox', text: 'Name' }, 'John Doe') + await I.seeInField({ role: 'combobox', text: 'Category' }, 'Technology') + await I.seeInField({ role: 'combobox', text: 'Title' }, 'Software Engineer') + + // Submit and verify data is processed correctly + await I.click({ role: 'button', text: 'Submit Form' }) + await I.see('Form Submitted!') + await I.see('John Doe') + await I.see('Technology') + await I.see('Software Engineer') + }) + }) + + describe('aria selectors without role locators', () => { + it('should find clickable elements by aria-label', async () => { + await I.amOnPage('/form/role_elements') + + await I.click('Reset') + await I.dontSeeInField('Title', 'Test') + + await I.click('Submit Form') + await I.see('Form Submitted!') + }) + + it('should click elements by aria-label', async () => { + await I.amOnPage('/form/role_elements') + + await I.fillField('Title', 'Test Title') + await I.fillField('Name', 'John Doe') + + await I.click('Submit Form') + await I.see('Form Submitted!') + await I.see('Test Title') + await I.see('John Doe') + }) + + it('should fill fields by aria-label without specifying role', async () => { + await I.amOnPage('/form/role_elements') + + await I.fillField('Title', 'Senior Developer') + await I.seeInField('Title', 'Senior Developer') + + await I.fillField('Name', 'Jane Smith') + await I.seeInField('Name', 'Jane Smith') + + await I.fillField('Category', 'Engineering') + await I.seeInField('Category', 'Engineering') + + await I.fillField('your@email.com', 'test@example.com') + await I.seeInField('your@email.com', 'test@example.com') + + await I.fillField('Enter your message', 'Hello World') + await I.seeInField('Enter your message', 'Hello World') + }) + + it('should check options by aria-label', async () => { + if (!isHelper('WebDriver')) return + + await I.amOnPage('/form/role_elements') + + await I.dontSeeCheckboxIsChecked('I agree to the terms and conditions') + await I.checkOption('I agree to the terms and conditions') + await I.seeCheckboxIsChecked('I agree to the terms and conditions') + + await I.dontSeeCheckboxIsChecked('Subscribe to newsletter') + await I.checkOption('Subscribe to newsletter') + await I.seeCheckboxIsChecked('Subscribe to newsletter') + }) + + it('should interact with multiple elements using aria-label', async () => { + await I.amOnPage('/form/role_elements') + + await I.fillField('Title', 'Product Manager') + await I.fillField('Name', 'Bob Johnson') + await I.fillField('Category', 'Product') + await I.fillField('your@email.com', 'bob@company.com') + await I.fillField('Enter your message', 'Test message') + + if (isHelper('WebDriver')) { + await I.checkOption('Subscribe to newsletter') + } + + await I.click('Submit Form') + await I.see('Form Submitted!') + await I.see('Product Manager') + await I.see('Bob Johnson') + await I.see('Product') + }) + }) }