Skip to content

Commit 36ac791

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 36ac791

6 files changed

Lines changed: 142 additions & 19 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: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const pathSeparator = path.sep
102102
* @prop {boolean} [ignoreHTTPSErrors] - Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false`
103103
* @prop {boolean} [bypassCSP] - bypass Content Security Policy or CSP
104104
* @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
105+
* @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.
105106
* @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).
106107
* @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).
107108
* @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object)
@@ -399,6 +400,7 @@ class Playwright extends Helper {
399400
storageState: undefined,
400401
onResponse: null,
401402
strict: false,
403+
visibleLocator: false,
402404
}
403405

404406
process.env.testIdAttribute = 'data-testid'
@@ -2009,6 +2011,7 @@ class Playwright extends Helper {
20092011
* {{> seeElementInDOM }}
20102012
*/
20112013
async seeElementInDOM(locator) {
2014+
disableVisibleLocatorForStep()
20122015
const els = await this._locate(locator)
20132016
try {
20142017
return empty('elements on page').negate(els.filter(v => v).fill('ELEMENT'))
@@ -2021,6 +2024,7 @@ class Playwright extends Helper {
20212024
* {{> dontSeeElementInDOM }}
20222025
*/
20232026
async dontSeeElementInDOM(locator) {
2027+
disableVisibleLocatorForStep()
20242028
const els = await this._locate(locator)
20252029
try {
20262030
return empty('elements on a page').assert(els.filter(v => v).fill('ELEMENT'))
@@ -2398,15 +2402,15 @@ class Playwright extends Helper {
23982402
// Fuzzy: try combobox
23992403
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
24002404
const comboboxSearchCtx = contextEl || pageContext
2401-
let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value })
2405+
let els = await findByRole.call(this, comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value })
24022406
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
24032407

24042408
// Fuzzy: try listbox
2405-
els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
2409+
els = await findByRole.call(this, comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
24062410
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
24072411

24082412
// Fuzzy: try radiogroup
2409-
els = await findByRole(comboboxSearchCtx, { role: 'radiogroup', name: matchedLocator.value })
2413+
els = await findByRole.call(this, comboboxSearchCtx, { role: 'radiogroup', name: matchedLocator.value })
24102414
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
24112415

24122416
// Fuzzy: try native select
@@ -2551,6 +2555,7 @@ class Playwright extends Helper {
25512555
*
25522556
*/
25532557
async seeNumberOfElements(locator, num) {
2558+
disableVisibleLocatorForStep()
25542559
const elements = await this._locate(locator)
25552560
return equals(`expected number of elements (${new Locator(locator)}) is ${num}, but found ${elements.length}`).assert(elements.length, num)
25562561
}
@@ -2709,7 +2714,7 @@ class Playwright extends Helper {
27092714
*
27102715
*/
27112716
async grabTextFrom(locator) {
2712-
const roleElements = await handleRoleLocator(this.page, locator)
2717+
const roleElements = await handleRoleLocator.call(this, this.page, locator)
27132718
if (roleElements && roleElements.length > 0) {
27142719
const text = await roleElements[0].textContent()
27152720
assertElementExists(text, JSON.stringify(locator))
@@ -4197,6 +4202,19 @@ export function buildLocatorString(locator) {
41974202
return locator.simplify()
41984203
}
41994204

4205+
function disableVisibleLocatorForStep() {
4206+
const opts = store.currentStep?.opts
4207+
if (opts && opts.visibleLocator === undefined) opts.visibleLocator = false
4208+
}
4209+
4210+
function withVisibleLocator(locator) {
4211+
if (!(store.currentStep?.opts?.visibleLocator ?? this.options.visibleLocator)) return locator
4212+
if (typeof locator.visible !== 'function') {
4213+
throw new Error('visibleLocator option requires Playwright 1.63 or newer. Upgrade the playwright package or disable visibleLocator in helper config')
4214+
}
4215+
return locator.visible()
4216+
}
4217+
42004218
/**
42014219
* Handles role locator objects by converting them to Playwright's getByRole() API
42024220
* Accepts both raw objects ({role: 'button', text: 'Submit'}) and Locator-wrapped role objects.
@@ -4212,35 +4230,35 @@ async function handleRoleLocator(context, locator) {
42124230
if (roleObj.name) options.name = roleObj.name
42134231
if (roleObj.exact !== undefined) options.exact = roleObj.exact
42144232

4215-
return context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined).all()
4233+
return withVisibleLocator.call(this, context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined)).all()
42164234
}
42174235

42184236
async function findByRole(context, locator) {
42194237
if (!locator || !locator.role) return null
42204238
const options = {}
42214239
if (locator.name) options.name = locator.name
42224240
if (locator.exact !== undefined) options.exact = locator.exact
4223-
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4241+
return withVisibleLocator.call(this, context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined)).all()
42244242
}
42254243

42264244
async function findElements(matcher, locator) {
42274245
const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
42284246

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

42314249
// Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
4232-
const roleElements = await handleRoleLocator(matcher, locator)
4250+
const roleElements = await handleRoleLocator.call(this, matcher, locator)
42334251
if (roleElements) return roleElements
42344252

42354253
locator = new Locator(locator, 'css')
42364254

42374255
const locatorString = buildLocatorString(locator)
42384256

4239-
return matcher.locator(locatorString).all()
4257+
return withVisibleLocator.call(this, matcher.locator(locatorString)).all()
42404258
}
42414259

42424260
async function findElement(matcher, locator) {
4243-
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4261+
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator).first()
42444262

42454263
locator = new Locator(locator, 'css')
42464264

@@ -4313,14 +4331,14 @@ async function findClickable(matcher, locator) {
43134331
const literal = xpathLocator.literal(matchedLocator.value)
43144332

43154333
try {
4316-
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
4334+
els = await withVisibleLocator.call(this, matcher.getByRole('button', { name: matchedLocator.value })).all()
43174335
if (els.length) return els
43184336
} catch (err) {
43194337
// getByRole not supported or failed
43204338
}
43214339

43224340
try {
4323-
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
4341+
els = await withVisibleLocator.call(this, matcher.getByRole('link', { name: matchedLocator.value })).all()
43244342
if (els.length) return els
43254343
} catch (err) {
43264344
// getByRole not supported or failed
@@ -4381,7 +4399,7 @@ async function findCheckable(locator, context) {
43814399
}
43824400

43834401
// Handle role locators with text/exact options
4384-
const roleElements = await handleRoleLocator(contextEl, locator)
4402+
const roleElements = await handleRoleLocator.call(this, contextEl, locator)
43854403
if (roleElements) return roleElements
43864404

43874405
const matchedLocator = new Locator(locator)
@@ -4433,7 +4451,7 @@ async function findFields(locator, context = null) {
44334451
: loc => this._locate(loc)
44344452

44354453
const matcher = contextEl || (await this.page)
4436-
const roleElements = await handleRoleLocator(matcher, locator)
4454+
const roleElements = await handleRoleLocator.call(this, matcher, locator)
44374455
if (roleElements) return roleElements
44384456

44394457
const matchedLocator = new Locator(locator)
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
/**

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

0 commit comments

Comments
 (0)