diff --git a/docs/webapi/click.mustache b/docs/webapi/click.mustache index c3204f295..7877f353a 100644 --- a/docs/webapi/click.mustache +++ b/docs/webapi/click.mustache @@ -3,9 +3,13 @@ If a fuzzy locator is given, the page will be searched for a button, link, or im For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched. For images, the "alt" attribute and inner text of any parent links are searched. +If no locator is provided, defaults to clicking the body element (`'//body'`). + The second parameter is a context (CSS or XPath locator) to narrow the search. ```js +// click body element (default) +I.click(); // simple link I.click('Logout'); // button of form @@ -20,6 +24,6 @@ I.click('Logout', '#nav'); I.click({css: 'nav a.login'}); ``` -@param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator. +@param {CodeceptJS.LocatorOrString} [locator='//body'] (optional, `'//body'` by default) clickable link or button located by text, or any element located by CSS|XPath|strict locator. @param {?CodeceptJS.LocatorOrString | null} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator. @returns {void} automatically synchronized promise through #recorder diff --git a/lib/command/init.js b/lib/command/init.js index 769bf5bb9..8cab0465f 100644 --- a/lib/command/init.js +++ b/lib/command/init.js @@ -35,7 +35,6 @@ const packages = [] let isTypeScript = false let extension = 'js' -const requireCodeceptConfigure = "const { setHeadlessWhen, setCommonPlugins } = require('@codeceptjs/configure');" const importCodeceptConfigure = "import { setHeadlessWhen, setCommonPlugins } from '@codeceptjs/configure';" const configHeader = ` @@ -232,9 +231,9 @@ export default async function (initPath) { fs.writeFileSync(typeScriptconfigFile, configSource, 'utf-8') print(`Config created at ${typeScriptconfigFile}`) } else { - configSource = beautify(`/** @type {CodeceptJS.MainConfig} */\nexports.config = ${inspect(config, false, 4, false)}`) + configSource = beautify(`/** @type {CodeceptJS.MainConfig} */\nexport const config = ${inspect(config, false, 4, false)}`) - if (hasConfigure) configSource = requireCodeceptConfigure + configHeader + configSource + if (hasConfigure) configSource = importCodeceptConfigure + configHeader + configSource fs.writeFileSync(configFile, configSource, 'utf-8') print(`Config created at ${configFile}`) diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 6c43226fe..37e7c4f2c 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 @@ -1911,7 +1911,7 @@ class Playwright extends Helper { * ``` * */ - async click(locator, context = null, options = {}) { + async click(locator = '//body', context = null, options = {}) { return proceedClick.call(this, locator, context, options) } @@ -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 } @@ -2629,6 +2640,33 @@ class Playwright extends Helper { return array } + /** + * Retrieves the ARIA snapshot for an element using Playwright's [`locator.ariaSnapshot`](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot). + * This method returns a YAML representation of the accessibility tree that can be used for assertions. + * If no locator is provided, it captures the snapshot of the entire page body. + * + * ```js + * const snapshot = await I.grabAriaSnapshot(); + * expect(snapshot).toContain('heading "Sign up"'); + * + * const formSnapshot = await I.grabAriaSnapshot('#login-form'); + * expect(formSnapshot).toContain('textbox "Email"'); + * ``` + * + * [Learn more about ARIA snapshots](https://playwright.dev/docs/aria-snapshots) + * + * @param {string|object} [locator='//body'] element located by CSS|XPath|strict locator. Defaults to body element. + * @return {Promise} YAML representation of the accessibility tree + */ + async grabAriaSnapshot(locator = '//body') { + const matchedLocator = new Locator(locator) + const els = await this._locate(matchedLocator) + assertElementExists(els, locator) + const snapshot = await els[0].ariaSnapshot() + this.debugSection('Aria Snapshot', snapshot) + return snapshot + } + /** * {{> saveElementScreenshot }} * @@ -3821,47 +3859,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 +3895,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 +3929,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 +3970,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 +3982,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..9963cc786 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 * @@ -1140,7 +1146,7 @@ class Puppeteer extends Helper { * * {{ react }} */ - async click(locator, context = null) { + async click(locator = '//body', context = null) { return proceedClick.call(this, locator, context) } @@ -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..c14f4ee17 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 }} * @@ -1009,7 +1048,7 @@ class WebDriver extends Helper { * * {{ react }} */ - async click(locator, context = null) { + async click(locator = '//body', context = null) { const clickMethod = this.browser.isMobile && !this.browser.isW3C ? 'touchClick' : 'elementClick' const locateFn = prepareLocateFn.call(this, context) @@ -1134,7 +1173,17 @@ class WebDriver extends Helper { assertElementExists(res, field, 'Field') const elem = usingFirstElement(res) highlightActiveElement.call(this, elem) - await elem.clearValue() + try { + await elem.clearValue() + } catch (err) { + if (err.message && err.message.includes('invalid element state')) { + await this.executeScript(el => { + el.value = '' + }, elem) + } else { + throw err + } + } await elem.setValue(value.toString()) } @@ -1239,7 +1288,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 +1309,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 +2368,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 +2510,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 +2881,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 @@ -2834,6 +2890,14 @@ async function findClickable(locator, locateFn) { els = await locateFn(Locator.clickable.narrow(literal)) if (els.length) return els + // Try ARIA selector for accessible name + try { + els = await locateFn(`aria/${locator.value}`) + if (els.length) return els + } catch (e) { + // ARIA selector not supported or failed + } + els = await locateFn(Locator.clickable.wide(literal)) if (els.length) return els @@ -2851,6 +2915,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 +2927,7 @@ async function findFields(locator) { els = await this._locate(Locator.field.byName(literal)) if (els.length) return els + return await this._locate(locator.value) // by css or xpath } @@ -2888,13 +2954,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 +3028,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,11 +3062,21 @@ 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) els = await locateFn(Locator.checkable.byText(literal)) if (els.length) return els + + // Try ARIA selector for accessible name + try { + els = await locateFn(`aria/${locator.value}`) + if (els.length) return els + } catch (e) { + // ARIA selector not supported or failed + } + els = await locateFn(Locator.checkable.byName(literal)) if (els.length) return els 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..7b444458a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeceptjs", - "version": "3.7.3", + "version": "4.0.0-beta.7.esm-aria", "type": "module", "description": "Supercharged End 2 End Testing Framework for NodeJS", "keywords": [ @@ -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..29df206e7 --- /dev/null +++ b/test/data/app/view/form/role_elements.php @@ -0,0 +1,231 @@ + + + + Role Elements Test + + + +
+

