Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions docs/element-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,28 @@ 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:
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. Chain `first()`, `last()` or `at()` to the action:

```js
I.click('a').first()

// click the last link
I.click('a').last()

// click the 2nd link
I.click('a').at(2)

// fill the second-to-last matching input
I.fillField('.email-input', 'test@example.com').at(-2)
```

Chain the selection directly to the action, in the same expression. `await I.click('a').last()` works too. Calling it after the step has run throws an error.

These methods exist on helper actions only. They are not available on custom steps and page object methods.

They differ from `locate('a').first()`, which builds a position into the locator. A chained selection picks from whatever the action matched, including fuzzy matches like `I.click('Submit').last()`.

The same selection is available as a step option:

```js
import step from 'codeceptjs/steps'
Expand Down Expand Up @@ -108,10 +129,12 @@ It works the other way too. If your helper has `strict: true` globally but you n
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:
And when you know there are multiple matches and want a specific one, `first()`, `last()`, `at()` and `elementIndex` override 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
// strict: true in config, but these work without error
I.click('a').first()
I.click('a').at(2)
I.click('a', step.opts({ elementIndex: 2 }))
```

Expand All @@ -120,6 +143,6 @@ I.click('a', step.opts({ elementIndex: 2 }))
| 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 need a specific element from a known list | Chain `.first()`, `.last()` or `.at(N)` to the action |
| 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 |
12 changes: 5 additions & 7 deletions docs/locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,17 +158,15 @@ I.tap('~submit') // mobile

## Picking a specific element

When a locator matches several elements on the page, CodeceptJS acts on the first one by default. To target a different match, pass `elementIndex` via `step.opts()`:
When a locator matches several elements on the page, CodeceptJS acts on the first one by default. To target a different match, chain `first()`, `last()` or `at()` to the action:

```js
import step from 'codeceptjs/steps'

I.click('a', step.opts({ elementIndex: 2 })) // the 2nd link
I.click('a', step.opts({ elementIndex: 'last' })) // the last link
I.fillField('.email-input', 'u@t.com', step.opts({ elementIndex: -1 }))
I.click('a').at(2) // the 2nd link
I.click('a').last() // the last link
I.fillField('.email-input', 'u@t.com').at(-1)
```

`elementIndex` accepts positive numbers (1-based), negative numbers (`-1` is last), or the aliases `'first'` and `'last'`. It works with `click`, `fillField`, `selectOption`, `checkOption`, and other single-element actions.
`at()` accepts positive numbers (1-based) and negative numbers (`-1` is last). It works with `click`, `fillField`, `selectOption`, `checkOption`, and other single-element actions. The same selection is available as `step.opts({ elementIndex: 2 })`.

To catch ambiguous locators during development rather than silently using the first match, enable `strict: true` in the helper config, or pass `step.opts({ exact: true })` on a single step:

Expand Down
8 changes: 5 additions & 3 deletions docs/migration-4.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,11 +648,13 @@ The error includes a `fetchDetails()` method that prints XPaths and HTML for eve
Pick a specific match without writing a more specific locator:

```js
I.click('a', step.opts({ elementIndex: 2 }))
I.click('a', step.opts({ elementIndex: 'last' }))
I.fillField('input', 'x', step.opts({ elementIndex: -1 }))
I.click('a').at(2)
I.click('a').last()
I.fillField('input', 'x').first()
```

`step.opts({ elementIndex: 2 })` does the same.

### Unfocused Element Detection

`I.type()` and `I.pressKey()` throw `NonFocusedType` if no element has focus. Click or focus the field first.
Expand Down
2 changes: 1 addition & 1 deletion docs/webapi/appendField.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ I.appendField('name', 'John', '.form-container');
@param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator
@param {string} value text value to append.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/attachFile.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ I.attachFile('#dropzone', 'data/avatar.jpg');
@param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
@param {string} pathToFile local file path relative to codecept.conf.ts or codecept.conf.js config file.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/blur.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ I.dontSee('#add-to-cart-btn');

