Skip to content

Commit cbd3d8c

Browse files
DavertMikDavertMik
authored andcommitted
Feat/aria selectors (#5260)
* implemented aria selectors for PW/WebDriver/Puppeteer * added aria elements * Implemented by role selector and aria locators with tests * fixed aria selectors for WebDriverIO * added tests, reverted runok * 4.0.0-beta.1 * fixed aria tests * fixed WD tests * improved output * fixed webdriver types --------- Co-authored-by: DavertMik <[email protected]>
1 parent b8b9400 commit cbd3d8c

File tree

14 files changed

+1010
-296
lines changed

14 files changed

+1010
-296
lines changed

docs/webapi/click.mustache

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ If a fuzzy locator is given, the page will be searched for a button, link, or im
33
For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched.
44
For images, the "alt" attribute and inner text of any parent links are searched.
55

6+
If no locator is provided, defaults to clicking the body element (`'//body'`).
7+
68
The second parameter is a context (CSS or XPath locator) to narrow the search.
79

810
```js
11+
// click body element (default)
12+
I.click();
913
// simple link
1014
I.click('Logout');
1115
// button of form
@@ -20,6 +24,6 @@ I.click('Logout', '#nav');
2024
I.click({css: 'nav a.login'});
2125
```
2226

23-
@param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator.
27+
@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.
2428
@param {?CodeceptJS.LocatorOrString | null} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
2529
@returns {void} automatically synchronized promise through #recorder

lib/command/init.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ const packages = []
4040
let isTypeScript = false
4141
let extension = 'js'
4242

43-
const requireCodeceptConfigure = "const { setHeadlessWhen, setCommonPlugins } = require('@codeceptjs/configure');"
4443
const importCodeceptConfigure = "import { setHeadlessWhen, setCommonPlugins } from '@codeceptjs/configure';"
4544

4645
const configHeader = `
@@ -237,9 +236,9 @@ export default async function (initPath) {
237236
fs.writeFileSync(typeScriptconfigFile, configSource, 'utf-8')
238237
print(`Config created at ${typeScriptconfigFile}`)
239238
} else {
240-
configSource = beautify(`/** @type {CodeceptJS.MainConfig} */\nexports.config = ${inspect(config, false, 4, false)}`)
239+
configSource = beautify(`/** @type {CodeceptJS.MainConfig} */\nexport const config = ${inspect(config, false, 4, false)}`)
241240

242-
if (hasConfigure) configSource = requireCodeceptConfigure + configHeader + configSource
241+
if (hasConfigure) configSource = importCodeceptConfigure + configHeader + configSource
243242

244243
fs.writeFileSync(configFile, configSource, 'utf-8')
245244
print(`Config created at ${configFile}`)

lib/helper/Playwright.js

Lines changed: 65 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import ElementNotFound from './errors/ElementNotFound.js'
3030
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
3131
import Popup from './extras/Popup.js'
3232
import Console from './extras/Console.js'
33-
import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
33+
import { buildLocatorString, findElements, findElement, getVisibleElements, findReact, findVue, findByPlaywrightLocator, findByRole } from './extras/PlaywrightLocator.js'
3434

3535
let playwright
3636
let perfTiming
@@ -2068,7 +2068,7 @@ class Playwright extends Helper {
20682068
* ```
20692069
*
20702070
*/
2071-
async click(locator, context = null, options = {}) {
2071+
async click(locator = '//body', context = null, options = {}) {
20722072
return proceedClick.call(this, locator, context, options)
20732073
}
20742074

@@ -2666,17 +2666,28 @@ class Playwright extends Helper {
26662666
*
26672667
*/
26682668
async grabTextFrom(locator) {
2669-
locator = this._contextLocator(locator)
2669+
const originalLocator = locator
2670+
const matchedLocator = new Locator(locator)
2671+
2672+
if (!matchedLocator.isFuzzy()) {
2673+
const els = await this._locate(matchedLocator)
2674+
assertElementExists(els, locator)
2675+
const text = await els[0].innerText()
2676+
this.debugSection('Text', text)
2677+
return text
2678+
}
2679+
2680+
const contextAwareLocator = this._contextLocator(matchedLocator.value)
26702681
let text
26712682
try {
2672-
text = await this.page.textContent(locator)
2683+
text = await this.page.textContent(contextAwareLocator)
26732684
} catch (err) {
26742685
if (err.message.includes('Timeout') || err.message.includes('exceeded')) {
2675-
throw new Error(`Element ${new Locator(locator).toString()} was not found by text|CSS|XPath`)
2686+
throw new Error(`Element ${new Locator(originalLocator).toString()} was not found by text|CSS|XPath`)
26762687
}
26772688
throw err
26782689
}
2679-
assertElementExists(text, locator)
2690+
assertElementExists(text, contextAwareLocator)
26802691
this.debugSection('Text', text)
26812692
return text
26822693
}
@@ -2866,6 +2877,33 @@ class Playwright extends Helper {
28662877
return array
28672878
}
28682879

2880+
/**
2881+
* Retrieves the ARIA snapshot for an element using Playwright's [`locator.ariaSnapshot`](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot).
2882+
* This method returns a YAML representation of the accessibility tree that can be used for assertions.
2883+
* If no locator is provided, it captures the snapshot of the entire page body.
2884+
*
2885+
* ```js
2886+
* const snapshot = await I.grabAriaSnapshot();
2887+
* expect(snapshot).toContain('heading "Sign up"');
2888+
*
2889+
* const formSnapshot = await I.grabAriaSnapshot('#login-form');
2890+
* expect(formSnapshot).toContain('textbox "Email"');
2891+
* ```
2892+
*
2893+
* [Learn more about ARIA snapshots](https://playwright.dev/docs/aria-snapshots)
2894+
*
2895+
* @param {string|object} [locator='//body'] element located by CSS|XPath|strict locator. Defaults to body element.
2896+
* @return {Promise<string>} YAML representation of the accessibility tree
2897+
*/
2898+
async grabAriaSnapshot(locator = '//body') {
2899+
const matchedLocator = new Locator(locator)
2900+
const els = await this._locate(matchedLocator)
2901+
assertElementExists(els, locator)
2902+
const snapshot = await els[0].ariaSnapshot()
2903+
this.debugSection('Aria Snapshot', snapshot)
2904+
return snapshot
2905+
}
2906+
28692907
/**
28702908
* {{> saveElementScreenshot }}
28712909
*
@@ -4123,157 +4161,6 @@ class Playwright extends Helper {
41234161

41244162
export default Playwright
41254163

4126-
function buildCustomLocatorString(locator) {
4127-
// Note: this.debug not available in standalone function, using console.log
4128-
console.log(`Building custom locator string: ${locator.type}=${locator.value}`)
4129-
return `${locator.type}=${locator.value}`
4130-
}
4131-
4132-
function buildLocatorString(locator) {
4133-
if (locator.isCustom()) {
4134-
return buildCustomLocatorString(locator)
4135-
}
4136-
if (locator.isXPath()) {
4137-
return `xpath=${locator.value}`
4138-
}
4139-
return locator.simplify()
4140-
}
4141-
4142-
async function findElements(matcher, locator) {
4143-
if (locator.react) return findReact(matcher, locator)
4144-
if (locator.vue) return findVue(matcher, locator)
4145-
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4146-
4147-
locator = new Locator(locator, 'css')
4148-
4149-
// Handle custom locators directly instead of relying on Playwright selector engines
4150-
if (locator.isCustom()) {
4151-
return findCustomElements.call(this, matcher, locator)
4152-
}
4153-
4154-
// Check if we have a custom context locator and need to search within it
4155-
if (this.contextLocator) {
4156-
const contextLocatorObj = new Locator(this.contextLocator, 'css')
4157-
if (contextLocatorObj.isCustom()) {
4158-
// Find the context elements first
4159-
const contextElements = await findCustomElements.call(this, matcher, contextLocatorObj)
4160-
if (contextElements.length === 0) {
4161-
return []
4162-
}
4163-
4164-
// Search within the first context element
4165-
const locatorString = buildLocatorString(locator)
4166-
return contextElements[0].locator(locatorString).all()
4167-
}
4168-
}
4169-
4170-
const locatorString = buildLocatorString(locator)
4171-
4172-
return matcher.locator(locatorString).all()
4173-
}
4174-
4175-
async function findCustomElements(matcher, locator) {
4176-
const customLocatorStrategies = this.customLocatorStrategies || globalCustomLocatorStrategies
4177-
const strategyFunction = customLocatorStrategies.get ? customLocatorStrategies.get(locator.type) : customLocatorStrategies[locator.type]
4178-
4179-
if (!strategyFunction) {
4180-
throw new Error(`Custom locator strategy "${locator.type}" is not defined. Please define "customLocatorStrategies" in your configuration.`)
4181-
}
4182-
4183-
// Execute the custom locator function in the browser context using page.evaluate
4184-
const page = matcher.constructor.name === 'Page' ? matcher : await matcher.page()
4185-
4186-
const elements = await page.evaluate(
4187-
({ strategyCode, selector }) => {
4188-
const strategy = new Function('return ' + strategyCode)()
4189-
const result = strategy(selector, document)
4190-
4191-
// Convert NodeList or single element to array
4192-
if (result && result.nodeType) {
4193-
return [result]
4194-
} else if (result && result.length !== undefined) {
4195-
return Array.from(result)
4196-
} else if (Array.isArray(result)) {
4197-
return result
4198-
}
4199-
4200-
return []
4201-
},
4202-
{
4203-
strategyCode: strategyFunction.toString(),
4204-
selector: locator.value,
4205-
},
4206-
)
4207-
4208-
// Convert the found elements back to Playwright locators
4209-
if (elements.length === 0) {
4210-
return []
4211-
}
4212-
4213-
// Create CSS selectors for the found elements and return as locators
4214-
const locators = []
4215-
const timestamp = Date.now()
4216-
4217-
for (let i = 0; i < elements.length; i++) {
4218-
// Use a unique attribute approach to target specific elements
4219-
const uniqueAttr = `data-codecept-custom-${timestamp}-${i}`
4220-
4221-
await page.evaluate(
4222-
({ index, uniqueAttr, strategyCode, selector }) => {
4223-
// Re-execute the strategy to find elements and mark the specific one
4224-
const strategy = new Function('return ' + strategyCode)()
4225-
const result = strategy(selector, document)
4226-
4227-
let elementsArray = []
4228-
if (result && result.nodeType) {
4229-
elementsArray = [result]
4230-
} else if (result && result.length !== undefined) {
4231-
elementsArray = Array.from(result)
4232-
} else if (Array.isArray(result)) {
4233-
elementsArray = result
4234-
}
4235-
4236-
if (elementsArray[index]) {
4237-
elementsArray[index].setAttribute(uniqueAttr, 'true')
4238-
}
4239-
},
4240-
{
4241-
index: i,
4242-
uniqueAttr,
4243-
strategyCode: strategyFunction.toString(),
4244-
selector: locator.value,
4245-
},
4246-
)
4247-
4248-
locators.push(page.locator(`[${uniqueAttr}="true"]`))
4249-
}
4250-
4251-
return locators
4252-
}
4253-
4254-
async function findElement(matcher, locator) {
4255-
if (locator.react) return findReact(matcher, locator)
4256-
if (locator.vue) return findVue(matcher, locator)
4257-
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4258-
4259-
locator = new Locator(locator, 'css')
4260-
4261-
return matcher.locator(buildLocatorString(locator)).first()
4262-
}
4263-
4264-
async function getVisibleElements(elements) {
4265-
const visibleElements = []
4266-
for (const element of elements) {
4267-
if (await element.isVisible()) {
4268-
visibleElements.push(element)
4269-
}
4270-
}
4271-
if (visibleElements.length === 0) {
4272-
return elements
4273-
}
4274-
return visibleElements
4275-
}
4276-
42774164
async function proceedClick(locator, context = null, options = {}) {
42784165
let matcher = await this._getContext()
42794166
if (context) {
@@ -4310,15 +4197,26 @@ async function proceedClick(locator, context = null, options = {}) {
43104197
}
43114198

43124199
async function findClickable(matcher, locator) {
4313-
if (locator.react) return findReact(matcher, locator)
4314-
if (locator.vue) return findVue(matcher, locator)
4315-
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4200+
const matchedLocator = new Locator(locator)
43164201

4317-
locator = new Locator(locator)
4318-
if (!locator.isFuzzy()) return findElements.call(this, matcher, locator)
4202+
if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
43194203

43204204
let els
4321-
const literal = xpathLocator.literal(locator.value)
4205+
const literal = xpathLocator.literal(matchedLocator.value)
4206+
4207+
try {
4208+
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
4209+
if (els.length) return els
4210+
} catch (err) {
4211+
// getByRole not supported or failed
4212+
}
4213+
4214+
try {
4215+
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
4216+
if (els.length) return els
4217+
} catch (err) {
4218+
// getByRole not supported or failed
4219+
}
43224220

43234221
els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
43244222
if (els.length) return els
@@ -4333,7 +4231,7 @@ async function findClickable(matcher, locator) {
43334231
// Do nothing
43344232
}
43354233

4336-
return findElements.call(this, matcher, locator.value) // by css or xpath
4234+
return findElements.call(this, matcher, matchedLocator.value) // by css or xpath
43374235
}
43384236

43394237
async function proceedSee(assertType, text, context, strict = false) {
@@ -4374,10 +4272,10 @@ async function findCheckable(locator, context) {
43744272

43754273
const matchedLocator = new Locator(locator)
43764274
if (!matchedLocator.isFuzzy()) {
4377-
return findElements.call(this, contextEl, matchedLocator.simplify())
4275+
return findElements.call(this, contextEl, matchedLocator)
43784276
}
43794277

4380-
const literal = xpathLocator.literal(locator)
4278+
const literal = xpathLocator.literal(matchedLocator.value)
43814279
let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
43824280
if (els.length) {
43834281
return els
@@ -4386,7 +4284,7 @@ async function findCheckable(locator, context) {
43864284
if (els.length) {
43874285
return els
43884286
}
4389-
return findElements.call(this, contextEl, locator)
4287+
return findElements.call(this, contextEl, matchedLocator.value)
43904288
}
43914289

43924290
async function proceedIsChecked(assertType, option) {

0 commit comments

Comments
 (0)