Skip to content

Commit 5793d13

Browse files
DavertMikclaude
andcommitted
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6RydiYkagn6C8Pts2Leou
1 parent cfc9545 commit 5793d13

7 files changed

Lines changed: 154 additions & 12 deletions

File tree

docs/helpers/Playwright.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ Type: [object][6]
7878
* `ignoreHTTPSErrors` **[boolean][27]?** Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false`
7979
* `bypassCSP` **[boolean][27]?** bypass Content Security Policy or CSP
8080
* `highlightElement` **[boolean][27]?** highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
81+
* `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.
8182
* `recordHar` **[object][6]?** record HAR and will be saved to `output/har`. See more of [HAR options][3].
82-
* `testIdAttribute` **[string][9]?** locate elements based on the testIdAttribute. See more of [locate by test id][49].
83+
* `testIdAttribute` **[string][9]?** locate elements based on the testIdAttribute. See more of [locate by test id][50].
8384
* `storageState` **([string][9] | [object][6])?** Playwright storage state (path to JSON file or object)
8485
passed directly to `browser.newContext`.
8586
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
29672968

29682969
[48]: https://playwright.dev/docs/api/class-consolemessage#console-message-type
29692970

2970-
[49]: https://playwright.dev/docs/locators#locate-by-test-id
2971+
[49]: https://playwright.dev/docs/api/class-locator#locator-visible
2972+
2973+
[50]: https://playwright.dev/docs/locators#locate-by-test-id

lib/helper/Playwright.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ let defaultSelectorEnginesInitialized = false
5050
const popupStore = new Popup()
5151
const consoleLogStore = new Console()
5252
const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
53+
const domPresenceSteps = ['seeElementInDOM', 'dontSeeElementInDOM', 'seeNumberOfElements']
5354
const checkableRoles = ['checkbox', 'radio', 'switch']
5455

