From 28398d4211bd34fcced0c782ff2f0030f12dbb0d Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 9 Sep 2026 00:34:52 +0300 Subject: [PATCH] feat(Playwright): visibleLocator config option Appends Playwright's locator.visible() (1.63+) to locators, so actions match only visible elements. Resolved per step: stepOpts({ visibleLocator }) overrides the helper config, following exact/strictMode/elementIndex. seeElementInDOM, dontSeeElementInDOM and seeNumberOfElements opt out by setting the step option, since they assert DOM presence regardless of visibility. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D6RydiYkagn6C8Pts2Leou --- docs/helpers/Playwright.md | 7 +- lib/helper/Playwright.js | 29 +++++-- lib/helper/extras/PlaywrightLocator.js | 4 +- lib/step/config.js | 1 + lib/store.js | 6 ++ package.json | 2 +- test/helper/Playwright_test.js | 115 +++++++++++++++++++++++++ 7 files changed, 152 insertions(+), 12 deletions(-) diff --git a/docs/helpers/Playwright.md b/docs/helpers/Playwright.md index 5f88baf1f..b9855e61f 100644 --- a/docs/helpers/Playwright.md +++ b/docs/helpers/Playwright.md @@ -78,8 +78,9 @@ Type: [object][6] * `ignoreHTTPSErrors` **[boolean][27]?** Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false` * `bypassCSP` **[boolean][27]?** bypass Content Security Policy or CSP * `highlightElement` **[boolean][27]?** highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose). +* `visibleLocator` **[boolean][27]?** append [`visible()`][49] to locators, so only visible elements are matched. Requires Playwright 1.63 or newer. Switch it off for a single step with `stepOpts({ visibleLocator: false })`. Not applied to `dragAndDrop`, which passes selectors to Playwright directly, nor to `seeElementInDOM`, `dontSeeElementInDOM` and `seeNumberOfElements`, which check the DOM regardless of visibility. When enabled, a locator matching only hidden elements fails as "element not found" instead of timing out on actionability, `strict` mode ignores hidden duplicates, and elements hidden by CSS (like a custom checkbox built on a visually hidden `input`) are no longer found. * `recordHar` **[object][6]?** record HAR and will be saved to `output/har`. See more of [HAR options][3]. -* `testIdAttribute` **[string][9]?** locate elements based on the testIdAttribute. See more of [locate by test id][49]. +* `testIdAttribute` **[string][9]?** locate elements based on the testIdAttribute. See more of [locate by test id][50]. * `storageState` **([string][9] | [object][6])?** Playwright storage state (path to JSON file or object) passed directly to `browser.newContext`. If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`), @@ -2967,4 +2968,6 @@ Returns **void** automatically synchronized promise through #recorder [48]: https://playwright.dev/docs/api/class-consolemessage#console-message-type -[49]: https://playwright.dev/docs/locators#locate-by-test-id +[49]: https://playwright.dev/docs/api/class-locator#locator-visible + +[50]: https://playwright.dev/docs/locators#locate-by-test-id diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index c4cabf115..42e586e94 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -50,6 +50,7 @@ let defaultSelectorEnginesInitialized = false const popupStore = new Popup() const consoleLogStore = new Console() const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron'] +const domPresenceSteps = ['seeElementInDOM', 'dontSeeElementInDOM', 'seeNumberOfElements'] const checkableRoles = ['checkbox', 'radio', 'switch'] import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js' @@ -102,6 +103,7 @@ const pathSeparator = path.sep * @prop {boolean} [ignoreHTTPSErrors] - Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false` * @prop {boolean} [bypassCSP] - bypass Content Security Policy or CSP * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose). + * @prop {boolean} [visibleLocator=false] - append [`visible()`](https://playwright.dev/docs/api/class-locator#locator-visible) to locators, so only visible elements are matched. Requires Playwright 1.63 or newer. Switch it off for a single step with `stepOpts({ visibleLocator: false })`. Not applied to `dragAndDrop`, which passes selectors to Playwright directly, nor to `seeElementInDOM`, `dontSeeElementInDOM` and `seeNumberOfElements`, which check the DOM regardless of visibility. When enabled, a locator matching only hidden elements fails as "element not found" instead of timing out on actionability, `strict` mode ignores hidden duplicates, and elements hidden by CSS (like a custom checkbox built on a visually hidden `input`) are no longer found. * @prop {object} [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har). * @prop {string} [testIdAttribute=data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id). * @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object) @@ -399,6 +401,7 @@ class Playwright extends Helper { storageState: undefined, onResponse: null, strict: false, + visibleLocator: false, } process.env.testIdAttribute = 'data-testid' @@ -555,6 +558,10 @@ class Playwright extends Helper { } } + _beforeStep(step) { + store.visibleLocator = step.opts?.visibleLocator ?? (this.options.visibleLocator && !domPresenceSteps.includes(step.helperMethod)) + } + async _before(test) { // Skip browser operations in dry-run mode (used by check command) if (store.dryRun) { @@ -4197,6 +4204,14 @@ export function buildLocatorString(locator) { return locator.simplify() } +function withVisibleLocator(locator) { + if (!store.visibleLocator) return locator + if (typeof locator.visible !== 'function') { + throw new Error('visibleLocator option requires Playwright 1.63 or newer. Upgrade the playwright package or disable visibleLocator in helper config') + } + return locator.visible() +} + /** * 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. @@ -4212,7 +4227,7 @@ async function handleRoleLocator(context, locator) { if (roleObj.name) options.name = roleObj.name if (roleObj.exact !== undefined) options.exact = roleObj.exact - return context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined).all() + return withVisibleLocator(context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined)).all() } async function findByRole(context, locator) { @@ -4220,13 +4235,13 @@ async function findByRole(context, locator) { const options = {} if (locator.name) options.name = locator.name if (locator.exact !== undefined) options.exact = locator.exact - return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all() + return withVisibleLocator(context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined)).all() } async function findElements(matcher, locator) { const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw - if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator) + if (isPwLocator) return withVisibleLocator(findByPlaywrightLocator.call(this, matcher, locator)).all() // Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true}) const roleElements = await handleRoleLocator(matcher, locator) @@ -4236,11 +4251,11 @@ async function findElements(matcher, locator) { const locatorString = buildLocatorString(locator) - return matcher.locator(locatorString).all() + return withVisibleLocator(matcher.locator(locatorString)).all() } async function findElement(matcher, locator) { - if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) + if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator).first() locator = new Locator(locator, 'css') @@ -4313,14 +4328,14 @@ async function findClickable(matcher, locator) { const literal = xpathLocator.literal(matchedLocator.value) try { - els = await matcher.getByRole('button', { name: matchedLocator.value }).all() + els = await withVisibleLocator(matcher.getByRole('button', { name: matchedLocator.value })).all() if (els.length) return els } catch (err) { // getByRole not supported or failed } try { - els = await matcher.getByRole('link', { name: matchedLocator.value }).all() + els = await withVisibleLocator(matcher.getByRole('link', { name: matchedLocator.value })).all() if (els.length) return els } catch (err) { // getByRole not supported or failed diff --git a/lib/helper/extras/PlaywrightLocator.js b/lib/helper/extras/PlaywrightLocator.js index 5fb3e4d4c..e2024eb6c 100644 --- a/lib/helper/extras/PlaywrightLocator.js +++ b/lib/helper/extras/PlaywrightLocator.js @@ -1,10 +1,10 @@ -async function findByPlaywrightLocator(matcher, locator) { +function findByPlaywrightLocator(matcher, locator) { const pwLocator = locator.locator || locator if (pwLocator && pwLocator.toString && pwLocator.toString().includes(process.env.testIdAttribute)) { return matcher.getByTestId(pwLocator.pw.value.split('=')[1]) } const pwValue = typeof pwLocator.pw === 'string' ? pwLocator.pw : pwLocator.pw - return matcher.locator(pwValue).all() + return matcher.locator(pwValue) } export { findByPlaywrightLocator } diff --git a/lib/step/config.js b/lib/step/config.js index f56b4bee7..6e7ba1f34 100644 --- a/lib/step/config.js +++ b/lib/step/config.js @@ -4,6 +4,7 @@ * @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. + * @property {boolean} [visibleLocator] - Match only visible elements. Overrides the Playwright helper `visibleLocator` config option for this step. */ /** diff --git a/lib/store.js b/lib/store.js index a6472cf8c..f5a45e1e6 100644 --- a/lib/store.js +++ b/lib/store.js @@ -93,6 +93,12 @@ const store = { /** @type {CodeceptJS.Suite | null} */ currentSuite: null, + /** + * Locators match only visible elements, resolved per step + * @type {boolean} + */ + visibleLocator: false, + /** @type {Map | null} */ tsFileMapping: null, diff --git a/package.json b/package.json index b3f16ff24..f187a27f0 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ "jsdoc": "^3.6.11", "jsdoc-typeof-plugin": "1.0.0", "json-server": "0.17.4", - "playwright": "^1.59.0", + "playwright": "^1.63.0", "prettier": "^3.3.2", "puppeteer": "24.36.0", "qrcode-terminal": "0.12.0", diff --git a/test/helper/Playwright_test.js b/test/helper/Playwright_test.js index 0355dde0c..21bd02deb 100644 --- a/test/helper/Playwright_test.js +++ b/test/helper/Playwright_test.js @@ -19,6 +19,8 @@ import * as webApiTests from './webapi.js' import FileSystem from '../../lib/helper/FileSystem.js' import { deleteDir } from '../../lib/utils.js' import Secret from '../../lib/secret.js' +import storeModule from '../../lib/store.js' +const store = storeModule.default || storeModule import codeceptjsModule from '../../lib/index.js' global.codeceptjs = codeceptjsModule.default || codeceptjsModule @@ -134,6 +136,119 @@ describe('Playwright', function () { await I.click('Hello World') }) }) + + describe('#visibleLocator', () => { + const step = (helperMethod, opts = {}) => I._beforeStep({ helperMethod, opts }) + + afterEach(() => { + store.visibleLocator = false + I.options.visibleLocator = false + I.options.strict = false + }) + + it('should match hidden elements when disabled', async () => { + await I.amOnPage('/invisible_elements') + I.options.strict = true + step('click') + let err + try { + await I.click({ css: 'button' }) + } catch (e) { + err = e + } + expect(err).to.exist + expect(err.constructor.name).to.equal('MultipleElementsFound') + }) + + it('should match only visible elements when enabled in config', async () => { + await I.amOnPage('/invisible_elements') + I.options.visibleLocator = true + I.options.strict = true + step('click') + await I.click({ css: 'button' }) + }) + + it('should be enabled for a single step', async () => { + await I.amOnPage('/invisible_elements') + I.options.strict = true + step('click', { visibleLocator: true }) + await I.click({ css: 'button' }) + }) + + it('should be disabled for a single step', async () => { + await I.amOnPage('/invisible_elements') + I.options.visibleLocator = true + I.options.strict = true + step('click', { visibleLocator: false }) + let err + try { + await I.click({ css: 'button' }) + } catch (e) { + err = e + } + expect(err).to.exist + expect(err.constructor.name).to.equal('MultipleElementsFound') + }) + + it('should not find elements which are all hidden', async () => { + await I.amOnPage('/invisible_elements') + I.options.visibleLocator = true + step('click') + let err + try { + await I.click({ css: 'button[style]' }) + } catch (e) { + err = e + } + expect(err).to.exist + expect(err.message).to.include('Clickable element') + expect(err.message).to.include('was not found') + }) + + it('should keep DOM assertions unaffected', async () => { + await I.amOnPage('/invisible_elements') + I.options.visibleLocator = true + + step('seeElementInDOM') + await I.seeElementInDOM({ css: 'button[style]' }) + + step('seeNumberOfElements') + await I.seeNumberOfElements('button', 3) + + step('dontSeeElementInDOM') + await I.dontSeeElementInDOM({ css: 'button[data-missing]' }) + }) + + it('should apply to playwright locators', async () => { + await I.amOnPage('/invisible_elements') + I.options.visibleLocator = true + I.options.strict = true + step('click') + await I.click({ pw: 'button' }) + }) + + it('should select from a custom combobox', async () => { + await I.amOnPage('/form/custom_select') + I.options.visibleLocator = true + step('selectOption') + await I.selectOption('Country', 'Porto') + step('see') + await I.see('country: pt', '#result') + }) + + it('should interact with fields and checkboxes', async () => { + await I.amOnPage('/invisible_elements') + I.options.visibleLocator = true + step('checkOption') + await I.checkOption('#ts') + step('seeCheckboxIsChecked') + await I.seeCheckboxIsChecked('#ts') + step('fillField') + await I.fillField('#basic', 'Pascal') + step('seeInField') + await I.seeInField('#basic', 'Pascal') + }) + }) describe('#grabCheckedElementStatus', () => { it('check grabCheckedElementStatus', async () => { await I.amOnPage('/invisible_elements')