Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/helpers/Playwright.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`),
Expand Down Expand Up @@ -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
30 changes: 23 additions & 7 deletions lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ 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'
import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
Expand Down Expand Up @@ -101,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)
Expand Down Expand Up @@ -398,6 +401,7 @@ class Playwright extends Helper {
storageState: undefined,
onResponse: null,
strict: false,
visibleLocator: false,
}

process.env.testIdAttribute = 'data-testid'
Expand Down Expand Up @@ -554,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) {
Expand Down Expand Up @@ -4196,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.
Expand All @@ -4211,21 +4227,21 @@ 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) {
if (!locator || !locator.role) return null
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)
Expand All @@ -4235,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')

Expand Down Expand Up @@ -4312,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
Expand Down
4 changes: 2 additions & 2 deletions lib/helper/extras/PlaywrightLocator.js
Original file line number Diff line number Diff line change
@@ -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 }
1 change: 1 addition & 0 deletions lib/step/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

/**
Expand Down
6 changes: 6 additions & 0 deletions lib/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | null} */
tsFileMapping: null,

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
115 changes: 115 additions & 0 deletions test/helper/Playwright_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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')
Expand Down
Loading