From 799c9f0ca3fa99f1f425daf8254111a22dc6df9d Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 9 Oct 2025 13:21:11 +0300 Subject: [PATCH 01/10] implemented aria selectors for PW/WebDriver/Puppeteer --- lib/helper/Playwright.js | 75 +++++----- lib/helper/Puppeteer.js | 67 +++++++++ lib/helper/WebDriver.js | 130 ++++++++++++++---- .../extras/PlaywrightReactVueLocator.js | 43 ------ lib/locator.js | 21 ++- package.json | 2 +- test/helper/webapi.js | 125 +++++++++++++++++ 7 files changed, 352 insertions(+), 111 deletions(-) delete mode 100644 lib/helper/extras/PlaywrightReactVueLocator.js diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 6c43226fe..5c4f4e5d4 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,13 +2424,21 @@ class Playwright extends Helper { * */ async grabTextFrom(locator) { + const originalLocator = locator + if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { + const els = await this._locate(locator) + assertElementExists(els, locator) + const text = await els[0].innerText() + this.debugSection('Text', text) + return text + } locator = this._contextLocator(locator) let text try { text = await this.page.textContent(locator) } 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 } @@ -3821,47 +3829,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) { @@ -3901,6 +3868,7 @@ 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) + if (locator.role) return findByRole(matcher, locator) locator = new Locator(locator) if (!locator.isFuzzy()) return findElements.call(this, matcher, locator) @@ -3908,6 +3876,20 @@ async function findClickable(matcher, locator) { let els const literal = xpathLocator.literal(locator.value) + try { + els = await matcher.getByRole('button', { name: locator.value }).all() + if (els.length) return els + } catch (err) { + // getByRole not supported or failed + } + + try { + els = await matcher.getByRole('link', { name: locator.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 @@ -3960,6 +3942,10 @@ async function findCheckable(locator, context) { contextEl = contextEl[0] } + if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { + return findElements.call(this, contextEl, locator) + } + const matchedLocator = new Locator(locator) if (!matchedLocator.isFuzzy()) { return findElements.call(this, contextEl, matchedLocator.simplify()) @@ -3986,6 +3972,9 @@ async function proceedIsChecked(assertType, option) { } async function findFields(locator) { + if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { + return this._locate(locator) + } const matchedLocator = new Locator(locator) if (!matchedLocator.isFuzzy()) { return this._locate(matchedLocator) diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 074f1a0d7..598370492 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 * @@ -2740,6 +2746,7 @@ class Puppeteer extends Helper { async function findElements(matcher, locator) { if (locator.react) return findReactElements.call(this, locator) + if (locator.role) return findByRole.call(this, matcher, locator) locator = new Locator(locator, 'css') if (!locator.isXPath()) return matcher.$$(locator.simplify()) @@ -2786,6 +2793,7 @@ async function proceedClick(locator, context = null, options = {}) { async function findClickable(matcher, locator) { if (locator.react) return findReactElements.call(this, locator) + if (locator.role) return findByRole.call(this, matcher, locator) locator = new Locator(locator) if (!locator.isFuzzy()) return findElements.call(this, matcher, locator) @@ -2805,6 +2813,14 @@ async function findClickable(matcher, locator) { // Do nothing } + // Try ARIA selector for accessible name + try { + els = await matcher.$$(`::-p-aria(${locator.value})`) + if (els.length) return els + } catch (err) { + // ARIA selector not supported or failed + } + return findElements.call(this, matcher, locator.value) // by css or xpath } @@ -2847,6 +2863,10 @@ async function findCheckable(locator, context) { contextEl = contextEl[0] } + if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { + return findElements.call(this, contextEl, locator) + } + const matchedLocator = new Locator(locator) if (!matchedLocator.isFuzzy()) { return findElements.call(this, contextEl, matchedLocator.simplify()) @@ -2861,6 +2881,15 @@ async function findCheckable(locator, context) { if (els.length) { return els } + + // Try ARIA selector for accessible name + try { + els = await contextEl.$$(`::-p-aria(${locator})`) + if (els.length) return els + } catch (err) { + // ARIA selector not supported or failed + } + return findElements.call(this, contextEl, locator) } @@ -2880,6 +2909,9 @@ async function findVisibleFields(locator) { } async function findFields(locator) { + if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { + return this._locate(locator) + } const matchedLocator = new Locator(locator) if (!matchedLocator.isFuzzy()) { return this._locate(matchedLocator) @@ -2899,6 +2931,16 @@ async function findFields(locator) { if (els.length) { return els } + + // Try ARIA selector for accessible name + try { + const page = await this.context + els = await page.$$(`::-p-aria(${locator})`) + if (els.length) return els + } catch (err) { + // ARIA selector not supported or failed + } + return this._locate({ css: locator }) } @@ -3212,4 +3254,29 @@ async function findReactElements(locator, props = {}, state = {}) { return result } +async function findByRole(matcher, locator) { + const role = locator.role + + if (!locator.text) { + return matcher.$$(`::-p-aria([role="${role}"])`) + } + + const allElements = await matcher.$$(`::-p-aria([role="${role}"])`) + const filtered = [] + const isExact = locator.exact === true + + for (const el of allElements) { + const texts = await el.evaluate(e => { + const accessibleName = e.hasAttribute('aria-label') ? e.getAttribute('aria-label') : (e.id && document.querySelector(`label[for="${e.id}"]`)?.textContent.trim()) || '' + return [accessibleName, e.getAttribute('placeholder') || '', e.innerText ? e.innerText.trim() : ''] + }) + + const matches = isExact ? texts.some(t => t === locator.text) : texts.some(t => t && t.includes(locator.text)) + + if (matches) filtered.push(el) + } + + return filtered +} + export { Puppeteer as default } diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 8ca0754a9..5cc553d49 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -890,6 +890,11 @@ class WebDriver extends Helper { return els } + // special locator type for ARIA roles + if (locator.role) { + return this._locateByRole(locator) + } + if (!this.options.smartWait || !smartWait) { if (this._isCustomLocator(locator)) { const locatorObj = new Locator(locator) @@ -960,6 +965,53 @@ 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}"]`) + } + + if (locator.exact === true) { + // Use WebdriverIO's aria selector for exact accessible name matching + const elements = await this.browser.$$(`[role="${role}"]`) + const filteredElements = [] + + for (const element of elements) { + try { + const match = await element.$(`aria/${locator.text}`) + if (match) filteredElements.push(element) + } catch (e) { + // Element doesn't have this accessible name + } + } + + return filteredElements + } + + // For partial match, manually filter by accessible name, placeholder, and innerText + const elements = await this.browser.$$(`[role="${role}"]`) + const filteredElements = [] + + for (const element of elements) { + const texts = await element.execute(e => { + const accessibleName = e.hasAttribute('aria-label') ? e.getAttribute('aria-label') : (e.id && document.querySelector(`label[for="${e.id}"]`)?.textContent.trim()) || '' + return [accessibleName, e.getAttribute('placeholder') || '', e.innerText ? e.innerText.trim() : ''] + }) + + if (texts.some(t => t && t.includes(locator.text))) { + filteredElements.push(element) + } + } + + return filteredElements + } + /** * {{> grabWebElements }} * @@ -2317,12 +2369,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 +2511,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 +2882,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 +2897,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 +2916,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 +2928,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 } @@ -2969,6 +3044,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 +3053,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/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..b3f715453 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} */ @@ -598,6 +607,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..ef8d069da 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,7 @@ "puppeteer": "24.8.0", "qrcode-terminal": "0.12.0", "rosie": "2.1.1", - "runok": "0.9.3", + "bunosh": "latest", "semver": "7.7.2", "sinon": "21.0.0", "sinon-chai": "3.7.0", diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 6779daa46..7b58fe654 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -1767,4 +1767,129 @@ 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 + 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') + }) + }) } From 1e3b0860c02a2d5a2f113488e49fec38aaebace7 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 9 Oct 2025 13:27:02 +0300 Subject: [PATCH 02/10] added aria elements --- lib/helper/extras/PlaywrightLocator.js | 146 ++++++++++++++++++++++ test/data/app/view/form/role_elements.php | 106 ++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 lib/helper/extras/PlaywrightLocator.js create mode 100644 test/data/app/view/form/role_elements.php diff --git a/lib/helper/extras/PlaywrightLocator.js b/lib/helper/extras/PlaywrightLocator.js new file mode 100644 index 000000000..bff2eaa1a --- /dev/null +++ b/lib/helper/extras/PlaywrightLocator.js @@ -0,0 +1,146 @@ +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) { + if (locator.react) return findReact(matcher, locator) + if (locator.vue) return findVue(matcher, locator) + if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) + if (locator.role) return findByRole(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) + if (locator.role) return findByRole(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 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() +} + +async function findByRole(matcher, locator) { + const role = locator.role + + if (!locator.text) { + const roleOptions = {} + if (locator.includeHidden !== undefined) roleOptions.includeHidden = locator.includeHidden + return matcher.getByRole(role, roleOptions).all() + } + + const allElements = await matcher.getByRole(role, locator.includeHidden !== undefined ? { includeHidden: locator.includeHidden } : {}).all() + + if (locator.exact === true) { + const filtered = [] + for (const el of allElements) { + const [accessibleName, placeholder, innerText] = await el.evaluate(element => { + const getAccessibleName = () => { + if (element.hasAttribute('aria-label')) { + return element.getAttribute('aria-label') + } + if (element.id) { + const label = document.querySelector(`label[for="${element.id}"]`) + if (label) return label.textContent.trim() + } + return '' + } + return [getAccessibleName(), element.getAttribute('placeholder') || '', element.innerText ? element.innerText.trim() : ''] + }) + + if (accessibleName === locator.text || placeholder === locator.text || innerText === locator.text) { + filtered.push(el) + } + } + return filtered + } + + const filtered = [] + for (const el of allElements) { + const [accessibleName, placeholder, innerText] = await el.evaluate(element => { + const getAccessibleName = () => { + if (element.hasAttribute('aria-label')) { + return element.getAttribute('aria-label') + } + if (element.id) { + const label = document.querySelector(`label[for="${element.id}"]`) + if (label) return label.textContent.trim() + } + return '' + } + return [getAccessibleName(), element.getAttribute('placeholder') || '', element.innerText ? element.innerText.trim() : ''] + }) + + if ((accessibleName && accessibleName.includes(locator.text)) || (placeholder && placeholder.includes(locator.text)) || (innerText && innerText.includes(locator.text))) { + filtered.push(el) + } + } + return filtered +} + +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/test/data/app/view/form/role_elements.php b/test/data/app/view/form/role_elements.php new file mode 100644 index 000000000..cd76a4d83 --- /dev/null +++ b/test/data/app/view/form/role_elements.php @@ -0,0 +1,106 @@ + + + + Role Elements Test + + + +

Role Elements Test Page

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + + + + \ No newline at end of file From 941c9ea6fc42071fc54591f2e36913869bdefecb Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 10 Oct 2025 05:44:36 +0300 Subject: [PATCH 03/10] Implemented by role selector and aria locators with tests --- lib/helper/Playwright.js | 42 ++-- lib/helper/Puppeteer.js | 164 ++++++++---- lib/helper/extras/PlaywrightLocator.js | 122 ++++----- test/data/app/view/form/role_elements.php | 294 +++++++++++++++------- 4 files changed, 380 insertions(+), 242 deletions(-) diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 5c4f4e5d4..d3afcd24c 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -2425,24 +2425,27 @@ class Playwright extends Helper { */ async grabTextFrom(locator) { const originalLocator = locator - if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { - const els = await this._locate(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 } - locator = this._contextLocator(locator) + + 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(originalLocator).toString()} was not found by text|CSS|XPath`) } throw err } - assertElementExists(text, locator) + assertElementExists(text, contextAwareLocator) this.debugSection('Text', text) return text } @@ -3865,26 +3868,22 @@ 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) - if (locator.role) return findByRole(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: locator.value }).all() + 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: locator.value }).all() + els = await matcher.getByRole('link', { name: matchedLocator.value }).all() if (els.length) return els } catch (err) { // getByRole not supported or failed @@ -3903,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) { @@ -3942,16 +3941,12 @@ async function findCheckable(locator, context) { contextEl = contextEl[0] } - if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { - return findElements.call(this, contextEl, locator) - } - 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 @@ -3960,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) { @@ -3972,9 +3967,6 @@ async function proceedIsChecked(assertType, option) { } async function findFields(locator) { - if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { - return this._locate(locator) - } const matchedLocator = new Locator(locator) if (!matchedLocator.isFuzzy()) { return this._locate(matchedLocator) diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 598370492..761b40c05 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -1311,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() @@ -1324,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() @@ -2745,20 +2761,22 @@ class Puppeteer extends Helper { } async function findElements(matcher, locator) { - if (locator.react) return findReactElements.call(this, locator) - if (locator.role) return findByRole.call(this, matcher, 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 @@ -2792,13 +2810,12 @@ async function proceedClick(locator, context = null, options = {}) { } async function findClickable(matcher, locator) { - if (locator.react) return findReactElements.call(this, locator) - if (locator.role) return findByRole.call(this, matcher, 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 @@ -2815,13 +2832,13 @@ async function findClickable(matcher, locator) { // Try ARIA selector for accessible name try { - els = await matcher.$$(`::-p-aria(${locator.value})`) + 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, 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) { @@ -2863,16 +2880,12 @@ async function findCheckable(locator, context) { contextEl = contextEl[0] } - if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { - return findElements.call(this, contextEl, locator) - } - 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 @@ -2884,21 +2897,36 @@ async function findCheckable(locator, context) { // Try ARIA selector for accessible name try { - els = await contextEl.$$(`::-p-aria(${locator})`) + 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, locator) + 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) } @@ -2909,14 +2937,11 @@ async function findVisibleFields(locator) { } async function findFields(locator) { - if (typeof locator === 'object' && (locator.role || locator.react || locator.vue || locator.pw)) { - return this._locate(locator) - } const matchedLocator = new Locator(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) { @@ -2935,13 +2960,13 @@ async function findFields(locator) { // Try ARIA selector for accessible name try { const page = await this.context - els = await page.$$(`::-p-aria(${locator})`) + els = await page.$$(`::-p-aria(${matchedLocator.value})`) if (els.length) return els } catch (err) { // ARIA selector not supported or failed } - return this._locate({ css: locator }) + return this._locate({ css: matchedLocator.value }) } async function proceedDragAndDrop(sourceLocator, destinationLocator) { @@ -3015,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) } } @@ -3189,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) @@ -3235,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 || {}, }, ) @@ -3255,28 +3293,52 @@ async function findReactElements(locator, props = {}, state = {}) { } async function findByRole(matcher, locator) { - const role = locator.role + const resolved = toLocatorConfig(locator, 'role') + const roleSelector = buildRoleSelector(resolved) - if (!locator.text) { - return matcher.$$(`::-p-aria([role="${role}"])`) + if (!resolved.text && !resolved.name) { + return matcher.$$(roleSelector) } - const allElements = await matcher.$$(`::-p-aria([role="${role}"])`) + const allElements = await matcher.$$(roleSelector) const filtered = [] - const isExact = locator.exact === true + 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 accessibleName = e.hasAttribute('aria-label') ? e.getAttribute('aria-label') : (e.id && document.querySelector(`label[for="${e.id}"]`)?.textContent.trim()) || '' - return [accessibleName, e.getAttribute('placeholder') || '', e.innerText ? e.innerText.trim() : ''] + 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] }) - const matches = isExact ? texts.some(t => t === locator.text) : texts.some(t => t && t.includes(locator.text)) - - if (matches) filtered.push(el) + 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/extras/PlaywrightLocator.js b/lib/helper/extras/PlaywrightLocator.js index bff2eaa1a..c7122939c 100644 --- a/lib/helper/extras/PlaywrightLocator.js +++ b/lib/helper/extras/PlaywrightLocator.js @@ -11,23 +11,25 @@ function buildLocatorString(locator) { } 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) - if (locator.role) return findByRole(matcher, locator) - locator = new Locator(locator, 'css') + const matchedLocator = new Locator(locator, 'css') - return matcher.locator(buildLocatorString(locator)).all() + 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) { - if (locator.react) return findReact(matcher, locator) - if (locator.vue) return findVue(matcher, locator) - if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) - if (locator.role) return findByRole(matcher, locator) - locator = new Locator(locator, 'css') + 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(locator)).first() + return matcher.locator(buildLocatorString(matchedLocator)).first() } async function getVisibleElements(elements) { @@ -44,88 +46,50 @@ async function getVisibleElements(elements) { } async function findReact(matcher, locator) { - let _locator = `_react=${locator.react}` - let props = '' + const details = locator.locator ?? { react: locator.value } + let locatorString = `_react=${details.react}` - if (locator.props) { - props += propBuilder(locator.props) - _locator += props + if (details.props) { + locatorString += propBuilder(details.props) } - return matcher.locator(_locator).all() + + return matcher.locator(locatorString).all() } async function findVue(matcher, locator) { - let _locator = `_vue=${locator.vue}` - let props = '' + const details = locator.locator ?? { vue: locator.value } + let locatorString = `_vue=${details.vue}` - if (locator.props) { - props += propBuilder(locator.props) - _locator += props + if (details.props) { + locatorString += propBuilder(details.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() + return matcher.locator(locatorString).all() } -async function findByRole(matcher, locator) { - const role = locator.role +async function findByPlaywrightLocator(matcher, locator, { first = false } = {}) { + const details = locator.locator ?? { pw: locator.value } + const locatorValue = details.pw - if (!locator.text) { - const roleOptions = {} - if (locator.includeHidden !== undefined) roleOptions.includeHidden = locator.includeHidden - return matcher.getByRole(role, roleOptions).all() - } + const handle = matcher.locator(locatorValue) + return first ? handle.first() : handle.all() +} - const allElements = await matcher.getByRole(role, locator.includeHidden !== undefined ? { includeHidden: locator.includeHidden } : {}).all() - - if (locator.exact === true) { - const filtered = [] - for (const el of allElements) { - const [accessibleName, placeholder, innerText] = await el.evaluate(element => { - const getAccessibleName = () => { - if (element.hasAttribute('aria-label')) { - return element.getAttribute('aria-label') - } - if (element.id) { - const label = document.querySelector(`label[for="${element.id}"]`) - if (label) return label.textContent.trim() - } - return '' - } - return [getAccessibleName(), element.getAttribute('placeholder') || '', element.innerText ? element.innerText.trim() : ''] - }) - - if (accessibleName === locator.text || placeholder === locator.text || innerText === locator.text) { - filtered.push(el) - } - } - return filtered - } +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 } - const filtered = [] - for (const el of allElements) { - const [accessibleName, placeholder, innerText] = await el.evaluate(element => { - const getAccessibleName = () => { - if (element.hasAttribute('aria-label')) { - return element.getAttribute('aria-label') - } - if (element.id) { - const label = document.querySelector(`label[for="${element.id}"]`) - if (label) return label.textContent.trim() - } - return '' - } - return [getAccessibleName(), element.getAttribute('placeholder') || '', element.innerText ? element.innerText.trim() : ''] - }) + if (includeHidden !== undefined) options.includeHidden = includeHidden - if ((accessibleName && accessibleName.includes(locator.text)) || (placeholder && placeholder.includes(locator.text)) || (innerText && innerText.includes(locator.text))) { - filtered.push(el) - } + const accessibleName = name ?? text + if (accessibleName !== undefined) { + options.name = accessibleName + if (exact === true) options.exact = true } - return filtered + + const roleLocator = matcher.getByRole(role, options) + return first ? roleLocator.first() : roleLocator.all() } function propBuilder(props) { diff --git a/test/data/app/view/form/role_elements.php b/test/data/app/view/form/role_elements.php index cd76a4d83..2f5c1b304 100644 --- a/test/data/app/view/form/role_elements.php +++ b/test/data/app/view/form/role_elements.php @@ -1,106 +1,226 @@ - + Role Elements Test -

Role Elements Test Page

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - - +
+

Role Elements Test Form

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

Form Submitted!

+

Form data submitted

+
- -
- - -
- -
- - -
- -
- - -
- - - - \ No newline at end of file + From ef8a2039a312fdfcb31b4192a7740cb52301ec57 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sat, 11 Oct 2025 03:10:31 +0300 Subject: [PATCH 04/10] fixed aria selectors for WebDriverIO --- lib/helper/WebDriver.js | 84 ++++++++++++++++++++++++----------------- test/helper/webapi.js | 6 ++- 2 files changed, 54 insertions(+), 36 deletions(-) diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 5cc553d49..c1b357b61 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -895,6 +895,12 @@ class WebDriver extends Helper { 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) @@ -977,34 +983,15 @@ class WebDriver extends Helper { return this.browser.$$(`[role="${role}"]`) } - if (locator.exact === true) { - // Use WebdriverIO's aria selector for exact accessible name matching - const elements = await this.browser.$$(`[role="${role}"]`) - const filteredElements = [] - - for (const element of elements) { - try { - const match = await element.$(`aria/${locator.text}`) - if (match) filteredElements.push(element) - } catch (e) { - // Element doesn't have this accessible name - } - } - - return filteredElements - } - - // For partial match, manually filter by accessible name, placeholder, and innerText 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 element.execute(e => { - const accessibleName = e.hasAttribute('aria-label') ? e.getAttribute('aria-label') : (e.id && document.querySelector(`label[for="${e.id}"]`)?.textContent.trim()) || '' - return [accessibleName, e.getAttribute('placeholder') || '', e.innerText ? e.innerText.trim() : ''] - }) - - if (texts.some(t => t && t.includes(locator.text))) { + const texts = await getElementTextAttributes.call(this, element) + if (texts.some(matchFn)) { filteredElements.push(element) } } @@ -1291,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) } @@ -1311,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) } @@ -2963,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))) @@ -3031,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) diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 7b58fe654..b76b5d51d 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -1859,8 +1859,10 @@ export function tests() { assert.equal(comboboxes.length, 4) // Test grabbing specific element - const submitButton = await I.grabWebElement({ role: 'button', text: 'Submit Form' }) - assert.ok(submitButton) + 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' }) From 3fb659b7f0c2ae088c2dceea0043e5c731904b3c Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sat, 11 Oct 2025 04:10:19 +0300 Subject: [PATCH 05/10] added tests, reverted runok --- lib/locator.js | 1 + package.json | 3 +- test/helper/webapi.js | 77 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/lib/locator.js b/lib/locator.js index b3f715453..b8eda835c 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -446,6 +446,7 @@ Locator.clickable = { `.//*[@aria-label = ${literal}]`, `.//*[@title = ${literal}]`, `.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`, + `.//*[@role='button'][normalize-space(.)=${literal}]`, ]), /** diff --git a/package.json b/package.json index ef8d069da..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", - "bunosh": "latest", + "runok": "^0.9.3", "semver": "7.7.2", "sinon": "21.0.0", "sinon-chai": "3.7.0", diff --git a/test/helper/webapi.js b/test/helper/webapi.js index b76b5d51d..cc3987033 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -1894,4 +1894,81 @@ export function tests() { 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') + }) + }) } From 88c9d5e5e274a30dd784404e1630022ed3c1ee7c Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sat, 11 Oct 2025 06:24:14 +0300 Subject: [PATCH 06/10] 4.0.0-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cce3d4c8c..2af960b65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeceptjs", - "version": "3.7.3", + "version": "4.0.0-beta.1", "type": "module", "description": "Supercharged End 2 End Testing Framework for NodeJS", "keywords": [ From 37570a3c30cc9cf728a32f58898e7988e74d6358 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 12 Oct 2025 03:46:41 +0300 Subject: [PATCH 07/10] fixed aria tests --- test/data/app/view/form/role_elements.php | 7 +++- test/helper/webapi.js | 51 +++++++++++++++++++---- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/test/data/app/view/form/role_elements.php b/test/data/app/view/form/role_elements.php index 2f5c1b304..29df206e7 100644 --- a/test/data/app/view/form/role_elements.php +++ b/test/data/app/view/form/role_elements.php @@ -157,7 +157,8 @@ class="custom-combobox"
-
Submit Form
+
Submit
+
Dont Submit
Cancel
Reset
@@ -211,6 +212,10 @@ class="custom-combobox" result.style.display = 'block'; }); + document.getElementById('dontSubmitBtn').addEventListener('click', function() { + alert('Dont Submit button was clicked - this should NOT happen when testing I.click("Submit")'); + }); + document.getElementById('resetBtn').addEventListener('click', function() { document.querySelectorAll('[contenteditable]').forEach(el => { el.textContent = ''; diff --git a/test/helper/webapi.js b/test/helper/webapi.js index cc3987033..b54568ed6 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -1780,7 +1780,7 @@ export function tests() { await I.seeElement({ role: 'checkbox' }) // Test count of elements with same role - await I.seeNumberOfVisibleElements({ role: 'button' }, 3) + await I.seeNumberOfVisibleElements({ role: 'button' }, 4) await I.seeNumberOfVisibleElements({ role: 'combobox' }, 4) await I.seeNumberOfVisibleElements({ role: 'checkbox' }, 2) }) @@ -1789,7 +1789,8 @@ export function tests() { 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: 'Submit' }) + await I.seeElement({ role: 'button', text: 'Dont Submit' }) await I.seeElement({ role: 'button', text: 'Cancel' }) await I.seeElement({ role: 'button', text: 'Reset' }) @@ -1823,7 +1824,7 @@ export function tests() { 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.click({ role: 'button', text: 'Submit' }) await I.see('Form Submitted!') await I.see('Form data submitted') }) @@ -1853,14 +1854,14 @@ export function tests() { // Test grabbing multiple elements const buttons = await I.grabWebElements({ role: 'button' }) - assert.equal(buttons.length, 3) + 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 Form' }) + const submitButton = await I.grabWebElement({ role: 'button', text: 'Submit' }) assert.ok(submitButton) } @@ -1887,7 +1888,7 @@ export function tests() { 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.click({ role: 'button', text: 'Submit' }) await I.see('Form Submitted!') await I.see('John Doe') await I.see('Technology') @@ -1902,7 +1903,7 @@ export function tests() { await I.click('Reset') await I.dontSeeInField('Title', 'Test') - await I.click('Submit Form') + await I.click('Submit') await I.see('Form Submitted!') }) @@ -1912,7 +1913,7 @@ export function tests() { await I.fillField('Title', 'Test Title') await I.fillField('Name', 'John Doe') - await I.click('Submit Form') + await I.click('Submit') await I.see('Form Submitted!') await I.see('Test Title') await I.see('John Doe') @@ -1964,11 +1965,43 @@ export function tests() { await I.checkOption('Subscribe to newsletter') } - await I.click('Submit Form') + 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') + }) }) } From 252af3871e92020e37544b5da163cd3c27def95e Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 13 Oct 2025 22:58:48 +0300 Subject: [PATCH 08/10] fixed WD tests --- lib/helper/WebDriver.js | 35 ++++++++++++++++++----------------- package.json | 2 +- translations/utils.js | 12 ++---------- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index c1b357b61..a735ad903 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -2880,20 +2880,20 @@ async function findClickable(locator, locateFn) { els = await locateFn(Locator.clickable.narrow(literal)) if (els.length) return els - els = await locateFn(Locator.clickable.wide(literal)) - if (els.length) return els - - 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}`) + 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 + + els = await locateFn(Locator.clickable.self(literal)) + if (els.length) return els + return await locateFn(locator.value) // by css or xpath } @@ -2912,20 +2912,20 @@ async function findFields(locator) { let els = await this._locate(Locator.field.labelEquals(literal)) if (els.length) return els - els = await this._locate(Locator.field.labelContains(literal)) - if (els.length) return els - - 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}`) + els = await this._locate(`aria/${locator.value}`) if (els.length) return els } catch (e) { // ARIA selector not supported or failed } + els = await this._locate(Locator.field.labelContains(literal)) + if (els.length) return els + + els = await this._locate(Locator.field.byName(literal)) + if (els.length) return els + return await this._locate(locator.value) // by css or xpath } @@ -3066,17 +3066,18 @@ async function findCheckable(locator, locateFn) { const literal = xpathLocator.literal(locator.value) els = await locateFn(Locator.checkable.byText(literal)) if (els.length) return els - 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}`) + 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 + return await locateFn(locator.value) // by css or xpath } diff --git a/package.json b/package.json index 2af960b65..7b444458a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeceptjs", - "version": "4.0.0-beta.1", + "version": "4.0.0-beta.7.esm-aria", "type": "module", "description": "Supercharged End 2 End Testing Framework for NodeJS", "keywords": [ 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], From 587e475d1ed4baa3ded0c30a03e9370256c4dd5e Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 16 Oct 2025 01:14:05 +0300 Subject: [PATCH 09/10] improved output --- docs/webapi/click.mustache | 6 +++++- lib/command/init.js | 5 ++--- lib/helper/Playwright.js | 29 +++++++++++++++++++++++++++- lib/helper/Puppeteer.js | 2 +- lib/helper/WebDriver.js | 22 +++++++++++---------- test/helper/Playwright_test.js | 35 ++++++++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 16 deletions(-) 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 d3afcd24c..37e7c4f2c 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -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) } @@ -2640,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 }} * diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 761b40c05..9963cc786 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -1146,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) } diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index a735ad903..c14f4ee17 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -1048,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) @@ -1173,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()) } @@ -2912,14 +2922,6 @@ async function findFields(locator) { let els = await this._locate(Locator.field.labelEquals(literal)) if (els.length) return els - // Try ARIA selector for accessible name - try { - els = await this._locate(`aria/${locator.value}`) - if (els.length) return els - } catch (e) { - // ARIA selector not supported or failed - } - els = await this._locate(Locator.field.labelContains(literal)) if (els.length) return els 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('/') From 18d9e1798ed3a8d8fba76653d049d79ec69f8a5c Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 17 Oct 2025 02:02:05 +0300 Subject: [PATCH 10/10] fixed webdriver types --- typings/tests/helpers/WebDriverIO.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' })