diff --git a/docs/element-selection.md b/docs/element-selection.md new file mode 100644 index 000000000..f8441b17c --- /dev/null +++ b/docs/element-selection.md @@ -0,0 +1,125 @@ +--- +permalink: /element-selection +title: Element Selection +--- + +# Element Selection + +When you write `I.click('a')` and there are multiple links on a page, CodeceptJS clicks the **first** one it finds. Most of the time this is exactly what you need — your locators are specific enough that there's only one match, or the first match happens to be the right one. + +But what happens when it's not? + +## Picking a Specific Element + +Say you have a list of items and you want to click the second one. You could write a more specific CSS selector, but sometimes the simplest approach is to tell CodeceptJS which element you want by position: + +```js +import step from 'codeceptjs/steps' + +// click the 2nd link +I.click('a', step.opts({ elementIndex: 2 })) + +// click the last link +I.click('a', step.opts({ elementIndex: 'last' })) + +// fill the last matching input +I.fillField('.email-input', 'test@example.com', step.opts({ elementIndex: -1 })) +``` + +The `elementIndex` option accepts: + +* **Positive numbers** (1-based) — `1` is first, `2` is second, `3` is third +* **Negative numbers** — `-1` is last, `-2` is second-to-last +* **`'first'`** and **`'last'`** as readable aliases + +This works with any action that targets a single element: `click`, `doubleClick`, `rightClick`, `fillField`, `appendField`, `clearField`, `checkOption`, `selectOption`, `attachFile`, and others. + +If only one element matches the locator, `elementIndex` is silently ignored — you always get that single element regardless of the index value. This is convenient when the number of matches depends on page state: you won't get an error if the list happens to have just one item. + +When multiple elements exist but the index is out of range, CodeceptJS throws a clear error: + +``` +elementIndex 100 exceeds the number of elements found (3) for "a" +``` + +You can combine `elementIndex` with other step options: + +```js +I.click('a', step.opts({ elementIndex: 2 }).timeout(5).retry(3)) +``` + +## Strict Mode + +If you'd rather not silently click the first of many matches, enable `strict: true` in your helper configuration. This makes CodeceptJS throw an error whenever a locator matches more than one element, forcing you to write precise locators: + +```js +// codecept.conf.js +helpers: { + Playwright: { + url: 'http://localhost', + browser: 'chromium', + strict: true, + } +} +``` + +Now any ambiguous locator will fail immediately: + +```js +I.click('a') // MultipleElementsFound: Multiple elements (3) found for "a" in strict mode +``` + +This is useful on projects where you want to catch accidental matches early — clicking the wrong button because of a vague locator is a common source of flaky tests. + +When a test fails in strict mode, the error includes a `fetchDetails()` method that lists the matched elements with their XPath and simplified HTML, so you can see exactly what was found and write a better locator: + +```js +// Multiple elements (3) found for "a" in strict mode. Call fetchDetails() for full information. +// After fetchDetails(): +// /html/body/div/a[1] First +// /html/body/div/a[2] Second +// /html/body/div/a[3] Third +// Use a more specific locator or grabWebElements() to work with multiple elements +``` + +Strict mode is supported in **Playwright**, **Puppeteer**, and **WebDriver** helpers. + +### Per-Step Strict Mode with `exact` + +You don't have to enable strict mode globally. Use `exact: true` to enforce it on a single step — handy when most of your tests are fine with default behavior but a particular action needs to be precise: + +```js +import step from 'codeceptjs/steps' + +I.click('a', step.opts({ exact: true })) +// throws MultipleElementsFound if more than one link matches +``` + +`strictMode: true` is an alias if you prefer a more descriptive name: + +```js +I.click('a', step.opts({ strictMode: true })) +``` + +It works the other way too. If your helper has `strict: true` globally but you need to relax it for one step, use `exact: false`: + +```js +// strict: true in config, but this step allows multiple matches +I.click('a', step.opts({ exact: false })) +``` + +And when you know there are multiple matches and want a specific one, `elementIndex` also overrides the strict check — no error is thrown because you've explicitly chosen which element to use: + +```js +// strict: true in config, but this works without error +I.click('a', step.opts({ elementIndex: 2 })) +``` + +## Summary + +| Situation | Approach | +|-----------|----------| +| You want to catch ambiguous locators early | Enable `strict: true` in helper config | +| You need a specific element from a known list | Use `step.opts({ elementIndex: N })` | +| You want to iterate over all matching elements | Use [`eachElement`](/els) from the `els` module | +| You need full control over element inspection | Use [`grabWebElements`](/WebElement) to get all matches | diff --git a/lib/element/WebElement.js b/lib/element/WebElement.js index 4c30028b2..173cdc753 100644 --- a/lib/element/WebElement.js +++ b/lib/element/WebElement.js @@ -325,7 +325,7 @@ class WebElement { parts.unshift(`${tagName}${pathIndex}`) current = current.parentElement } - return '/' + parts.join('/') + return '//' + parts.join('/') } switch (this.helperType) { diff --git a/lib/els.js b/lib/els.js index 107cc303b..cf2a82fe5 100644 --- a/lib/els.js +++ b/lib/els.js @@ -9,7 +9,7 @@ import { isAsyncFunction, humanizeFunction } from './utils.js' function element(purpose, locator, fn) { let stepConfig - if (arguments[arguments.length - 1] instanceof StepConfig) { + if (StepConfig.isStepConfig(arguments[arguments.length - 1])) { stepConfig = arguments[arguments.length - 1] } diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 034e5053e..01dfbda10 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -38,6 +38,7 @@ import Console from './extras/Console.js' import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js' import { dropFile } from './scripts/dropFile.js' import WebElement from '../element/WebElement.js' +import { selectElement } from './extras/elementSelection.js' let playwright let perfTiming @@ -1779,8 +1780,7 @@ class Playwright extends Helper { if (elements.length === 0) { throw new ElementNotFound(locator, 'Element', 'was not found') } - if (this.options.strict) assertOnlyOneElement(elements, locator, this) - return elements[0] + return selectElement(elements, locator, this) } /** @@ -1795,8 +1795,7 @@ class Playwright extends Helper { const context = providedContext || (await this._getContext()) const els = await findCheckable.call(this, locator, context) assertElementExists(els[0], locator, 'Checkbox or radio') - if (this.options.strict) assertOnlyOneElement(els, locator, this) - return els[0] + return selectElement(els, locator, this) } /** @@ -2282,8 +2281,7 @@ class Playwright extends Helper { async fillField(field, value, context = null) { const els = await findFields.call(this, field, context) assertElementExists(els, field, 'Field') - if (this.options.strict) assertOnlyOneElement(els, field, this) - const el = els[0] + const el = selectElement(els, field, this) await el.clear() if (store.debugMode) this.debugSection('Focused', await elToString(el, 1)) @@ -2301,9 +2299,8 @@ class Playwright extends Helper { async clearField(locator, context = null) { const els = await findFields.call(this, locator, context) assertElementExists(els, locator, 'Field to clear') - if (this.options.strict) assertOnlyOneElement(els, locator, this) - const el = els[0] + const el = selectElement(els, locator, this) await highlightActiveElement.call(this, el) @@ -2318,10 +2315,10 @@ class Playwright extends Helper { async appendField(field, value, context = null) { const els = await findFields.call(this, field, context) assertElementExists(els, field, 'Field') - if (this.options.strict) assertOnlyOneElement(els, field, this) - await highlightActiveElement.call(this, els[0]) - await els[0].press('End') - await els[0].type(value.toString(), { delay: this.options.pressKeyDelay }) + const el = selectElement(els, field, this) + await highlightActiveElement.call(this, el) + await el.press('End') + await el.type(value.toString(), { delay: this.options.pressKeyDelay }) return this._waitForAction() } @@ -2353,22 +2350,24 @@ class Playwright extends Helper { } const els = await findFields.call(this, locator, context) if (els.length) { - const tag = await els[0].evaluate(el => el.tagName) - const type = await els[0].evaluate(el => el.type) + const el = selectElement(els, locator, this) + const tag = await el.evaluate(el => el.tagName) + const type = await el.evaluate(el => el.type) if (tag === 'INPUT' && type === 'file') { - await els[0].setInputFiles(file) + await el.setInputFiles(file) return this._waitForAction() } } const targetEls = els.length ? els : await this._locate(locator) assertElementExists(targetEls, locator, 'Element') + const el = selectElement(targetEls, locator, this) const fileData = { base64Content: base64EncodeFile(file), fileName: path.basename(file), mimeType: getMimeType(path.basename(file)), } - await targetEls[0].evaluate(dropFile, fileData) + await el.evaluate(dropFile, fileData) return this._waitForAction() } @@ -2391,23 +2390,23 @@ class Playwright extends Helper { this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`) const els = contextEl ? await findElements.call(this, contextEl, matchedLocator) : await this._locate(matchedLocator) assertElementExists(els, select, 'Selectable element') - return proceedSelect.call(this, pageContext, els[0], option) + return proceedSelect.call(this, pageContext, selectElement(els, select, this), option) } // Fuzzy: try combobox this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`) const comboboxSearchCtx = contextEl || pageContext let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value }) - if (els?.length) return proceedSelect.call(this, pageContext, els[0], option) + if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option) // Fuzzy: try listbox els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value }) - if (els?.length) return proceedSelect.call(this, pageContext, els[0], option) + if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option) // Fuzzy: try native select els = await findFields.call(this, select, context) assertElementExists(els, select, 'Selectable element') - return proceedSelect.call(this, pageContext, els[0], option) + return proceedSelect.call(this, pageContext, selectElement(els, select, this), option) } /** @@ -2701,15 +2700,12 @@ class Playwright extends Helper { * */ async grabTextFrom(locator) { - // Handle role locators with text/exact options - if (isRoleLocatorObject(locator)) { - const elements = await handleRoleLocator(this.page, locator) - if (elements && elements.length > 0) { - const text = await elements[0].textContent() - assertElementExists(text, JSON.stringify(locator)) - this.debugSection('Text', text) - return text - } + const roleElements = await handleRoleLocator(this.page, locator) + if (roleElements && roleElements.length > 0) { + const text = await roleElements[0].textContent() + assertElementExists(text, JSON.stringify(locator)) + this.debugSection('Text', text) + return text } const locatorObj = new Locator(locator, 'css') @@ -4195,25 +4191,22 @@ export function buildLocatorString(locator) { return locator.simplify() } -/** - * Checks if a locator is a role locator object (e.g., {role: 'button', text: 'Submit', exact: true}) - */ -function isRoleLocatorObject(locator) { - return locator && typeof locator === 'object' && locator.role && !locator.type -} - /** * Handles role locator objects by converting them to Playwright's getByRole() API + * Accepts both raw objects ({role: 'button', text: 'Submit'}) and Locator-wrapped role objects. * Returns elements array if role locator, null otherwise */ async function handleRoleLocator(context, locator) { - if (!isRoleLocatorObject(locator)) return null + const loc = new Locator(locator) + if (!loc.isRole()) return null + const roleObj = loc.locator || {} const options = {} - if (locator.text) options.name = locator.text - if (locator.exact !== undefined) options.exact = locator.exact + if (roleObj.text) options.name = roleObj.text + if (roleObj.name) options.name = roleObj.name + if (roleObj.exact !== undefined) options.exact = roleObj.exact - return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all() + return context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined).all() } async function findByRole(context, locator) { @@ -4282,16 +4275,22 @@ async function proceedClick(locator, context = null, options = {}) { assertElementExists(els, locator, 'Clickable element') } - await highlightActiveElement.call(this, els[0]) - if (store.debugMode) this.debugSection('Clicked', await elToString(els[0], 1)) + const opts = store.currentStep?.opts + let element + if (opts?.elementIndex != null) { + element = selectElement(els, locator, this) + } else { + const strict = (opts?.exact === false || opts?.strictMode === false) ? false : (this.options.strict || opts?.exact === true || opts?.strictMode === true) + if (strict) assertOnlyOneElement(els, locator, this) + element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0] + } + + await highlightActiveElement.call(this, element) + if (store.debugMode) this.debugSection('Clicked', await elToString(element, 1)) - /* - using the force true options itself but instead dispatching a click - */ if (options.force) { - await els[0].dispatchEvent('click') + await element.dispatchEvent('click') } else { - const element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0] await element.click(options) } const promises = [] @@ -4308,7 +4307,6 @@ async function findClickable(matcher, locator) { if (!matchedLocator.isFuzzy()) { const els = await findElements.call(this, matcher, matchedLocator) - if (this.options.strict) assertOnlyOneElement(els, locator, this) return els } @@ -4317,42 +4315,27 @@ async function findClickable(matcher, locator) { try { els = await matcher.getByRole('button', { name: matchedLocator.value }).all() - if (els.length) { - if (this.options.strict) assertOnlyOneElement(els, locator, this) - return els - } + if (els.length) return els } catch (err) { // getByRole not supported or failed } try { els = await matcher.getByRole('link', { name: matchedLocator.value }).all() - if (els.length) { - if (this.options.strict) assertOnlyOneElement(els, locator, this) - return els - } + 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) { - if (this.options.strict) assertOnlyOneElement(els, locator, this) - return els - } + if (els.length) return els els = await findElements.call(this, matcher, Locator.clickable.wide(literal)) - if (els.length) { - if (this.options.strict) assertOnlyOneElement(els, locator, this) - return els - } + if (els.length) return els try { els = await findElements.call(this, matcher, Locator.clickable.self(literal)) - if (els.length) { - if (this.options.strict) assertOnlyOneElement(els, locator, this) - return els - } + if (els.length) return els } catch (err) { // Do nothing } @@ -4437,12 +4420,9 @@ async function findFields(locator, context = null) { ? loc => findElements.call(this, contextEl, loc) : loc => this._locate(loc) - // Handle role locators with text/exact options - if (isRoleLocatorObject(locator)) { - const matcher = contextEl || (await this.page) - const roleElements = await handleRoleLocator(matcher, locator) - if (roleElements) return roleElements - } + const matcher = contextEl || (await this.page) + const roleElements = await handleRoleLocator(matcher, locator) + if (roleElements) return roleElements const matchedLocator = new Locator(locator) if (!matchedLocator.isFuzzy()) { diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index c9ea57806..4a220a948 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -43,6 +43,7 @@ import { dropFile } from './scripts/dropFile.js' import { dontSeeElementError, seeElementError, dontSeeElementInDOMError, seeElementInDOMError } from './errors/ElementAssertion.js' import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js' import WebElement from '../element/WebElement.js' +import { selectElement } from './extras/elementSelection.js' let puppeteer @@ -1008,13 +1009,13 @@ class Puppeteer extends Helper { */ async _locateElement(locator) { const context = await this.context - if (this.options.strict) { + const elementIndex = store.currentStep?.opts?.elementIndex + if (this.options.strict || elementIndex) { const elements = await findElements.call(this, context, locator) if (elements.length === 0) { throw new ElementNotFound(locator, 'Element', 'was not found') } - assertOnlyOneElement(elements, locator, this) - return elements[0] + return selectElement(elements, locator, this) } return findElement.call(this, context, locator) } @@ -1033,8 +1034,7 @@ class Puppeteer extends Helper { if (!els || els.length === 0) { throw new ElementNotFound(locator, 'Checkbox or radio') } - if (this.options.strict) assertOnlyOneElement(els, locator, this) - return els[0] + return selectElement(els, locator, this) } /** @@ -1593,8 +1593,7 @@ class Puppeteer extends Helper { async fillField(field, value, context = null) { const els = await findVisibleFields.call(this, field, context) assertElementExists(els, field, 'Field') - if (this.options.strict) assertOnlyOneElement(els, field, this) - const el = els[0] + const el = selectElement(els, field, this) const tag = await el.getProperty('tagName').then(el => el.jsonValue()) const editable = await el.getProperty('contenteditable').then(el => el.jsonValue()) if (tag === 'INPUT' || tag === 'TEXTAREA') { @@ -1624,10 +1623,10 @@ class Puppeteer extends Helper { async appendField(field, value, context = null) { const els = await findVisibleFields.call(this, field, context) assertElementExists(els, field, 'Field') - if (this.options.strict) assertOnlyOneElement(els, field, this) - highlightActiveElement.call(this, els[0], await this._getContext()) - await els[0].press('End') - await els[0].type(value.toString(), { delay: this.options.pressKeyDelay }) + const el = selectElement(els, field, this) + highlightActiveElement.call(this, el, await this._getContext()) + await el.press('End') + await el.type(value.toString(), { delay: this.options.pressKeyDelay }) return this._waitForAction() } @@ -1660,22 +1659,24 @@ class Puppeteer extends Helper { } const els = await findFields.call(this, locator, context) if (els.length) { - const tag = await els[0].evaluate(el => el.tagName) - const type = await els[0].evaluate(el => el.type) + const el = selectElement(els, locator, this) + const tag = await el.evaluate(el => el.tagName) + const type = await el.evaluate(el => el.type) if (tag === 'INPUT' && type === 'file') { - await els[0].uploadFile(file) + await el.uploadFile(file) return this._waitForAction() } } const targetEls = els.length ? els : await this._locate(locator) assertElementExists(targetEls, locator, 'Element') + const el = selectElement(targetEls, locator, this) const fileData = { base64Content: base64EncodeFile(file), fileName: path.basename(file), mimeType: getMimeType(path.basename(file)), } - await targetEls[0].evaluate(dropFile, fileData) + await el.evaluate(dropFile, fileData) return this._waitForAction() } @@ -1698,23 +1699,23 @@ class Puppeteer extends Helper { this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`) const els = contextEl ? await findElements.call(this, contextEl, select) : await this._locate(select) assertElementExists(els, select, 'Selectable element') - return proceedSelect.call(this, pageContext, els[0], option) + return proceedSelect.call(this, pageContext, selectElement(els, select, this), option) } // Fuzzy: try combobox this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`) const comboboxSearchCtx = contextEl || pageContext let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value }) - if (els?.length) return proceedSelect.call(this, pageContext, els[0], option) + if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option) // Fuzzy: try listbox els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value }) - if (els?.length) return proceedSelect.call(this, pageContext, els[0], option) + if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option) // Fuzzy: try native select const visibleEls = await findVisibleFields.call(this, select, context) assertElementExists(visibleEls, select, 'Selectable field') - return proceedSelect.call(this, pageContext, visibleEls[0], option) + return proceedSelect.call(this, pageContext, selectElement(visibleEls, select, this), option) } /** @@ -3121,11 +3122,11 @@ async function proceedClick(locator, context = null, options = {}) { } else { assertElementExists(els, locator, 'Clickable element') } - if (this.options.strict) assertOnlyOneElement(els, locator, this) + const el = selectElement(els, locator, this) - highlightActiveElement.call(this, els[0], await this._getContext()) + highlightActiveElement.call(this, el, await this._getContext()) - await els[0].click(options) + await el.click(options) const promises = [] if (options.waitForNavigation) { promises.push(this.waitForNavigation()) diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 74107ced8..8cffa0567 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -40,6 +40,7 @@ import { dontSeeElementError, seeElementError, seeElementInDOMError, dontSeeElem import { dropFile } from './scripts/dropFile.js' import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js' import WebElement from '../element/WebElement.js' +import { selectElement } from './extras/elementSelection.js' const SHADOW = 'shadow' const webRoot = 'body' @@ -1093,8 +1094,7 @@ class WebDriver extends Helper { } else { assertElementExists(res, locator, 'Clickable element') } - if (this.options.strict) assertOnlyOneElement(res, locator, this) - const elem = usingFirstElement(res) + const elem = selectElement(res, locator, this) highlightActiveElement.call(this, elem) return this.browser[clickMethod](getElementId(elem)) } @@ -1113,8 +1113,7 @@ class WebDriver extends Helper { } else { assertElementExists(res, locator, 'Clickable element') } - if (this.options.strict) assertOnlyOneElement(res, locator, this) - const elem = usingFirstElement(res) + const elem = selectElement(res, locator, this) highlightActiveElement.call(this, elem) return this.executeScript(el => { @@ -1141,9 +1140,8 @@ class WebDriver extends Helper { } else { assertElementExists(res, locator, 'Clickable element') } - if (this.options.strict) assertOnlyOneElement(res, locator, this) - const elem = usingFirstElement(res) + const elem = selectElement(res, locator, this) highlightActiveElement.call(this, elem) return elem.doubleClick() } @@ -1162,9 +1160,8 @@ class WebDriver extends Helper { } else { assertElementExists(res, locator, 'Clickable element') } - if (this.options.strict) assertOnlyOneElement(res, locator, this) - const el = usingFirstElement(res) + const el = selectElement(res, locator, this) await el.moveTo() @@ -1279,8 +1276,7 @@ class WebDriver extends Helper { async fillField(field, value, context = null) { const res = await findFields.call(this, field, context) assertElementExists(res, field, 'Field') - if (this.options.strict) assertOnlyOneElement(res, field, this) - const elem = usingFirstElement(res) + const elem = selectElement(res, field, this) highlightActiveElement.call(this, elem) try { await elem.clearValue() @@ -1303,8 +1299,7 @@ class WebDriver extends Helper { async appendField(field, value, context = null) { const res = await findFields.call(this, field, context) assertElementExists(res, field, 'Field') - if (this.options.strict) assertOnlyOneElement(res, field, this) - const elem = usingFirstElement(res) + const elem = selectElement(res, field, this) highlightActiveElement.call(this, elem) return elem.addValue(value.toString()) } @@ -1316,8 +1311,7 @@ class WebDriver extends Helper { async clearField(field, context = null) { const res = await findFields.call(this, field, context) assertElementExists(res, field, 'Field') - if (this.options.strict) assertOnlyOneElement(res, field, this) - const elem = usingFirstElement(res) + const elem = selectElement(res, field, this) highlightActiveElement.call(this, elem) return elem.clearValue(getElementId(elem)) } @@ -1334,22 +1328,22 @@ class WebDriver extends Helper { this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`) const els = await locateFn(select) assertElementExists(els, select, 'Selectable element') - return proceedSelectOption.call(this, usingFirstElement(els), option) + return proceedSelectOption.call(this, selectElement(els, select, this), option) } // Fuzzy: try combobox this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`) let els = await this._locateByRole({ role: 'combobox', text: matchedLocator.value }) - if (els?.length) return proceedSelectOption.call(this, usingFirstElement(els), option) + if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option) // Fuzzy: try listbox els = await this._locateByRole({ role: 'listbox', text: matchedLocator.value }) - if (els?.length) return proceedSelectOption.call(this, usingFirstElement(els), option) + if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option) // Fuzzy: try native select const res = await findFields.call(this, select, context) assertElementExists(res, select, 'Selectable field') - return proceedSelectOption.call(this, usingFirstElement(res), option) + return proceedSelectOption.call(this, selectElement(res, select, this), option) } /** @@ -1367,7 +1361,7 @@ class WebDriver extends Helper { this.debug(`Uploading ${file}`) if (res.length) { - const el = usingFirstElement(res) + const el = selectElement(res, locator, this) const tag = await this.browser.execute(function (elem) { return elem.tagName }, el) const type = await this.browser.execute(function (elem) { return elem.type }, el) if (tag === 'INPUT' && type === 'file') { @@ -1385,7 +1379,7 @@ class WebDriver extends Helper { const targetRes = res.length ? res : await this._locate(locator) assertElementExists(targetRes, locator, 'Element') - const targetEl = usingFirstElement(targetRes) + const targetEl = selectElement(targetRes, locator, this) const fileData = { base64Content: base64EncodeFile(file), fileName: path.basename(file), @@ -1405,7 +1399,7 @@ class WebDriver extends Helper { const res = await findCheckable.call(this, field, locateFn) assertElementExists(res, field, 'Checkable') - const elem = usingFirstElement(res) + const elem = selectElement(res, field, this) const elementId = getElementId(elem) highlightActiveElement.call(this, elem) @@ -1426,7 +1420,7 @@ class WebDriver extends Helper { const res = await findCheckable.call(this, field, locateFn) assertElementExists(res, field, 'Checkable') - const elem = usingFirstElement(res) + const elem = selectElement(res, field, this) const elementId = getElementId(elem) highlightActiveElement.call(this, elem) @@ -3301,6 +3295,19 @@ function assertElementExists(res, locator, prefix, suffix) { } function usingFirstElement(els) { + const rawIndex = store.currentStep?.opts?.elementIndex + if (rawIndex != null && els.length > 1) { + let elementIndex = rawIndex + if (elementIndex === 'first') elementIndex = 1 + if (elementIndex === 'last') elementIndex = -1 + if (Number.isInteger(elementIndex) && elementIndex !== 0) { + const idx = elementIndex > 0 ? elementIndex - 1 : els.length + elementIndex + if (idx >= 0 && idx < els.length) { + debug(`[Elements] Using element #${rawIndex} out of ${els.length}`) + return els[idx] + } + } + } if (els.length > 1) debug(`[Elements] Using first element out of ${els.length}`) return els[0] } diff --git a/lib/helper/extras/elementSelection.js b/lib/helper/extras/elementSelection.js new file mode 100644 index 000000000..a69fdc936 --- /dev/null +++ b/lib/helper/extras/elementSelection.js @@ -0,0 +1,58 @@ +import store from '../../store.js' +import output from '../../output.js' +import WebElement from '../../element/WebElement.js' +import MultipleElementsFound from '../errors/MultipleElementsFound.js' + +function resolveElementIndex(value) { + if (value === 'first') return 1 + if (value === 'last') return -1 + return value +} + +function isStrictStep(opts, helper) { + if (opts?.exact === true || opts?.strictMode === true) return true + if (opts?.exact === false || opts?.strictMode === false) return false + return helper.options.strict +} + +function selectElement(els, locator, helper) { + const opts = store.currentStep?.opts + const rawIndex = opts?.elementIndex + const elementIndex = resolveElementIndex(rawIndex) + + if (elementIndex != null) { + if (els.length === 1) return els[0] + + if (!Number.isInteger(elementIndex) || elementIndex === 0) { + throw new Error(`elementIndex must be a non-zero integer or 'first'/'last', got: ${rawIndex}`) + } + + let idx + if (elementIndex > 0) { + idx = elementIndex - 1 + if (idx >= els.length) { + throw new Error(`elementIndex ${elementIndex} exceeds the number of elements found (${els.length}) for "${locator}"`) + } + } else { + idx = els.length + elementIndex + if (idx < 0) { + throw new Error(`elementIndex ${elementIndex} exceeds the number of elements found (${els.length}) for "${locator}"`) + } + } + + output.debug(`[Elements] Using element #${elementIndex} out of ${els.length}`) + return els[idx] + } + + if (isStrictStep(opts, helper)) { + if (els.length > 1) { + const webElements = Array.from(els).map(el => new WebElement(el, helper)) + throw new MultipleElementsFound(locator, webElements) + } + } + + if (els.length > 1) output.debug(`[Elements] Using first element out of ${els.length}`) + return els[0] +} + +export { selectElement } diff --git a/lib/step/config.js b/lib/step/config.js index 5bcae0da5..f56b4bee7 100644 --- a/lib/step/config.js +++ b/lib/step/config.js @@ -1,20 +1,33 @@ +/** + * @typedef {Object} StepOptions + * @property {number|'first'|'last'} [elementIndex] - Select a specific element when multiple match. 1-based positive index, negative from end, or 'first'/'last'. + * @property {boolean} [exact] - Enable strict mode for this step. Throws if multiple elements match. + * @property {boolean} [strictMode] - Alias for exact. + * @property {boolean} [ignoreCase] - Perform case-insensitive text matching. + */ + /** * StepConfig is a configuration object for a step. * It is used to create a new step that is a combination of other steps. */ class StepConfig { constructor(opts = {}) { - /** @member {{ opts: Record, timeout: number|undefined, retry: number|undefined }} */ + /** @member {{ opts: StepOptions, timeout: number|undefined, retry: number|undefined }} */ this.config = { opts, timeout: undefined, retry: undefined, } + this.__isStepConfig = true + } + + static isStepConfig(obj) { + return obj && (obj instanceof StepConfig || obj.__isStepConfig === true) } /** * Set the options for the step. - * @param {object} opts - The options for the step. + * @param {StepOptions} opts - The options for the step. * @returns {StepConfig} - The step configuration object. */ opts(opts) { diff --git a/lib/step/record.js b/lib/step/record.js index 3964eddda..38124850e 100644 --- a/lib/step/record.js +++ b/lib/step/record.js @@ -11,7 +11,7 @@ function recordStep(step, args) { // apply step configuration const lastArg = args[args.length - 1] - if (lastArg instanceof StepConfig) { + if (StepConfig.isStepConfig(lastArg)) { const stepConfig = args.pop() const { opts, timeout, retry } = stepConfig.getConfig() diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 9f7d8913e..ce6540823 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -2332,4 +2332,100 @@ export function tests() { expect(err.message).to.include('Use a more specific locator') }) }) + + describe('#elementIndex step option', () => { + afterEach(() => { + store.currentStep = null + I.options.strict = false + }) + + it('should click nth element with positive index', async () => { + await I.amOnPage('/info') + store.currentStep = { opts: { elementIndex: 2 } } + await I.click('#grab-multiple a') + }) + + it('should click last element with -1', async () => { + await I.amOnPage('/info') + store.currentStep = { opts: { elementIndex: -1 } } + await I.click('#grab-multiple a') + }) + + it('should support "first" alias', async () => { + await I.amOnPage('/info') + store.currentStep = { opts: { elementIndex: 'first' } } + await I.click('#grab-multiple a') + }) + + it('should support "last" alias', async () => { + await I.amOnPage('/info') + store.currentStep = { opts: { elementIndex: 'last' } } + await I.click('#grab-multiple a') + }) + + it('should ignore elementIndex when only one element found', async () => { + await I.amOnPage('/info') + store.currentStep = { opts: { elementIndex: 5 } } + await I.click('#first-link') + }) + + it('should skip strict mode when elementIndex is set', async () => { + await I.amOnPage('/info') + I.options.strict = true + store.currentStep = { opts: { elementIndex: 1 } } + await I.click('#grab-multiple a') + }) + + it('should throw if elementIndex out of bounds with multiple elements', async () => { + await I.amOnPage('/info') + store.currentStep = { opts: { elementIndex: 100 } } + let err + try { + await I.click('#grab-multiple a') + } catch (e) { + err = e + } + expect(err).to.exist + expect(err.message).to.include('elementIndex') + }) + + it('should enable strict mode per-step with exact: true', async () => { + await I.amOnPage('/info') + store.currentStep = { opts: { exact: true } } + let err + try { + await I.click('#grab-multiple a') + } catch (e) { + err = e + } + expect(err).to.exist + expect(err.constructor.name).to.equal('MultipleElementsFound') + }) + + it('should enable strict mode per-step with strictMode: true', async () => { + await I.amOnPage('/info') + store.currentStep = { opts: { strictMode: true } } + let err + try { + await I.click('#grab-multiple a') + } catch (e) { + err = e + } + expect(err).to.exist + expect(err.constructor.name).to.equal('MultipleElementsFound') + }) + + it('should not throw with exact: true when single element found', async () => { + await I.amOnPage('/info') + store.currentStep = { opts: { exact: true } } + await I.click('#first-link') + }) + + it('should cancel strict mode with exact: false', async () => { + await I.amOnPage('/info') + I.options.strict = true + store.currentStep = { opts: { exact: false } } + await I.click('#grab-multiple a') + }) + }) } diff --git a/typings/index.d.ts b/typings/index.d.ts index 09c1bb116..e06a8620e 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -745,3 +745,22 @@ declare module 'codeceptjs/effects' { export const retryTo: RetryTo export const hopeThat: HopeThat } + +declare module 'codeceptjs/steps' { + const step: { + opts(opts: CodeceptJS.StepOptions): CodeceptJS.StepConfig; + timeout(timeout: number): CodeceptJS.StepConfig; + retry(retry: number): CodeceptJS.StepConfig; + stepOpts(opts: CodeceptJS.StepOptions): CodeceptJS.StepConfig; + stepTimeout(timeout: number): CodeceptJS.StepConfig; + stepRetry(retry: number): CodeceptJS.StepConfig; + section(name: string): any; + endSection(): any; + Section(name: string): any; + EndSection(): any; + Given(): any; + When(): any; + Then(): any; + } + export default step +}