Role Elements Test Form

+ +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+
Submit
+
Dont Submit
+
Cancel
+
Reset
+
+
+ +
+

Form Submitted!

+

Form data submitted

+
+
+
+ + + + diff --git a/test/helper/Playwright_test.js b/test/helper/Playwright_test.js index 2358fdb4f..494d8818c 100644 --- a/test/helper/Playwright_test.js +++ b/test/helper/Playwright_test.js @@ -800,6 +800,41 @@ describe('Playwright', function () { .then(html => assert.equal(html.trim(), 'New tab'))) }) + describe('#grabAriaSnapshot', () => { + it('should grab aria snapshot of entire page when no locator is provided', () => + I.amOnPage('/') + .then(() => I.grabAriaSnapshot()) + .then(snapshot => { + assert.ok(snapshot) + assert.ok(typeof snapshot === 'string') + })) + + it('should grab aria snapshot of entire page using default body locator', () => + I.amOnPage('/') + .then(() => I.grabAriaSnapshot('//body')) + .then(snapshot => { + assert.ok(snapshot) + assert.ok(typeof snapshot === 'string') + })) + + it('should grab aria snapshot of a specific element', () => + I.amOnPage('/') + .then(() => I.grabAriaSnapshot('#area1')) + .then(snapshot => { + assert.ok(snapshot) + assert.ok(typeof snapshot === 'string') + })) + + it('should grab aria snapshot from within an iframe', () => + I.amOnPage('/iframe') + .then(() => I.switchTo({ frame: 'iframe' })) + .then(() => I.grabAriaSnapshot()) + .then(snapshot => { + assert.ok(snapshot) + assert.ok(typeof snapshot === 'string') + })) + }) + describe('#grabBrowserLogs', () => { it('should grab browser logs', () => I.amOnPage('/') diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 6779daa46..b54568ed6 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -1767,4 +1767,241 @@ 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' }, 4) + 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' }) + await I.seeElement({ role: 'button', text: 'Dont Submit' }) + 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' }) + 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, 4) + + 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' }) + 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' }) + 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') + 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') + 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') + await I.see('Form Submitted!') + await I.see('Product Manager') + await I.see('Bob Johnson') + await I.see('Product') + }) + + it('should click the correct button when multiple buttons have similar text', async () => { + await I.amOnPage('/form/role_elements') + + // Fill form with test data + await I.fillField('Title', 'Test Data') + await I.fillField('Name', 'Test User') + + // Click 'Submit' button - should NOT click 'Dont Submit' + await I.click('Submit') + + // Verify form was submitted (meaning the correct 'Submit' button was clicked) + await I.see('Form Submitted!') + await I.see('Test Data') + await I.see('Test User') + + // Reset and test again to be sure + await I.click('Reset') + await I.dontSee('Form Submitted!') + + // Fill form again + await I.fillField('Title', 'Another Test') + await I.fillField('Name', 'Another User') + + // Click 'Submit' button again + await I.click('Submit') + + // Verify form was submitted again + await I.see('Form Submitted!') + await I.see('Another Test') + await I.see('Another User') + }) + }) } diff --git a/translations/utils.js b/translations/utils.js index e4cf26fe6..e1e5f6aae 100644 --- a/translations/utils.js +++ b/translations/utils.js @@ -1,15 +1,7 @@ -import { readFileSync } from 'fs' -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +import { dialects } from '@cucumber/gherkin' export function gherkinTranslations(langCode) { - // Load gherkin languages JSON file - const gherkinLanguagesPath = join(__dirname, '../node_modules/@cucumber/gherkin/src/gherkin-languages.json') - const gherkinLanguages = JSON.parse(readFileSync(gherkinLanguagesPath, 'utf8')) - const { feature, scenario, scenarioOutline } = gherkinLanguages[langCode] + const { feature, scenario, scenarioOutline } = dialects[langCode] return { Feature: feature[0], Scenario: scenario[0], diff --git a/typings/tests/helpers/WebDriverIO.types.ts b/typings/tests/helpers/WebDriverIO.types.ts index 40f54550d..de7de4783 100644 --- a/typings/tests/helpers/WebDriverIO.types.ts +++ b/typings/tests/helpers/WebDriverIO.types.ts @@ -49,7 +49,7 @@ wd.blur('div', { id: '//div' }) wd.blur('div', { android: '//div' }) wd.blur('div', { ios: '//div' }) -expectError(wd.click()) +expectType(wd.click()) expectType(wd.click('div')) wd.click({ css: 'div' }) wd.click({ xpath: '//div' })