Skip to content

Commit 9b19b83

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 116d65d commit 9b19b83

6 files changed

Lines changed: 144 additions & 18 deletions

File tree

docs/helpers/Playwright.md

Lines changed: 6 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)`),
@@ -97,6 +98,7 @@ Returns elements array if role locator, null otherwise
9798

9899
* `context` &#x20;
99100
* `locator` &#x20;
101+
* `visible` &#x20;
100102

101103

102104

@@ -2967,4 +2969,6 @@ Returns **void** automatically synchronized promise through #recorder
29672969

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

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

lib/helper/Playwright.js

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

403405
process.env.testIdAttribute = 'data-testid'
@@ -2008,6 +2010,7 @@ class Playwright extends Helper {
20082010
* {{> seeElementInDOM }}
20092011
*/
20102012
async seeElementInDOM(locator) {
2013+
disableVisibleLocatorForStep()
20112014
const els = await this._locate(locator)
20122015
try {
20132016
return empty('elements on page').negate(els.filter(v => v).fill('ELEMENT'))
@@ -2020,6 +2023,7 @@ class Playwright extends Helper {
20202023
* {{> dontSeeElementInDOM }}
20212024
*/
20222025
async dontSeeElementInDOM(locator) {
2026+
disableVisibleLocatorForStep()
20232027
const els = await this._locate(locator)
20242028
try {
20252029
return empty('elements on a page').assert(els.filter(v => v).fill('ELEMENT'))
@@ -2397,11 +2401,12 @@ class Playwright extends Helper {
23972401
// Fuzzy: try combobox
23982402
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
23992403
const comboboxSearchCtx = contextEl || pageContext
2400-
let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value })
2404+
const visible = useVisibleLocator(this)
2405+
let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value }, visible)
24012406
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
24022407

24032408
// Fuzzy: try listbox
2404-
els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
2409+
els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value }, visible)
24052410
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
24062411

24072412
// Fuzzy: try native select
@@ -2546,6 +2551,7 @@ class Playwright extends Helper {
25462551
*
25472552
*/
25482553
async seeNumberOfElements(locator, num) {
2554+
disableVisibleLocatorForStep()
25492555
const elements = await this._locate(locator)
25502556
return equals(`expected number of elements (${new Locator(locator)}) is ${num}, but found ${elements.length}`).assert(elements.length, num)
25512557
}
@@ -4192,12 +4198,29 @@ export function buildLocatorString(locator) {
41924198
return locator.simplify()
41934199
}
41944200

4201+
function disableVisibleLocatorForStep() {
4202+
const opts = store.currentStep?.opts
4203+
if (opts && opts.visibleLocator === undefined) opts.visibleLocator = false
4204+
}
4205+
4206+
function useVisibleLocator(helper) {
4207+
return store.currentStep?.opts?.visibleLocator ?? helper.options.visibleLocator
4208+
}
4209+
4210+
export function withVisibleLocator(locator, enabled) {
4211+
if (!enabled) 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+
41954218
/**
41964219
* Handles role locator objects by converting them to Playwright's getByRole() API
41974220
* Accepts both raw objects ({role: 'button', text: 'Submit'}) and Locator-wrapped role objects.
41984221
* Returns elements array if role locator, null otherwise
41994222
*/
4200-
async function handleRoleLocator(context, locator) {
4223+
async function handleRoleLocator(context, locator, visible) {
42014224
const loc = new Locator(locator)
42024225
if (!loc.isRole()) return null
42034226

@@ -4207,31 +4230,32 @@ async function handleRoleLocator(context, locator) {
42074230
if (roleObj.name) options.name = roleObj.name
42084231
if (roleObj.exact !== undefined) options.exact = roleObj.exact
42094232

4210-
return context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined).all()
4233+
return withVisibleLocator(context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined), visible).all()
42114234
}
42124235

4213-
async function findByRole(context, locator) {
4236+
async function findByRole(context, locator, visible) {
42144237
if (!locator || !locator.role) return null
42154238
const options = {}
42164239
if (locator.name) options.name = locator.name
42174240
if (locator.exact !== undefined) options.exact = locator.exact
4218-
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4241+
return withVisibleLocator(context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined), visible).all()
42194242
}
42204243

42214244
async function findElements(matcher, locator) {
4245+
const visible = useVisibleLocator(this)
42224246
const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
42234247

4224-
if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
4248+
if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator, visible)
42254249

42264250
// Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
4227-
const roleElements = await handleRoleLocator(matcher, locator)
4251+
const roleElements = await handleRoleLocator(matcher, locator, visible)
42284252
if (roleElements) return roleElements
42294253

42304254
locator = new Locator(locator, 'css')
42314255

42324256
const locatorString = buildLocatorString(locator)
42334257

4234-
return matcher.locator(locatorString).all()
4258+
return withVisibleLocator(matcher.locator(locatorString), visible).all()
42354259
}
42364260

42374261
async function findElement(matcher, locator) {
@@ -4306,16 +4330,17 @@ async function findClickable(matcher, locator) {
43064330

43074331
let els
43084332
const literal = xpathLocator.literal(matchedLocator.value)
4333+
const visible = useVisibleLocator(this)
43094334

43104335
try {
4311-
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
4336+
els = await withVisibleLocator(matcher.getByRole('button', { name: matchedLocator.value }), visible).all()
43124337
if (els.length) return els
43134338
} catch (err) {
43144339
// getByRole not supported or failed
43154340
}
43164341

43174342
try {
4318-
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
4343+
els = await withVisibleLocator(matcher.getByRole('link', { name: matchedLocator.value }), visible).all()
43194344
if (els.length) return els
43204345
} catch (err) {
43214346
// getByRole not supported or failed
@@ -4376,7 +4401,7 @@ async function findCheckable(locator, context) {
43764401
}
43774402

43784403
// Handle role locators with text/exact options
4379-
const roleElements = await handleRoleLocator(contextEl, locator)
4404+
const roleElements = await handleRoleLocator(contextEl, locator, useVisibleLocator(this))
43804405
if (roleElements) return roleElements
43814406

43824407
const matchedLocator = new Locator(locator)
@@ -4417,7 +4442,7 @@ async function findFields(locator, context = null) {
44174442
: loc => this._locate(loc)
44184443

44194444
const matcher = contextEl || (await this.page)
4420-
const roleElements = await handleRoleLocator(matcher, locator)
4445+
const roleElements = await handleRoleLocator(matcher, locator, useVisibleLocator(this))
44214446
if (roleElements) return roleElements
44224447

44234448
const matchedLocator = new Locator(locator)
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
async function findByPlaywrightLocator(matcher, locator) {
1+
import { withVisibleLocator } from '../Playwright.js'
2+
3+
async function findByPlaywrightLocator(matcher, locator, visible) {
24
const pwLocator = locator.locator || locator
35
if (pwLocator && pwLocator.toString && pwLocator.toString().includes(process.env.testIdAttribute)) {
46
return matcher.getByTestId(pwLocator.pw.value.split('=')[1])
57
}
68
const pwValue = typeof pwLocator.pw === 'string' ? pwLocator.pw : pwLocator.pw
7-
return matcher.locator(pwValue).all()
9+
return withVisibleLocator(matcher.locator(pwValue), visible).all()
810
}
911

1012
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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2799,4 +2799,98 @@ export function tests() {
27992799
await I.click('#grab-multiple a')
28002800
})
28012801
})
2802+
2803+
describe('#visibleLocator step option', () => {
2804+
beforeEach(function () {
2805+
if (!isHelper('Playwright')) this.skip()
2806+
})
2807+
2808+
afterEach(() => {
2809+
store.currentStep = null
2810+
I.options.visibleLocator = false
2811+
I.options.strict = false
2812+
})
2813+
2814+
it('should match hidden elements when disabled', async () => {
2815+
await I.amOnPage('/invisible_elements')
2816+
I.options.strict = true
2817+
let err
2818+
try {
2819+
await I.click({ css: 'button' })
2820+
} catch (e) {
2821+
err = e
2822+
}
2823+
expect(err).to.exist
2824+
expect(err.constructor.name).to.equal('MultipleElementsFound')
2825+
})
2826+
2827+
it('should match only visible elements when enabled in config', async () => {
2828+
await I.amOnPage('/invisible_elements')
2829+
I.options.visibleLocator = true
2830+
I.options.strict = true
2831+
await I.click({ css: 'button' })
2832+
})
2833+
2834+
it('should be enabled for a single step', async () => {
2835+
await I.amOnPage('/invisible_elements')
2836+
I.options.strict = true
2837+
store.currentStep = { opts: { visibleLocator: true } }
2838+
await I.click({ css: 'button' })
2839+
})
2840+
2841+
it('should be disabled for a single step', async () => {
2842+
await I.amOnPage('/invisible_elements')
2843+
I.options.visibleLocator = true
2844+
I.options.strict = true
2845+
store.currentStep = { opts: { visibleLocator: false } }
2846+
let err
2847+
try {
2848+
await I.click({ css: 'button' })
2849+
} catch (e) {
2850+
err = e
2851+
}
2852+
expect(err).to.exist
2853+
expect(err.constructor.name).to.equal('MultipleElementsFound')
2854+
})
2855+
2856+
it('should not find elements which are all hidden', async () => {
2857+
await I.amOnPage('/invisible_elements')
2858+
I.options.visibleLocator = true
2859+
let err
2860+
try {
2861+
await I.click({ css: 'button[style]' })
2862+
} catch (e) {
2863+
err = e
2864+
}
2865+
expect(err).to.exist
2866+
expect(err.message).to.include('Clickable element')
2867+
expect(err.message).to.include('was not found')
2868+
})
2869+
2870+
it('should keep DOM assertions unaffected', async () => {
2871+
await I.amOnPage('/invisible_elements')
2872+
I.options.visibleLocator = true
2873+
store.currentStep = { opts: {} }
2874+
await I.seeElementInDOM({ css: 'button[style]' })
2875+
await I.seeNumberOfElements('button', 3)
2876+
store.currentStep = { opts: {} }
2877+
await I.dontSeeElementInDOM({ css: 'button[data-missing]' })
2878+
})
2879+
2880+
it('should select from a custom combobox', async () => {
2881+
await I.amOnPage('/form/custom_select')
2882+
I.options.visibleLocator = true
2883+
await I.selectOption('Country', 'Porto')
2884+
await I.see('country: pt', '#result')
2885+
})
2886+
2887+
it('should interact with fields and checkboxes', async () => {
2888+
await I.amOnPage('/invisible_elements')
2889+
I.options.visibleLocator = true
2890+
await I.checkOption('#ts')
2891+
await I.seeCheckboxIsChecked('#ts')
2892+
await I.fillField('#basic', 'Pascal')
2893+
await I.seeInField('#basic', 'Pascal')
2894+
})
2895+
})
28022896
}

0 commit comments

Comments
 (0)