Skip to content

Commit 28398d4

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 28398d4

7 files changed

Lines changed: 152 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/Playwright_test.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import * as webApiTests from './webapi.js'
1919
import FileSystem from '../../lib/helper/FileSystem.js'
2020
import { deleteDir } from '../../lib/utils.js'
2121
import Secret from '../../lib/secret.js'
22+
import storeModule from '../../lib/store.js'
23+
const store = storeModule.default || storeModule
2224
import codeceptjsModule from '../../lib/index.js'
2325
global.codeceptjs = codeceptjsModule.default || codeceptjsModule
2426

@@ -134,6 +136,119 @@ describe('Playwright', function () {
134136
await I.click('Hello World')
135137
})
136138
})
139+
140+
describe('#visibleLocator', () => {
141+
const step = (helperMethod, opts = {}) => I._beforeStep({ helperMethod, opts })
142+
143+
afterEach(() => {
144+
store.visibleLocator = false
145+
I.options.visibleLocator = false
146+
I.options.strict = false
147+
})
148+
149+
it('should match hidden elements when disabled', async () => {
150+
await I.amOnPage('/invisible_elements')
151+
I.options.strict = true
152+
step('click')
153+
let err
154+
try {
155+
await I.click({ css: 'button' })
156+
} catch (e) {
157+
err = e
158+
}
159+
expect(err).to.exist
160+
expect(err.constructor.name).to.equal('MultipleElementsFound')
161+
})
162+
163+
it('should match only visible elements when enabled in config', async () => {
164+
await I.amOnPage('/invisible_elements')
165+
I.options.visibleLocator = true
166+
I.options.strict = true
167+
step('click')
168+
await I.click({ css: 'button' })
169+
})
170+
171+
it('should be enabled for a single step', async () => {
172+
await I.amOnPage('/invisible_elements')
173+
I.options.strict = true
174+
step('click', { visibleLocator: true })
175+
await I.click({ css: 'button' })
176+
})
177+
178+
it('should be disabled for a single step', async () => {
179+
await I.amOnPage('/invisible_elements')
180+
I.options.visibleLocator = true
181+
I.options.strict = true
182+
step('click', { visibleLocator: false })
183+
let err
184+
try {
185+
await I.click({ css: 'button' })
186+
} catch (e) {
187+
err = e
188+
}
189+
expect(err).to.exist
190+
expect(err.constructor.name).to.equal('MultipleElementsFound')
191+
})
192+
193+
it('should not find elements which are all hidden', async () => {
194+
await I.amOnPage('/invisible_elements')
195+
I.options.visibleLocator = true
196+
step('click')
197+
let err
198+
try {
199+
await I.click({ css: 'button[style]' })
200+
} catch (e) {
201+
err = e
202+
}
203+
expect(err).to.exist
204+
expect(err.message).to.include('Clickable element')
205+
expect(err.message).to.include('was not found')
206+
})
207+
208+
it('should keep DOM assertions unaffected', async () => {
209+
await I.amOnPage('/invisible_elements')
210+
I.options.visibleLocator = true
211+
212+
step('seeElementInDOM')
213+
await I.seeElementInDOM({ css: 'button[style]' })
214+
215+
step('seeNumberOfElements')
216+
await I.seeNumberOfElements('button', 3)
217+
218+
step('dontSeeElementInDOM')
219+
await I.dontSeeElementInDOM({ css: 'button[data-missing]' })
220+
})
221+
222+
it('should apply to playwright locators', async () => {
223+
await I.amOnPage('/invisible_elements')
224+
I.options.visibleLocator = true
225+
I.options.strict = true
226+
step('click')
227+
await I.click({ pw: 'button' })
228+
})
229+
230+
it('should select from a custom combobox', async () => {
231+
await I.amOnPage('/form/custom_select')
232+
I.options.visibleLocator = true
233+
step('selectOption')
234+
await I.selectOption('Country', 'Porto')
235+
step('see')
236+
await I.see('country: pt', '#result')
237+
})
238+
239+
it('should interact with fields and checkboxes', async () => {
240+
await I.amOnPage('/invisible_elements')
241+
I.options.visibleLocator = true
242+
step('checkOption')
243+
await I.checkOption('#ts')
244+
step('seeCheckboxIsChecked')
245+
await I.seeCheckboxIsChecked('#ts')
246+
step('fillField')
247+
await I.fillField('#basic', 'Pascal')
248+
step('seeInField')
249+
await I.seeInField('#basic', 'Pascal')
250+
})
251+
})
137252
describe('#grabCheckedElementStatus', () => {
138253
it('check grabCheckedElementStatus', async () => {
139254
await I.amOnPage('/invisible_elements')

0 commit comments

Comments
 (0)