From b0e82e2b94b7397c8690f1544d8d3e9acc6a45eb Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 29 Mar 2026 21:30:27 +0300 Subject: [PATCH 01/10] feat: add elementIndex step option for targeting specific elements When multiple elements match a locator, users can now specify which one to interact with using step.opts({ elementIndex }). Supports positive (1-based), negative (-1 = last), and 'first'/'last' aliases. Silently ignored when only one element matches. Overrides strict mode when set. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/strict.md | 101 ++++++++++++++++++++++++++ lib/helper/Playwright.js | 84 +++++++++------------ lib/helper/Puppeteer.js | 45 ++++++------ lib/helper/WebDriver.js | 51 +++++++------ lib/helper/extras/elementSelection.js | 51 +++++++++++++ test/helper/webapi.js | 57 +++++++++++++++ 6 files changed, 297 insertions(+), 92 deletions(-) create mode 100644 docs/strict.md create mode 100644 lib/helper/extras/elementSelection.js diff --git a/docs/strict.md b/docs/strict.md new file mode 100644 index 000000000..421bece05 --- /dev/null +++ b/docs/strict.md @@ -0,0 +1,101 @@ +--- +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 +``` + +When you do need to target one of multiple matches in strict mode, `elementIndex` overrides the 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 })) +``` + +Strict mode is supported in **Playwright**, **Puppeteer**, and **WebDriver** helpers. + +## 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/helper/Playwright.js b/lib/helper/Playwright.js index 034e5053e..ccd0ddebf 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) } /** @@ -4282,16 +4281,21 @@ 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 elementIndex = store.currentStep?.opts?.elementIndex + let element + if (elementIndex != null) { + element = selectElement(els, locator, this) + } else { + if (this.options.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 +4312,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 +4320,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 } 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..7fd4b4e64 --- /dev/null +++ b/lib/helper/extras/elementSelection.js @@ -0,0 +1,51 @@ +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 selectElement(els, locator, helper) { + const rawIndex = store.currentStep?.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 (helper.options.strict) { + if (els.length > 1) { + const webElements = 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/test/helper/webapi.js b/test/helper/webapi.js index 9f7d8913e..801b92956 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -2332,4 +2332,61 @@ 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') + }) + }) } From ae063e6cba6b433681c073e4b31189e8b8b1b824 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 29 Mar 2026 21:38:41 +0300 Subject: [PATCH 02/10] feat: add TypeScript types for step options and codeceptjs/steps module Add StepOptions typedef with elementIndex and ignoreCase in JSDoc. Add declare module for 'codeceptjs/steps' for IDE autocompletion. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/step/config.js | 10 ++++++++-- typings/index.d.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/step/config.js b/lib/step/config.js index 5bcae0da5..2c132b8f2 100644 --- a/lib/step/config.js +++ b/lib/step/config.js @@ -1,10 +1,16 @@ +/** + * @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} [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, @@ -14,7 +20,7 @@ class StepConfig { /** * 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/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 +} From e8b64f28b01c25165fed5a4ef2ec5a30af79e439 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 29 Mar 2026 21:45:18 +0300 Subject: [PATCH 03/10] fix: use absolute XPath with // prefix in MultipleElementsFound error toAbsoluteXPath() now returns //html/... instead of /html/... to match standard absolute XPath notation. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/element/WebElement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From 8b16fe8881bc57be53218bc5cdc788340d6e6e7b Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 29 Mar 2026 21:50:31 +0300 Subject: [PATCH 04/10] docs: rename strict.md to element-selection.md Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/{strict.md => element-selection.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{strict.md => element-selection.md} (100%) diff --git a/docs/strict.md b/docs/element-selection.md similarity index 100% rename from docs/strict.md rename to docs/element-selection.md From 2b8ea08ce432a51a168364305e0c885d442e32e1 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 30 Mar 2026 01:21:05 +0300 Subject: [PATCH 05/10] fix: use duck-typing for StepConfig detection instead of instanceof instanceof fails when StepConfig is loaded from different module paths (e.g., symlinked packages). Add __isStepConfig marker and static isStepConfig() method for reliable detection across module boundaries. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/els.js | 2 +- lib/step/config.js | 5 +++++ lib/step/record.js | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) 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/step/config.js b/lib/step/config.js index 2c132b8f2..b8abd6251 100644 --- a/lib/step/config.js +++ b/lib/step/config.js @@ -16,6 +16,11 @@ class StepConfig { timeout: undefined, retry: undefined, } + this.__isStepConfig = true + } + + static isStepConfig(obj) { + return obj && (obj instanceof StepConfig || obj.__isStepConfig === true) } /** 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() From 9116dd2d509dcc217bb7b1d018da8347f1bbfefb Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 30 Mar 2026 19:10:58 +0300 Subject: [PATCH 06/10] feat: add exact and strictMode step options for per-step strict mode Enable strict mode on individual steps without changing helper config: step.opts({ exact: true }) // Playwright-compatible naming step.opts({ strictMode: true }) // alias Throws MultipleElementsFound when multiple elements match, even with strict: false in helper config. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/helper/Playwright.js | 6 ++--- lib/helper/extras/elementSelection.js | 10 +++++++-- lib/step/config.js | 2 ++ test/helper/webapi.js | 32 +++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index ccd0ddebf..e49000c7c 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -4281,12 +4281,12 @@ async function proceedClick(locator, context = null, options = {}) { assertElementExists(els, locator, 'Clickable element') } - const elementIndex = store.currentStep?.opts?.elementIndex + const opts = store.currentStep?.opts let element - if (elementIndex != null) { + if (opts?.elementIndex != null) { element = selectElement(els, locator, this) } else { - if (this.options.strict) assertOnlyOneElement(els, locator, this) + if (this.options.strict || opts?.exact === true || opts?.strictMode === true) assertOnlyOneElement(els, locator, this) element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0] } diff --git a/lib/helper/extras/elementSelection.js b/lib/helper/extras/elementSelection.js index 7fd4b4e64..9f6745804 100644 --- a/lib/helper/extras/elementSelection.js +++ b/lib/helper/extras/elementSelection.js @@ -9,8 +9,14 @@ function resolveElementIndex(value) { return value } +function isStrictStep(opts, helper) { + if (opts?.exact === true || opts?.strictMode === true) return true + return helper.options.strict +} + function selectElement(els, locator, helper) { - const rawIndex = store.currentStep?.opts?.elementIndex + const opts = store.currentStep?.opts + const rawIndex = opts?.elementIndex const elementIndex = resolveElementIndex(rawIndex) if (elementIndex != null) { @@ -37,7 +43,7 @@ function selectElement(els, locator, helper) { return els[idx] } - if (helper.options.strict) { + if (isStrictStep(opts, helper)) { if (els.length > 1) { const webElements = els.map(el => new WebElement(el, helper)) throw new MultipleElementsFound(locator, webElements) diff --git a/lib/step/config.js b/lib/step/config.js index b8abd6251..f56b4bee7 100644 --- a/lib/step/config.js +++ b/lib/step/config.js @@ -1,6 +1,8 @@ /** * @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. */ diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 801b92956..e246eaec5 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -2388,5 +2388,37 @@ export function tests() { 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') + }) }) } From 59c7b25327436c1fef1c9e5217cac5609b67adb8 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 30 Mar 2026 19:12:19 +0300 Subject: [PATCH 07/10] feat: exact: false cancels strict mode per-step When helper has strict: true, step.opts({ exact: false }) overrides it for that step, allowing multiple element matches without error. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/helper/Playwright.js | 3 ++- lib/helper/extras/elementSelection.js | 1 + test/helper/webapi.js | 7 +++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index e49000c7c..982f687a1 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -4286,7 +4286,8 @@ async function proceedClick(locator, context = null, options = {}) { if (opts?.elementIndex != null) { element = selectElement(els, locator, this) } else { - if (this.options.strict || opts?.exact === true || opts?.strictMode === true) assertOnlyOneElement(els, locator, this) + 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] } diff --git a/lib/helper/extras/elementSelection.js b/lib/helper/extras/elementSelection.js index 9f6745804..3dbb9b4dc 100644 --- a/lib/helper/extras/elementSelection.js +++ b/lib/helper/extras/elementSelection.js @@ -11,6 +11,7 @@ function resolveElementIndex(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 } diff --git a/test/helper/webapi.js b/test/helper/webapi.js index e246eaec5..ce6540823 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -2420,5 +2420,12 @@ export function tests() { 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') + }) }) } From c3097db26b81734846e444395bd83e910b9cf263 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 30 Mar 2026 21:01:04 +0300 Subject: [PATCH 08/10] fix: role locators now use getByRole() when wrapped in Locator object handleRoleLocator used isRoleLocatorObject() which rejected Locator-wrapped role objects (checking !locator.type). This caused findClickable to fall through to a CSS [role="button"] selector, losing text/exact filters. Now uses new Locator(locator).isRole() to detect role locators regardless of whether they arrive as raw objects or Locator instances, ensuring Playwright's native getByRole() API is always used. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/helper/Playwright.js | 43 ++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 982f687a1..01dfbda10 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -2700,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') @@ -4194,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) { @@ -4426,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()) { From 6e4f7c5529f4bc317b25851df21cd2d5482ce897 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 30 Mar 2026 21:19:31 +0300 Subject: [PATCH 09/10] fix: use Array.from() for WebDriver element collections in selectElement WebDriver returns element collections that aren't plain arrays, so .map() may not work correctly. Matches the pattern used in WebDriver's own assertOnlyOneElement. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/helper/extras/elementSelection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/helper/extras/elementSelection.js b/lib/helper/extras/elementSelection.js index 3dbb9b4dc..a69fdc936 100644 --- a/lib/helper/extras/elementSelection.js +++ b/lib/helper/extras/elementSelection.js @@ -46,7 +46,7 @@ function selectElement(els, locator, helper) { if (isStrictStep(opts, helper)) { if (els.length > 1) { - const webElements = els.map(el => new WebElement(el, helper)) + const webElements = Array.from(els).map(el => new WebElement(el, helper)) throw new MultipleElementsFound(locator, webElements) } } From 6f14a47b384ce8ed68556f1ff0c3b7924a93f6ae Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 30 Mar 2026 21:38:31 +0300 Subject: [PATCH 10/10] docs: add exact/strictMode per-step options to element selection guide Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/element-selection.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/element-selection.md b/docs/element-selection.md index 421bece05..f8441b17c 100644 --- a/docs/element-selection.md +++ b/docs/element-selection.md @@ -82,15 +82,39 @@ When a test fails in strict mode, the error includes a `fetchDetails()` method t // Use a more specific locator or grabWebElements() to work with multiple elements ``` -When you do need to target one of multiple matches in strict mode, `elementIndex` overrides the check — no error is thrown because you've explicitly chosen which element to use: +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 })) ``` -Strict mode is supported in **Playwright**, **Puppeteer**, and **WebDriver** helpers. - ## Summary | Situation | Approach |