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
+}