@param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
@param {any} [options] Playwright only: [Additional options](https://playwright.dev/docs/api/class-locator#locator-blur) for available options object as 2nd argument.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/checkOption.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ I.checkOption('agree', '//form');
```
@param {CodeceptJS.LocatorOrString} field checkbox located by label | name | CSS | XPath | strict locator.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/clearField.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ I.clearField('Email', '.form-container');
```
@param {LocatorOrString} editable field located by label|name|CSS|XPath|strict locator.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
@returns {void} automatically synchronized promise through #recorder.
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder.
2 changes: 1 addition & 1 deletion docs/webapi/click.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ I.click({role: 'button', name: 'Submit'});

@param {CodeceptJS.LocatorOrString} [locator='//body'] (optional, `'//body'` by default) clickable link or button located by text, or any element located by CSS|XPath|strict locator.
@param {?CodeceptJS.LocatorOrString | null} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/clickLink.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ I.clickLink('Logout', '#nav');
```
@param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/doubleClick.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ I.doubleClick('.btn.edit');

@param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/dragAndDrop.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ I.dragAndDrop('#dragHandle', '#container');

@param {LocatorOrString} srcElement located by CSS|XPath|strict locator.
@param {LocatorOrString} destElement located by CSS|XPath|strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/dragSlider.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ I.dragSlider('#slider', -70);

@param {CodeceptJS.LocatorOrString} locator located by label|name|CSS|XPath|strict locator.
@param {number} offsetX position to drag.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/fillField.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ I.fillField('Name', 'John', '#section2');
@param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator.
@param {CodeceptJS.StringOrSecret} value text value to fill.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/focus.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ I.see('#add-to-cart-bnt');

@param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
@param {any} [options] Playwright only: [Additional options](https://playwright.dev/docs/api/class-locator#locator-focus) for available options object as 2nd argument.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/forceClick.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ I.forceClick({css: 'nav a.login'});

@param {CodeceptJS.LocatorOrString} locator clickable link or button located by text, or any element located by CSS|XPath|strict locator.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element to search in CSS|XPath|Strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/moveCursorTo.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ I.moveCursorTo('#submit', '.container');
@param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator.
@param {number|CodeceptJS.LocatorOrString} [offsetX=0] (optional, `0` by default) X-axis offset or context locator.
@param {number} [offsetY=0] (optional, `0` by default) Y-axis offset.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/rightClick.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ I.rightClick('Click me', '.context');

@param {CodeceptJS.LocatorOrString} locator clickable element located by CSS|XPath|strict locator.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS|XPath|strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/saveElementScreenshot.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ I.saveElementScreenshot(`#submit`,'debug.png');

@param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
@param {string} fileName file name to save.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/scrollTo.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ I.scrollTo('#submit', 5, 5);
@param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator.
@param {number} [offsetX=0] (optional, `0` by default) X-axis offset.
@param {number} [offsetY=0] (optional, `0` by default) Y-axis offset.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/selectOption.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ I.selectOption('Which OS do you use?', ['Android', 'iOS']);
@param {LocatorOrString} select field located by label|name|CSS|XPath|strict locator.
@param {string|Array<*>} option visible text or value of option.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
2 changes: 1 addition & 1 deletion docs/webapi/uncheckOption.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ I.uncheckOption('agree', '//form');
```
@param {CodeceptJS.LocatorOrString} field checkbox located by label | name | CSS | XPath | strict locator.
@param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
@returns {void} automatically synchronized promise through #recorder
@returns {CodeceptJS.StepPromise} automatically synchronized promise through #recorder
17 changes: 16 additions & 1 deletion lib/actor.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ class Actor {
}
}

function addElementSelection(promise, step) {
const selectElement = elementIndex => {
if (step.status !== 'queued') {
throw new Error(`Can't select an element for "${step.title}": the step was already executed. Chain it to the step call: I.${step.title}(...).first()`)
}
step.opts = { ...step.opts, elementIndex }
return promise
}

promise.first = () => selectElement('first')
promise.last = () => selectElement('last')
promise.at = position => selectElement(position)
return promise
}