5556
import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
@@ -102,6 +103,7 @@ const pathSeparator = path.sep
102103
* @prop {boolean} [ignoreHTTPSErrors] - Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false`
103104
* @prop {boolean} [bypassCSP] - bypass Content Security Policy or CSP
104105
* @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
106+
* @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.
105107
* @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).
106108
* @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).
107109
* @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object)
@@ -399,6 +401,7 @@ class Playwright extends Helper {
399401
storageState: undefined,
400402
onResponse: null,
401403
strict: false,
404+
visibleLocator: false,
402405
}
403406

404407
process.env.testIdAttribute = 'data-testid'
@@ -555,6 +558,10 @@ class Playwright extends Helper {
555558
}
556559
}
557560

561+
_beforeStep(step) {
562+
store.visibleLocator = step.opts?.visibleLocator ?? (this.options.visibleLocator && !domPresenceSteps.includes(step.helperMethod))
563+
}
564+
558565
async _before(test) {
559566
// Skip browser operations in dry-run mode (used by check command)
560567
if (store.dryRun) {
@@ -4197,6 +4204,14 @@ export function buildLocatorString(locator) {
41974204
return locator.simplify()
41984205
}
41994206

4207+
function withVisibleLocator(locator) {
4208+
if (!store.visibleLocator) return locator
4209+
if (typeof locator.visible !== 'function') {
4210+
throw new Error('visibleLocator option requires Playwright 1.63 or newer. Upgrade the playwright package or disable visibleLocator in helper config')
4211+
}
4212+
return locator.visible()
4213+
}
4214+
42004215
/**
42014216
* Handles role locator objects by converting them to Playwright's getByRole() API
42024217
* Accepts both raw objects ({role: 'button', text: 'Submit'}) and Locator-wrapped role objects.
@@ -4212,21 +4227,21 @@ async function handleRoleLocator(context, locator) {
42124227
if (roleObj.name) options.name = roleObj.name
42134228
if (roleObj.exact !== undefined) options.exact = roleObj.exact
42144229

4215-
return context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined).all()
4230+
return withVisibleLocator(context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined)).all()
42164231
}
42174232

42184233
async function findByRole(context, locator) {
42194234
if (!locator || !locator.role) return null
42204235
const options = {}
42214236
if (locator.name) options.name = locator.name
42224237
if (locator.exact !== undefined) options.exact = locator.exact
4223-
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4238+
return withVisibleLocator(context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined)).all()
42244239
}
42254240

42264241
async function findElements(matcher, locator) {
42274242
const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
42284243

4229-
if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
4244+
if (isPwLocator) return withVisibleLocator(findByPlaywrightLocator.call(this, matcher, locator)).all()
42304245

42314246
// Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
42324247
const roleElements = await handleRoleLocator(matcher, locator)
@@ -4236,11 +4251,11 @@ async function findElements(matcher, locator) {
42364251

42374252
const locatorString = buildLocatorString(locator)
42384253

4239-
return matcher.locator(locatorString).all()
4254+
return withVisibleLocator(matcher.locator(locatorString)).all()
42404255
}
42414256

42424257
async function findElement(matcher, locator) {
4243-
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4258+
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator).first()
42444259

42454260
locator = new Locator(locator, 'css')
42464261

@@ -4313,14 +4328,14 @@ async function findClickable(matcher, locator) {
43134328
const literal = xpathLocator.literal(matchedLocator.value)
43144329

43154330
try {
4316-
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
4331+
els = await withVisibleLocator(matcher.getByRole('button', { name: matchedLocator.value })).all()
43174332
if (els.length) return els
43184333
} catch (err) {
43194334
// getByRole not supported or failed
43204335
}
43214336

43224337
try {
4323-
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
4338+
els = await withVisibleLocator(matcher.getByRole('link', { name: matchedLocator.value })).all()
43244339
if (els.length) return els
43254340
} catch (err) {
43264341
// getByRole not supported or failed
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
async function findByPlaywrightLocator(matcher, locator) {
1+
function findByPlaywrightLocator(matcher, locator) {
22
const pwLocator = locator.locator || locator
33
if (pwLocator && pwLocator.toString && pwLocator.toString().includes(process.env.testIdAttribute)) {
44
return matcher.getByTestId(pwLocator.pw.value.split('=')[1])
55
}
66
const pwValue = typeof pwLocator.pw === 'string' ? pwLocator.pw : pwLocator.pw
7-
return matcher.locator(pwValue).all()
7+
return matcher.locator(pwValue)
88
}
99

1010
export { findByPlaywrightLocator }

lib/step/config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* @property {boolean} [exact] - Enable strict mode for this step. Throws if multiple elements match.
55
* @property {boolean} [strictMode] - Alias for exact.
66
* @property {boolean} [ignoreCase] - Perform case-insensitive text matching.
7+
* @property {boolean} [visibleLocator] - Match only visible elements. Overrides the Playwright helper `visibleLocator` config option for this step.
78
*/
89

910
/**

lib/store.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ const store = {
9393
/** @type {CodeceptJS.Suite | null} */
9494
currentSuite: null,
9595

96+
/**
97+
* Locators match only visible elements, resolved per step
98+
* @type {boolean}
99+
*/
100+
visibleLocator: false,
101+
96102
/** @type {Map<string, string> | null} */
97103
tsFileMapping: null,
98104

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@
177177
"jsdoc": "^3.6.11",
178178
"jsdoc-typeof-plugin": "1.0.0",
179179
"json-server": "0.17.4",
180-
"playwright": "^1.59.0",
180+
"playwright": "^1.63.0",
181181
"prettier": "^3.3.2",
182182
"puppeteer": "24.36.0",
183183
"qrcode-terminal": "0.12.0",

test/helper/webapi.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2960,4 +2960,121 @@ export function tests() {
29602960
await I.click('#grab-multiple a')
29612961
})
29622962
})
2963+
2964+
describe('#visibleLocator step option', () => {
2965+
const step = (helperMethod, opts = {}) => I._beforeStep({ helperMethod, opts })
2966+
2967+
beforeEach(function () {
2968+
if (!isHelper('Playwright')) this.skip()
2969+
})
2970+
2971+
afterEach(() => {
2972+
store.visibleLocator = false
2973+
I.options.visibleLocator = false
2974+
I.options.strict = false
2975+
})
2976+
2977+
it('should match hidden elements when disabled', async () => {
2978+
await I.amOnPage('/invisible_elements')
2979+
I.options.strict = true
2980+
step('click')
2981+
let err
2982+
try {
2983+
await I.click({ css: 'button' })
2984+
} catch (e) {
2985+
err = e
2986+
}
2987+
expect(err).to.exist
2988+
expect(err.constructor.name).to.equal('MultipleElementsFound')
2989+
})
2990+
2991+
it('should match only visible elements when enabled in config', async () => {
2992+
await I.amOnPage('/invisible_elements')
2993+
I.options.visibleLocator = true
2994+
I.options.strict = true
2995+
step('click')
2996+
await I.click({ css: 'button' })
2997+
})
2998+
2999+
it('should be enabled for a single step', async () => {
3000+
await I.amOnPage('/invisible_elements')
3001+
I.options.strict = true
3002+
step('click', { visibleLocator: true })
3003+
await I.click({ css: 'button' })
3004+
})
3005+
3006+
it('should be disabled for a single step', async () => {
3007+
await I.amOnPage('/invisible_elements')
3008+
I.options.visibleLocator = true
3009+
I.options.strict = true
3010+
step('click', { visibleLocator: false })
3011+
let err
3012+
try {
3013+
await I.click({ css: 'button' })
3014+
} catch (e) {
3015+
err = e
3016+
}
3017+
expect(err).to.exist
3018+
expect(err.constructor.name).to.equal('MultipleElementsFound')
3019+
})
3020+
3021+
it('should not find elements which are all hidden', async () => {
3022+
await I.amOnPage('/invisible_elements')
3023+
I.options.visibleLocator = true
3024+
step('click')
3025+
let err
3026+
try {
3027+
await I.click({ css: 'button[style]' })
3028+
} catch (e) {
3029+
err = e
3030+
}
3031+
expect(err).to.exist
3032+
expect(err.message).to.include('Clickable element')
3033+
expect(err.message).to.include('was not found')
3034+
})
3035+
3036+
it('should keep DOM assertions unaffected', async () => {
3037+
await I.amOnPage('/invisible_elements')
3038+
I.options.visibleLocator = true
3039+
3040+
step('seeElementInDOM')
3041+
await I.seeElementInDOM({ css: 'button[style]' })
3042+
3043+
step('seeNumberOfElements')
3044+
await I.seeNumberOfElements('button', 3)
3045+
3046+
step('dontSeeElementInDOM')
3047+
await I.dontSeeElementInDOM({ css: 'button[data-missing]' })
3048+
})
3049+
3050+
it('should apply to playwright locators', async () => {
3051+
await I.amOnPage('/invisible_elements')
3052+
I.options.visibleLocator = true
3053+
I.options.strict = true
3054+
step('click')
3055+
await I.click({ pw: 'button' })
3056+
})
3057+
3058+
it('should select from a custom combobox', async () => {
3059+
await I.amOnPage('/form/custom_select')
3060+
I.options.visibleLocator = true
3061+
step('selectOption')
3062+
await I.selectOption('Country', 'Porto')
3063+
step('see')
3064+
await I.see('country: pt', '#result')
3065+
})
3066+
3067+
it('should interact with fields and checkboxes', async () => {
3068+
await I.amOnPage('/invisible_elements')
3069+
I.options.visibleLocator = true
3070+
step('checkOption')
3071+
await I.checkOption('#ts')
3072+
step('seeCheckboxIsChecked')
3073+
await I.seeCheckboxIsChecked('#ts')
3074+
step('fillField')
3075+
await I.fillField('#basic', 'Pascal')
3076+
step('seeInField')
3077+
await I.seeInField('#basic', 'Pascal')
3078+
})
3079+
})
29633080
}

0 commit comments

Comments
 (0)