From bffd03797a3b6dcb15536da05f185224f0751c1b Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 6 Nov 2026 15:42:43 +0200 Subject: [PATCH 1/2] feat(actor): chain first(), last() and at() on helper actions I.click('a').first() selects a match without step.opts(), and overrides strict mode like elementIndex does. Puppeteer forceClick now honours strict mode and the selected index, matching the other helpers. Co-Authored-By: Claude Opus 5.5 --- docs/element-selection.md | 31 ++++++- docs/locators.md | 12 ++- docs/migration-4.md | 8 +- docs/webapi/appendField.mustache | 2 +- docs/webapi/attachFile.mustache | 2 +- docs/webapi/blur.mustache | 2 +- docs/webapi/checkOption.mustache | 2 +- docs/webapi/clearField.mustache | 2 +- docs/webapi/click.mustache | 2 +- docs/webapi/clickLink.mustache | 2 +- docs/webapi/doubleClick.mustache | 2 +- docs/webapi/dragAndDrop.mustache | 2 +- docs/webapi/dragSlider.mustache | 2 +- docs/webapi/fillField.mustache | 2 +- docs/webapi/focus.mustache | 2 +- docs/webapi/forceClick.mustache | 2 +- docs/webapi/moveCursorTo.mustache | 2 +- docs/webapi/rightClick.mustache | 2 +- docs/webapi/saveElementScreenshot.mustache | 2 +- docs/webapi/scrollTo.mustache | 2 +- docs/webapi/selectOption.mustache | 2 +- docs/webapi/uncheckOption.mustache | 2 +- lib/actor.js | 17 +++- lib/helper/Playwright.js | 2 +- lib/helper/Puppeteer.js | 2 +- test/acceptance/element_selection_test.js | 99 +++++++++++++++++++++ test/unit/actor_test.js | 48 ++++++++++ typings/index.d.ts | 6 ++ typings/tests/helpers/Appium.types.ts | 10 +-- typings/tests/helpers/AppiumTs.types.ts | 10 +-- typings/tests/helpers/Playwright.types.ts | 46 +++++----- typings/tests/helpers/PlaywrightTs.types.ts | 46 +++++----- typings/tests/helpers/WebDriverIO.types.ts | 38 ++++---- 33 files changed, 306 insertions(+), 107 deletions(-) create mode 100644 test/acceptance/element_selection_test.js diff --git a/docs/element-selection.md b/docs/element-selection.md index f8441b17c..bfaebda98 100644 --- a/docs/element-selection.md +++ b/docs/element-selection.md @@ -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' @@ -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 })) ``` @@ -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 | diff --git a/docs/locators.md b/docs/locators.md index 4fd3ea746..6fb586345 100644 --- a/docs/locators.md +++ b/docs/locators.md @@ -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: diff --git a/docs/migration-4.md b/docs/migration-4.md index eba44b583..849c68e81 100644 --- a/docs/migration-4.md +++ b/docs/migration-4.md @@ -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. diff --git a/docs/webapi/appendField.mustache b/docs/webapi/appendField.mustache index 8daf7b7e2..b5f8e1455 100644 --- a/docs/webapi/appendField.mustache +++ b/docs/webapi/appendField.mustache @@ -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 diff --git a/docs/webapi/attachFile.mustache b/docs/webapi/attachFile.mustache index 691b57f22..e1552ae33 100644 --- a/docs/webapi/attachFile.mustache +++ b/docs/webapi/attachFile.mustache @@ -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 diff --git a/docs/webapi/blur.mustache b/docs/webapi/blur.mustache index e10e9b4e3..ae98f2d97 100644 --- a/docs/webapi/blur.mustache +++ b/docs/webapi/blur.mustache @@ -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 diff --git a/docs/webapi/checkOption.mustache b/docs/webapi/checkOption.mustache index c7a4c4582..4d6426eeb 100644 --- a/docs/webapi/checkOption.mustache +++ b/docs/webapi/checkOption.mustache @@ -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 diff --git a/docs/webapi/clearField.mustache b/docs/webapi/clearField.mustache index fe0790120..89b0860e2 100644 --- a/docs/webapi/clearField.mustache +++ b/docs/webapi/clearField.mustache @@ -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. diff --git a/docs/webapi/click.mustache b/docs/webapi/click.mustache index e9fa5cfbf..fc4aa835e 100644 --- a/docs/webapi/click.mustache +++ b/docs/webapi/click.mustache @@ -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 diff --git a/docs/webapi/clickLink.mustache b/docs/webapi/clickLink.mustache index a1a6983ab..0760e8c94 100644 --- a/docs/webapi/clickLink.mustache +++ b/docs/webapi/clickLink.mustache @@ -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 diff --git a/docs/webapi/doubleClick.mustache b/docs/webapi/doubleClick.mustache index b615ed60d..97c23b484 100644 --- a/docs/webapi/doubleClick.mustache +++ b/docs/webapi/doubleClick.mustache @@ -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 diff --git a/docs/webapi/dragAndDrop.mustache b/docs/webapi/dragAndDrop.mustache index f3ccf888f..271bf3467 100644 --- a/docs/webapi/dragAndDrop.mustache +++ b/docs/webapi/dragAndDrop.mustache @@ -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 diff --git a/docs/webapi/dragSlider.mustache b/docs/webapi/dragSlider.mustache index 19030606f..ea6bf5e72 100644 --- a/docs/webapi/dragSlider.mustache +++ b/docs/webapi/dragSlider.mustache @@ -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 diff --git a/docs/webapi/fillField.mustache b/docs/webapi/fillField.mustache index 044a471c4..0ed09fe6b 100644 --- a/docs/webapi/fillField.mustache +++ b/docs/webapi/fillField.mustache @@ -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 diff --git a/docs/webapi/focus.mustache b/docs/webapi/focus.mustache index 3f0a2eeab..03c4965dd 100644 --- a/docs/webapi/focus.mustache +++ b/docs/webapi/focus.mustache @@ -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 diff --git a/docs/webapi/forceClick.mustache b/docs/webapi/forceClick.mustache index 83241f16f..ef7da52bb 100644 --- a/docs/webapi/forceClick.mustache +++ b/docs/webapi/forceClick.mustache @@ -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 diff --git a/docs/webapi/moveCursorTo.mustache b/docs/webapi/moveCursorTo.mustache index b5d212669..04d4b6bd9 100644 --- a/docs/webapi/moveCursorTo.mustache +++ b/docs/webapi/moveCursorTo.mustache @@ -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 diff --git a/docs/webapi/rightClick.mustache b/docs/webapi/rightClick.mustache index 975c9f0d3..78491a8d9 100644 --- a/docs/webapi/rightClick.mustache +++ b/docs/webapi/rightClick.mustache @@ -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 diff --git a/docs/webapi/saveElementScreenshot.mustache b/docs/webapi/saveElementScreenshot.mustache index 096dda455..3f704079b 100644 --- a/docs/webapi/saveElementScreenshot.mustache +++ b/docs/webapi/saveElementScreenshot.mustache @@ -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 diff --git a/docs/webapi/scrollTo.mustache b/docs/webapi/scrollTo.mustache index 13ef3fa51..e7b6fb59f 100644 --- a/docs/webapi/scrollTo.mustache +++ b/docs/webapi/scrollTo.mustache @@ -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 diff --git a/docs/webapi/selectOption.mustache b/docs/webapi/selectOption.mustache index 2dff7d2ec..d5c153dbd 100644 --- a/docs/webapi/selectOption.mustache +++ b/docs/webapi/selectOption.mustache @@ -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 diff --git a/docs/webapi/uncheckOption.mustache b/docs/webapi/uncheckOption.mustache index 69d5c98ae..7307222c5 100644 --- a/docs/webapi/uncheckOption.mustache +++ b/docs/webapi/uncheckOption.mustache @@ -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 diff --git a/lib/actor.js b/lib/actor.js index f16967d36..d72b2aca9 100644 --- a/lib/actor.js +++ b/lib/actor.js @@ -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 @@ -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) } } }) diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 6cfcf420a..1b1d77aec 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -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} + * @returns {CodeceptJS.StepPromise} */ async clickXY(locator, x, y) { // If locator is a number, treat it as global X coordinate diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 7102f35da..f38e5ae48 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -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() diff --git a/test/acceptance/element_selection_test.js b/test/acceptance/element_selection_test.js new file mode 100644 index 000000000..376f33c30 --- /dev/null +++ b/test/acceptance/element_selection_test.js @@ -0,0 +1,99 @@ +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"]' + +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() + const values = await I.grabValueFromAll(fields) + assert.deepEqual(values, ['chained', '', '']) +}) + +Scenario(`last() selects the last match ${allHelpers}`, async ({ I }) => { + I.amOnPage(strictPage) + await I.fillField(fields, 'chained').last() + const values = await I.grabValueFromAll(fields) + assert.deepEqual(values, ['', '', 'chained']) +}) + +Scenario(`at() selects a match by position ${allHelpers}`, async ({ I }) => { + I.amOnPage(strictPage) + await I.fillField(fields, 'chained').at(2) + const values = await I.grabValueFromAll(fields) + assert.deepEqual(values, ['', 'chained', '']) +}) + +Scenario(`at() counts from the end with a negative position ${allHelpers}`, async ({ I }) => { + I.amOnPage(strictPage) + await I.fillField(fields, 'chained').at(-2) + const values = await I.grabValueFromAll(fields) + assert.deepEqual(values, ['', 'chained', '']) +}) + +Scenario(`selection works without await ${allHelpers}`, ({ I }) => { + I.amOnPage(strictPage) + I.fillField(fields, 'chained').last() + I.seeInField({ css: 'input[name="duplicate-field"]:last-of-type' }, 'chained') +}) diff --git a/test/unit/actor_test.js b/test/unit/actor_test.js index 76e8d17b2..727d307b7 100644 --- a/test/unit/actor_test.js +++ b/test/unit/actor_test.js @@ -191,4 +191,52 @@ describe('Actor', () => { expect(listeners).toEqual(3) }) }) + + describe('element selection chain', () => { + let startedOpts + + beforeEach(() => { + startedOpts = undefined + event.dispatcher.on(event.step.started, step => (startedOpts = step.opts)) + recorder.start() + }) + + it('should select first element and keep the step result', async () => { + const val = await I.hello().first() + expect(val).toEqual('hello world') + expect(startedOpts.elementIndex).toEqual('first') + }) + + it('should select last element', async () => { + await I.hello().last() + expect(startedOpts.elementIndex).toEqual('last') + }) + + it('should select element by position', async () => { + await I.hello().at(2) + expect(startedOpts.elementIndex).toEqual(2) + }) + + it('should keep options passed with step.opts', async () => { + await I.hello(step.opts({ exact: true })).last() + expect(startedOpts).toEqual({ exact: true, elementIndex: 'last' }) + }) + + it('should not change the options object shared between steps', async () => { + const config = step.opts({ exact: true }) + await I.hello(config).first() + expect(config.getConfig().opts).toEqual({ exact: true }) + }) + + it('should use the option of the last selection in the chain', async () => { + await I.hello().first().at(3) + expect(startedOpts.elementIndex).toEqual(3) + }) + + it('should fail when selection is applied after the step was executed', async () => { + const promise = I.hello() + await promise + expect(() => promise.first()).toThrow('already') + }) + }) }) diff --git a/typings/index.d.ts b/typings/index.d.ts index d4cb014d1..acfd5bee7 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -527,6 +527,12 @@ declare namespace CodeceptJS { type StringOrSecret = string | CodeceptJS.Secret + interface StepPromise extends Promise { + first(): StepPromise + last(): StepPromise + at(position: number): StepPromise + } + interface GherkinCallback { (test: Test): void | Promise } diff --git a/typings/tests/helpers/Appium.types.ts b/typings/tests/helpers/Appium.types.ts index de7581914..8df6d8db8 100644 --- a/typings/tests/helpers/Appium.types.ts +++ b/typings/tests/helpers/Appium.types.ts @@ -65,14 +65,14 @@ expectType>(appium.rotate()) expectType>(appium.setImmediateValue()) expectType>(appium.simulateTouchId()) expectType>(appium.closeApp()) -expectType(appium.appendField(str, str)) -expectType(appium.checkOption(str)) -expectType(appium.click(str)) +expectType(appium.appendField(str, str)) +expectType(appium.checkOption(str)) +expectType(appium.click(str)) expectType(appium.dontSeeCheckboxIsChecked(str)) expectType(appium.dontSeeElement(str)) expectType(appium.dontSeeInField(str, str)) expectType(appium.dontSee(str)) -expectType(appium.fillField(str, str)) +expectType(appium.fillField(str, str)) expectType>(appium.grabTextFromAll(str)) expectType>(appium.grabTextFrom(str)) expectType>(appium.grabNumberOfVisibleElements(str)) @@ -87,7 +87,7 @@ expectType(appium.seeCheckboxIsChecked(str)) expectType(appium.seeElement(str)) expectType(appium.seeInField(str, str)) expectType(appium.see(str)) -expectType(appium.selectOption(str, str)) +expectType(appium.selectOption(str, str)) expectType(appium.waitForElement(str)) expectType(appium.waitForVisible(str)) expectType(appium.waitForInvisible(str)) diff --git a/typings/tests/helpers/AppiumTs.types.ts b/typings/tests/helpers/AppiumTs.types.ts index de7581914..8df6d8db8 100644 --- a/typings/tests/helpers/AppiumTs.types.ts +++ b/typings/tests/helpers/AppiumTs.types.ts @@ -65,14 +65,14 @@ expectType>(appium.rotate()) expectType>(appium.setImmediateValue()) expectType>(appium.simulateTouchId()) expectType>(appium.closeApp()) -expectType(appium.appendField(str, str)) -expectType(appium.checkOption(str)) -expectType(appium.click(str)) +expectType(appium.appendField(str, str)) +expectType(appium.checkOption(str)) +expectType(appium.click(str)) expectType(appium.dontSeeCheckboxIsChecked(str)) expectType(appium.dontSeeElement(str)) expectType(appium.dontSeeInField(str, str)) expectType(appium.dontSee(str)) -expectType(appium.fillField(str, str)) +expectType(appium.fillField(str, str)) expectType>(appium.grabTextFromAll(str)) expectType>(appium.grabTextFrom(str)) expectType>(appium.grabNumberOfVisibleElements(str)) @@ -87,7 +87,7 @@ expectType(appium.seeCheckboxIsChecked(str)) expectType(appium.seeElement(str)) expectType(appium.seeInField(str, str)) expectType(appium.see(str)) -expectType(appium.selectOption(str, str)) +expectType(appium.selectOption(str, str)) expectType(appium.waitForElement(str)) expectType(appium.waitForVisible(str)) expectType(appium.waitForInvisible(str)) diff --git a/typings/tests/helpers/Playwright.types.ts b/typings/tests/helpers/Playwright.types.ts index c3ee53627..77fa27b20 100644 --- a/typings/tests/helpers/Playwright.types.ts +++ b/typings/tests/helpers/Playwright.types.ts @@ -24,14 +24,14 @@ expectType(playwright._createContextPage({})); expectType(playwright.amOnPage(str)); expectType(playwright.resizeWindow(num, num)); expectType(playwright.setPlaywrightRequestHeaders(str)); -expectType(playwright.moveCursorTo(str, num, num)); +expectType(playwright.moveCursorTo(str, num, num)); expectError(playwright.dragAndDrop(str)); -expectType(playwright.dragAndDrop(str, str)); -expectType(playwright.dragAndDrop(str, str, { sourcePosition, targetPosition })); +expectType(playwright.dragAndDrop(str, str)); +expectType(playwright.dragAndDrop(str, str, { sourcePosition, targetPosition })); expectType(playwright.refreshPage()); expectType(playwright.scrollPageToTop()); expectType(playwright.scrollPageToBottom()); -expectType(playwright.scrollTo(str, num, num)); +expectType(playwright.scrollTo(str, num, num)); expectType(playwright.seeInTitle(str)); //playwright.grabPageScrollPosition(); expectType(playwright.seeTitleEquals(str)); @@ -52,29 +52,29 @@ expectType(playwright.dontSeeElement(str)); expectType(playwright.seeElementInDOM(str)); expectType(playwright.dontSeeElementInDOM(str)); expectType>(playwright.handleDownloads(str)); -expectType(playwright.click(str)); -expectType(playwright.click(str, str)); -expectType(playwright.click(str, null, { position })); -expectType(playwright.forceClick(str)); -expectType(playwright.focus(str)); -expectType(playwright.blur(str)); -expectType(playwright.doubleClick(str)); -expectType(playwright.rightClick(str)); -expectType(playwright.checkOption(str)); -expectType(playwright.uncheckOption(str)); +expectType(playwright.click(str)); +expectType(playwright.click(str, str)); +expectType(playwright.click(str, null, { position })); +expectType(playwright.forceClick(str)); +expectType(playwright.focus(str)); +expectType(playwright.blur(str)); +expectType(playwright.doubleClick(str)); +expectType(playwright.rightClick(str)); +expectType(playwright.checkOption(str)); +expectType(playwright.uncheckOption(str)); expectType(playwright.seeCheckboxIsChecked(str)); expectType(playwright.dontSeeCheckboxIsChecked(str)); expectType(playwright.pressKeyDown(str)); expectType(playwright.pressKeyUp(str)); expectType(playwright.pressKey(str)); expectType(playwright.type(str)); -expectType(playwright.fillField(str, str)); -expectType(playwright.clearField(str)); -expectType(playwright.appendField(str, str)); +expectType(playwright.fillField(str, str)); +expectType(playwright.clearField(str)); +expectType(playwright.appendField(str, str)); expectType(playwright.seeInField(str, str)); expectType(playwright.dontSeeInField(str, str)); -expectType(playwright.attachFile(str, str)); -expectType(playwright.selectOption(str, str)); +expectType(playwright.attachFile(str, str)); +expectType(playwright.selectOption(str, str)); expectType>(playwright.grabNumberOfVisibleElements(str)); expectType(playwright.seeInCurrentUrl(str)); expectType(playwright.dontSeeInCurrentUrl(str)); @@ -106,10 +106,10 @@ expectType>(playwright.grabCssPropertyFrom(str, str)); expectType>(playwright.grabCssPropertyFromAll(str, str)); expectType(playwright.seeCssPropertiesOnElements(str, str)); expectType(playwright.seeAttributesOnElements(str, str)); -expectType(playwright.dragSlider(str, num)); +expectType(playwright.dragSlider(str, num)); expectType>(playwright.grabAttributeFrom(str, str)); expectType>(playwright.grabAttributeFromAll(str, str)); -expectType(playwright.saveElementScreenshot(str, str)); +expectType(playwright.saveElementScreenshot(str, str)); expectType(playwright.saveScreenshot(str)); expectType>(playwright.makeApiRequest(str, str, str)); expectType(playwright.wait(num)); @@ -148,3 +148,7 @@ expectType(playwright.dontSeeTraffic({ name: str, url: str })); expectType(playwright.dontSeeTraffic({ name: str, url: /hello/ })); expectError(playwright.dontSeeTraffic({ name: str })); expectError(playwright.dontSeeTraffic({ url: str })); + +expectType(playwright.click(str).first()) +expectType(playwright.fillField(str, str).last()) +expectType(playwright.checkOption(str).at(2)) diff --git a/typings/tests/helpers/PlaywrightTs.types.ts b/typings/tests/helpers/PlaywrightTs.types.ts index da631d33f..a2d1f7bc4 100644 --- a/typings/tests/helpers/PlaywrightTs.types.ts +++ b/typings/tests/helpers/PlaywrightTs.types.ts @@ -22,14 +22,14 @@ expectType>(playwright.grabPopupText()); expectType>(playwright.amOnPage(str)); expectType>(playwright.resizeWindow(num, num)); expectType>(playwright.setPlaywrightRequestHeaders(str)); -expectType>(playwright.moveCursorTo(str, num, num)); +expectType(playwright.moveCursorTo(str, num, num)); expectError(playwright.dragAndDrop(str)); -expectType>(playwright.dragAndDrop(str, str)); -expectType>(playwright.dragAndDrop(str, str, { sourcePosition, targetPosition })); +expectType(playwright.dragAndDrop(str, str)); +expectType(playwright.dragAndDrop(str, str, { sourcePosition, targetPosition })); expectType>(playwright.refreshPage()); expectType>(playwright.scrollPageToTop()); expectType>(playwright.scrollPageToBottom()); -expectType>(playwright.scrollTo(str, num, num)); +expectType(playwright.scrollTo(str, num, num)); expectType>(playwright.seeInTitle(str)); //expectType>(playwright.grabPageScrollPosition()); expectType>(playwright.seeTitleEquals(str)); @@ -50,29 +50,29 @@ expectType>(playwright.dontSeeElement(str)); expectType>(playwright.seeElementInDOM(str)); expectType>(playwright.dontSeeElementInDOM(str)); expectType>(playwright.handleDownloads(str)); -expectType>(playwright.click(str)); -expectType>(playwright.click(str, str)); -expectType>(playwright.click(str, null, { position })); -expectType>(playwright.forceClick(str)); -expectType>(playwright.focus(str)); -expectType>(playwright.blur(str)); -expectType>(playwright.doubleClick(str)); -expectType>(playwright.rightClick(str)); -expectType>(playwright.checkOption(str)); -expectType>(playwright.uncheckOption(str)); +expectType(playwright.click(str)); +expectType(playwright.click(str, str)); +expectType(playwright.click(str, null, { position })); +expectType(playwright.forceClick(str)); +expectType(playwright.focus(str)); +expectType(playwright.blur(str)); +expectType(playwright.doubleClick(str)); +expectType(playwright.rightClick(str)); +expectType(playwright.checkOption(str)); +expectType(playwright.uncheckOption(str)); expectType>(playwright.seeCheckboxIsChecked(str)); expectType>(playwright.dontSeeCheckboxIsChecked(str)); expectType>(playwright.pressKeyDown(str)); expectType>(playwright.pressKeyUp(str)); expectType>(playwright.pressKey(str)); expectType>(playwright.type(str)); -expectType>(playwright.fillField(str, str)); -expectType>(playwright.clearField(str)); -expectType>(playwright.appendField(str, str)); +expectType(playwright.fillField(str, str)); +expectType(playwright.clearField(str)); +expectType(playwright.appendField(str, str)); expectType>(playwright.seeInField(str, str)); expectType>(playwright.dontSeeInField(str, str)); -expectType>(playwright.attachFile(str, str)); -expectType>(playwright.selectOption(str, str)); +expectType(playwright.attachFile(str, str)); +expectType(playwright.selectOption(str, str)); expectType>(playwright.grabNumberOfVisibleElements(str)); expectType>(playwright.seeInCurrentUrl(str)); expectType>(playwright.dontSeeInCurrentUrl(str)); @@ -104,10 +104,10 @@ expectType>(playwright.grabCssPropertyFrom(str, str)); expectType>(playwright.grabCssPropertyFromAll(str, str)); expectType>(playwright.seeCssPropertiesOnElements(str, str)); expectType>(playwright.seeAttributesOnElements(str, str)); -expectType>(playwright.dragSlider(str, num)); +expectType(playwright.dragSlider(str, num)); expectType>(playwright.grabAttributeFrom(str, str)); expectType>(playwright.grabAttributeFromAll(str, str)); -expectType>(playwright.saveElementScreenshot(str, str)); +expectType(playwright.saveElementScreenshot(str, str)); expectType>(playwright.saveScreenshot(str)); expectType>(playwright.makeApiRequest(str, str, str)); expectType>(playwright.wait(num)); @@ -146,3 +146,7 @@ expectType>(playwright.dontSeeTraffic({ name: str, url: str })); expectType>(playwright.dontSeeTraffic({ name: str, url: /hello/ })); expectError(playwright.dontSeeTraffic({ name: str })); expectError(playwright.dontSeeTraffic({ url: str })); + +expectType(playwright.click(str).first()) +expectType(playwright.fillField(str, str).last()) +expectType(playwright.checkOption(str).at(2)) diff --git a/typings/tests/helpers/WebDriverIO.types.ts b/typings/tests/helpers/WebDriverIO.types.ts index de7de4783..b752be652 100644 --- a/typings/tests/helpers/WebDriverIO.types.ts +++ b/typings/tests/helpers/WebDriverIO.types.ts @@ -10,7 +10,7 @@ expectError(wd.amOnPage()) expectType(wd.amOnPage('')) expectError(wd.focus()) -expectType(wd.focus('div')) +expectType(wd.focus('div')) wd.focus({ css: 'div' }) wd.focus({ xpath: '//div' }) wd.focus({ name: 'div' }) @@ -30,7 +30,7 @@ wd.focus('div', { android: '//div' }) wd.focus('div', { ios: '//div' }) expectError(wd.blur()) -expectType(wd.blur('div')) +expectType(wd.blur('div')) wd.blur({ css: 'div' }) wd.blur({ xpath: '//div' }) wd.blur({ name: 'div' }) @@ -49,8 +49,8 @@ wd.blur('div', { id: '//div' }) wd.blur('div', { android: '//div' }) wd.blur('div', { ios: '//div' }) -expectType(wd.click()) -expectType(wd.click('div')) +expectType(wd.click()) +expectType(wd.click('div')) wd.click({ css: 'div' }) wd.click({ xpath: '//div' }) wd.click({ name: 'div' }) @@ -70,7 +70,7 @@ wd.click('div', { android: '//div' }) wd.click('div', { ios: '//div' }) expectError(wd.forceClick()) -expectType(wd.forceClick('div')) +expectType(wd.forceClick('div')) wd.forceClick({ css: 'div' }) wd.forceClick({ xpath: '//div' }) wd.forceClick({ name: 'div' }) @@ -90,7 +90,7 @@ wd.forceClick('div', { android: '//div' }) wd.forceClick('div', { ios: '//div' }) expectError(wd.doubleClick()) -expectType(wd.doubleClick('div')) +expectType(wd.doubleClick('div')) wd.doubleClick({ css: 'div' }) wd.doubleClick({ xpath: '//div' }) wd.doubleClick({ name: 'div' }) @@ -110,7 +110,7 @@ wd.doubleClick('div', { android: '//div' }) wd.doubleClick('div', { ios: '//div' }) expectError(wd.rightClick()) -expectType(wd.rightClick('div')) +expectType(wd.rightClick('div')) wd.rightClick({ css: 'div' }) wd.rightClick({ xpath: '//div' }) wd.rightClick({ name: 'div' }) @@ -131,7 +131,7 @@ wd.rightClick('div', { ios: '//div' }) expectError(wd.fillField()) expectError(wd.fillField('div')) -expectType(wd.fillField('div', str)) +expectType(wd.fillField('div', str)) wd.fillField({ css: 'div' }, str) wd.fillField({ xpath: '//div' }, str) wd.fillField({ name: 'div' }, str) @@ -143,7 +143,7 @@ wd.fillField(locate('div'), str) expectError(wd.appendField()) expectError(wd.appendField('div')) -expectType(wd.appendField('div', str)) +expectType(wd.appendField('div', str)) wd.appendField({ css: 'div' }, str) wd.appendField({ xpath: '//div' }, str) wd.appendField({ name: 'div' }, str) @@ -164,17 +164,17 @@ wd.clearField({ ios: 'div' }) expectError(wd.selectOption()) expectError(wd.selectOption('div')) -expectType(wd.selectOption('div', str)) +expectType(wd.selectOption('div', str)) expectError(wd.attachFile()) expectError(wd.attachFile('div')) -expectType(wd.attachFile('div', str)) +expectType(wd.attachFile('div', str)) expectError(wd.checkOption()) -expectType(wd.checkOption('div')) +expectType(wd.checkOption('div')) expectError(wd.uncheckOption()) -expectType(wd.uncheckOption('div')) +expectType(wd.uncheckOption('div')) expectError(wd.seeInTitle()) expectType(wd.seeInTitle(str)) @@ -273,12 +273,12 @@ wd.scrollIntoView('div', true) wd.scrollIntoView('div', { behavior: 'auto', block: 'center', inline: 'center' }) expectError(wd.scrollTo()) -expectType(wd.scrollTo('div')) -expectType(wd.scrollTo('div', num, num)) +expectType(wd.scrollTo('div')) +expectType(wd.scrollTo('div', num, num)) expectError(wd.moveCursorTo()) -expectType(wd.moveCursorTo('div')) -expectType(wd.moveCursorTo('div', num, num)) +expectType(wd.moveCursorTo('div')) +expectType(wd.moveCursorTo('div', num, num)) expectError(wd.saveScreenshot()) expectType(wd.saveScreenshot(str)) @@ -319,10 +319,10 @@ expectType(wd.resizeWindow(num, num)) expectError(wd.dragAndDrop()) expectError(wd.dragAndDrop('div')) -expectType(wd.dragAndDrop('div', 'div')) +expectType(wd.dragAndDrop('div', 'div')) expectError(wd.dragSlider()) -expectType(wd.dragSlider('div', num)) +expectType(wd.dragSlider('div', num)) expectError(wd.switchToWindow()) expectType(wd.switchToWindow(str)) From e038df4a7f20a369098972896d2eae0abe432946 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 6 Nov 2026 16:11:01 +0200 Subject: [PATCH 2/2] test(acceptance): check selected field per position WebDriver grabValueFromAll drops empty values, so the array comparison could not tell which field was filled. Co-Authored-By: Claude Opus 5.5 --- test/acceptance/element_selection_test.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/test/acceptance/element_selection_test.js b/test/acceptance/element_selection_test.js index 376f33c30..7d203fced 100644 --- a/test/acceptance/element_selection_test.js +++ b/test/acceptance/element_selection_test.js @@ -20,6 +20,14 @@ 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)], @@ -67,33 +75,29 @@ for (const [name, helpers, page, action] of actions) { Scenario(`first() selects the first match ${allHelpers}`, async ({ I }) => { I.amOnPage(strictPage) await I.fillField(fields, 'chained').first() - const values = await I.grabValueFromAll(fields) - assert.deepEqual(values, ['chained', '', '']) + seeOnlyFieldFilled(I, 1) }) Scenario(`last() selects the last match ${allHelpers}`, async ({ I }) => { I.amOnPage(strictPage) await I.fillField(fields, 'chained').last() - const values = await I.grabValueFromAll(fields) - assert.deepEqual(values, ['', '', 'chained']) + seeOnlyFieldFilled(I, 3) }) Scenario(`at() selects a match by position ${allHelpers}`, async ({ I }) => { I.amOnPage(strictPage) await I.fillField(fields, 'chained').at(2) - const values = await I.grabValueFromAll(fields) - assert.deepEqual(values, ['', 'chained', '']) + 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) - const values = await I.grabValueFromAll(fields) - assert.deepEqual(values, ['', 'chained', '']) + seeOnlyFieldFilled(I, 2) }) Scenario(`selection works without await ${allHelpers}`, ({ I }) => { I.amOnPage(strictPage) I.fillField(fields, 'chained').last() - I.seeInField({ css: 'input[name="duplicate-field"]:last-of-type' }, 'chained') + seeOnlyFieldFilled(I, 3) })