/**
* Fetches all methods from all enabled helpers,
* and makes them available to use from I. object
Expand Down Expand Up @@ -63,7 +78,7 @@ export default function (obj = {}, container) {
step.actor = translation.I
}
// add methods to promise chain
return recordStep(step, Array.from(arguments))
return addElementSelection(recordStep(step, Array.from(arguments)), step)
}
}
})
Expand Down
2 changes: 1 addition & 1 deletion lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -2140,7 +2140,7 @@ class Playwright extends Helper {
* @param {CodeceptJS.LocatorOrString|number} locator Element to click on or X coordinate if no element.
* @param {number} [x] X coordinate relative to element, or Y coordinate if locator is a number.
* @param {number} [y] Y coordinate relative to element.
* @returns {Promise<void>}
* @returns {CodeceptJS.StepPromise}
*/
async clickXY(locator, x, y) {
// If locator is a number, treat it as global X coordinate
Expand Down
2 changes: 1 addition & 1 deletion lib/helper/Puppeteer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1299,7 +1299,7 @@ class Puppeteer extends Helper {
} else {
assertElementExists(els, locator, 'Clickable element')
}
const elem = els[0]
const elem = selectElement(els, locator, this)
return this.executeScript(el => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
Expand Down
103 changes: 103 additions & 0 deletions test/acceptance/element_selection_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from 'assert'
import { container, event } from '../../lib/index.js'
import { tryTo } from '../../lib/effects.js'

Feature('element selection chain')

function setStrict(value) {
Object.values(container.helpers()).forEach(helper => {
if (helper.options) helper.options.strict = value
})
}

Before(() => setStrict(true))
After(() => setStrict(false))

const allHelpers = '@Playwright @Puppeteer @WebDriverIO'
const browserOnly = '@Playwright @Puppeteer'
const strictPage = '/form/strict_mode'
const buttons = '.duplicate-btn'
const fields = 'input[name="duplicate-field"]'
const checkboxes = 'input[name="terms"]'

function seeOnlyFieldFilled(I, position) {
for (const current of [1, 2, 3]) {
const field = { css: `${fields}:nth-of-type(${current})` }
if (current === position) I.seeInField(field, 'chained')
else I.dontSeeInField(field, 'chained')
}
}

const actions = [
['click', allHelpers, strictPage, I => I.click(buttons)],
['forceClick', allHelpers, strictPage, I => I.forceClick(buttons)],
['doubleClick', allHelpers, strictPage, I => I.doubleClick(buttons)],
['rightClick', allHelpers, strictPage, I => I.rightClick(buttons)],
['fillField', allHelpers, strictPage, I => I.fillField(fields, 'value')],
['appendField', allHelpers, strictPage, I => I.appendField(fields, 'value')],
['clearField', allHelpers, strictPage, I => I.clearField(fields)],
['checkOption', allHelpers, strictPage, I => I.checkOption(checkboxes)],
['uncheckOption', allHelpers, strictPage, I => I.uncheckOption(checkboxes)],
['selectOption', allHelpers, '/form/field_values', I => I.selectOption('select', 'not seen two')],
['attachFile', allHelpers, '/form/file', I => I.attachFile('input[type="file"]', '../data/app/avatar.jpg')],
['moveCursorTo', browserOnly, strictPage, I => I.moveCursorTo(buttons)],
['focus', browserOnly, strictPage, I => I.focus(fields)],
['blur', browserOnly, strictPage, I => I.blur(fields)],
['scrollTo', browserOnly, strictPage, I => I.scrollTo(buttons)],
['saveElementScreenshot', '@Playwright', strictPage, I => I.saveElementScreenshot(buttons, 'element_selection.png')],
['clickXY', '@Playwright', strictPage, I => I.clickXY(buttons, 1, 1)],
['clickLink', '@Puppeteer', '/info', I => I.clickLink('a[href]')],
]

for (const [name, helpers, page, action] of actions) {
Scenario(`${name} requires a selection in strict mode ${helpers}`, async ({ I }) => {
I.amOnPage(page)
let error
event.dispatcher.once(event.step.failed, (step, err) => (error = err))
await tryTo(() => action(I))
assert.equal(error?.constructor.name, 'MultipleElementsFound')
})

Scenario(`${name} works with first() in strict mode ${helpers}`, async ({ I }) => {
I.amOnPage(page)
await action(I).first()
})

Scenario(`${name} reads the selected position ${helpers}`, async ({ I }) => {
I.amOnPage(page)
let error
event.dispatcher.once(event.step.failed, (step, err) => (error = err))
await tryTo(() => action(I).at(100))
assert.match(error?.message || '', /exceeds the number of elements/)
})
}

Scenario(`first() selects the first match ${allHelpers}`, async ({ I }) => {
I.amOnPage(strictPage)
await I.fillField(fields, 'chained').first()
seeOnlyFieldFilled(I, 1)
})

Scenario(`last() selects the last match ${allHelpers}`, async ({ I }) => {
I.amOnPage(strictPage)
await I.fillField(fields, 'chained').last()
seeOnlyFieldFilled(I, 3)
})

Scenario(`at() selects a match by position ${allHelpers}`, async ({ I }) => {
I.amOnPage(strictPage)
await I.fillField(fields, 'chained').at(2)
seeOnlyFieldFilled(I, 2)
})

Scenario(`at() counts from the end with a negative position ${allHelpers}`, async ({ I }) => {
I.amOnPage(strictPage)
await I.fillField(fields, 'chained').at(-2)
seeOnlyFieldFilled(I, 2)
})

Scenario(`selection works without await ${allHelpers}`, ({ I }) => {
I.amOnPage(strictPage)
I.fillField(fields, 'chained').last()
seeOnlyFieldFilled(I, 3)
})
Loading
Loading