diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 931119763..106d497e9 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -30,7 +30,7 @@ import ElementNotFound from './errors/ElementNotFound.js' import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js' import Popup from './extras/Popup.js' import Console from './extras/Console.js' -import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js' +import { findReact, findVue } from './extras/PlaywrightLocator.js' import WebElement from '../element/WebElement.js' let playwright @@ -2489,7 +2489,19 @@ class Playwright extends Helper { * {{> selectOption }} */ async selectOption(select, option) { - const els = await findFields.call(this, select) + const selectLocator = Locator.from(select, 'css') + let els = null + + if (selectLocator.isFuzzy()) { + els = await findByRole(this.page, { role: 'listbox', name: selectLocator.value }) + if (!els || els.length === 0) { + els = await findByRole(this.page, { role: 'combobox', name: selectLocator.value }) + } + } + + if (!els || els.length === 0) { + els = await findFields.call(this, select) + } assertElementExists(els, select, 'Selectable field') const el = els[0] @@ -2790,17 +2802,6 @@ class Playwright extends Helper { * */ async grabTextFrom(locator) { - // Handle role locators with text/exact options - if (isRoleLocatorObject(locator)) { - const elements = await handleRoleLocator(this.page, locator) - if (elements && elements.length > 0) { - const text = await elements[0].textContent() - assertElementExists(text, JSON.stringify(locator)) - this.debugSection('Text', text) - return text - } - } - const locatorObj = new Locator(locator, 'css') if (locatorObj.isCustom()) { @@ -2813,21 +2814,32 @@ class Playwright extends Helper { assertElementExists(text, locatorObj.toString()) this.debugSection('Text', text) return text - } else { - locator = this._contextLocator(locator) - try { - const text = await this.page.textContent(locator) - assertElementExists(text, locator) + } + + if (locatorObj.isRole()) { + // Handle role locators with text/exact options + const roleElements = await findByRole(this.page, locator) + if (roleElements && roleElements.length > 0) { + const text = await roleElements[0].textContent() + assertElementExists(text, JSON.stringify(locator)) this.debugSection('Text', text) return text - } catch (error) { - // Convert Playwright timeout errors to ElementNotFound for consistency - if (error.message && error.message.includes('Timeout')) { - throw new ElementNotFound(locator, 'text') - } - throw error } } + + locator = this._contextLocator(locator) + try { + const text = await this.page.textContent(locator) + assertElementExists(text, locator) + this.debugSection('Text', text) + return text + } catch (error) { + // Convert Playwright timeout errors to ElementNotFound for consistency + if (error.message && error.message.includes('Timeout')) { + throw new ElementNotFound(locator, 'text') + } + throw error + } } /** @@ -4306,50 +4318,26 @@ function buildLocatorString(locator) { if (locator.isXPath()) { return `xpath=${locator.value}` } - return locator.simplify() + return locator.simplify() } -/** - * Checks if a locator is a role locator object (e.g., {role: 'button', text: 'Submit', exact: true}) - */ -function isRoleLocatorObject(locator) { - return locator && typeof locator === 'object' && locator.role && !locator.type -} - -/** - * Handles role locator objects by converting them to Playwright's getByRole() API - * Returns elements array if role locator, null otherwise - */ -async function handleRoleLocator(context, locator) { - if (!isRoleLocatorObject(locator)) return null - - const options = {} - if (locator.text) options.name = locator.text - if (locator.exact !== undefined) options.exact = locator.exact - - return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all() +async function findByRole(context, locator) { + const matchedLocator = Locator.from(locator) + if (!matchedLocator.isRole()) return null + const roleOptions = matchedLocator.getRoleOptions() + return context.getByRole(roleOptions.role, roleOptions.options).all() } async function findElements(matcher, locator) { - // Check if locator is a Locator object with react/vue type, or a raw object with react/vue property - const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react - const isVueLocator = locator.type === 'vue' || (locator.locator && locator.locator.vue) || locator.vue - const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw - - if (isReactLocator) return findReact(matcher, locator) - if (isVueLocator) return findVue(matcher, locator) - if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator) - - // Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true}) - const roleElements = await handleRoleLocator(matcher, locator) + const matchedLocator = Locator.from(locator) + const roleElements = await findByRole(matcher, matchedLocator) if (roleElements) return roleElements - locator = new Locator(locator, 'css') + const isReactLocator = matchedLocator.type === 'react' + const isVueLocator = matchedLocator.type === 'vue' - // Handle custom locators directly instead of relying on Playwright selector engines - if (locator.isCustom()) { - return findCustomElements.call(this, matcher, locator) - } + if (isReactLocator) return findReact(matcher, matchedLocator) + if (isVueLocator) return findVue(matcher, matchedLocator) // Check if we have a custom context locator and need to search within it if (this.contextLocator) { @@ -4362,12 +4350,12 @@ async function findElements(matcher, locator) { } // Search within the first context element - const locatorString = buildLocatorString(locator) + const locatorString = buildLocatorString(matchedLocator) return contextElements[0].locator(locatorString).all() } } - const locatorString = buildLocatorString(locator) + const locatorString = buildLocatorString(matchedLocator) return matcher.locator(locatorString).all() } @@ -4460,13 +4448,17 @@ async function findCustomElements(matcher, locator) { } async function findElement(matcher, locator) { - if (locator.react) return findReact(matcher, locator) - if (locator.vue) return findVue(matcher, locator) - if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator) + const matchedLocator = Locator.from(locator) + const roleElements = await findByRole(matcher, matchedLocator) + if (roleElements && roleElements.length > 0) return roleElements[0] + + const isReactLocator = matchedLocator.type === 'react' + const isVueLocator = matchedLocator.type === 'vue' - locator = new Locator(locator, 'css') + if (isReactLocator) return findReact(matcher, matchedLocator) + if (isVueLocator) return findVue(matcher, matchedLocator) - return matcher.locator(buildLocatorString(locator)).first() + return matcher.locator(buildLocatorString(matchedLocator)).first() } async function getVisibleElements(elements) { @@ -4518,8 +4510,14 @@ async function proceedClick(locator, context = null, options = {}) { } async function findClickable(matcher, locator) { + // Convert to Locator first to handle JSON strings properly const matchedLocator = new Locator(locator) + // Handle role locators from Locator + if (matchedLocator.isRole()) { + return findByRole(matcher, matchedLocator) + } + if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator) let els @@ -4592,14 +4590,17 @@ async function findCheckable(locator, context) { } // Handle role locators with text/exact options - const roleElements = await handleRoleLocator(contextEl, locator) + const roleElements = await findByRole(contextEl, locator) if (roleElements) return roleElements - const matchedLocator = new Locator(locator) + const matchedLocator = Locator.from(locator) if (!matchedLocator.isFuzzy()) { return findElements.call(this, contextEl, matchedLocator) } + const checkboxByRole = await findByRole(contextEl, { role: 'checkbox', name: matchedLocator.value }) + if (checkboxByRole) return checkboxByRole + const literal = xpathLocator.literal(matchedLocator.value) let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal)) if (els.length) { @@ -4621,17 +4622,15 @@ async function proceedIsChecked(assertType, option) { } async function findFields(locator) { - // Handle role locators with text/exact options - if (isRoleLocatorObject(locator)) { - const page = await this.page - const roleElements = await handleRoleLocator(page, locator) - if (roleElements) return roleElements - } + const page = await this.page + const roleElements = await findByRole(page, locator) + if (roleElements) return roleElements const matchedLocator = new Locator(locator) if (!matchedLocator.isFuzzy()) { return this._locate(matchedLocator) } + const literal = xpathLocator.literal(locator) let els = await this._locate({ xpath: Locator.field.labelEquals(literal) }) diff --git a/lib/helper/extras/PlaywrightLocator.js b/lib/helper/extras/PlaywrightLocator.js index c7122939c..24ec8883b 100644 --- a/lib/helper/extras/PlaywrightLocator.js +++ b/lib/helper/extras/PlaywrightLocator.js @@ -11,22 +11,20 @@ function buildLocatorString(locator) { } async function findElements(matcher, locator) { - const matchedLocator = new Locator(locator, 'css') + const matchedLocator = Locator.from(locator, 'css') if (matchedLocator.type === 'react') return findReact(matcher, matchedLocator) if (matchedLocator.type === 'vue') return findVue(matcher, matchedLocator) - if (matchedLocator.type === 'pw') return findByPlaywrightLocator(matcher, matchedLocator) if (matchedLocator.isRole()) return findByRole(matcher, matchedLocator) return matcher.locator(buildLocatorString(matchedLocator)).all() } async function findElement(matcher, locator) { - const matchedLocator = new Locator(locator, 'css') + const matchedLocator = Locator.from(locator, 'css') if (matchedLocator.type === 'react') return findReact(matcher, matchedLocator) if (matchedLocator.type === 'vue') return findVue(matcher, matchedLocator) - if (matchedLocator.type === 'pw') return findByPlaywrightLocator(matcher, matchedLocator, { first: true }) if (matchedLocator.isRole()) return findByRole(matcher, matchedLocator, { first: true }) return matcher.locator(buildLocatorString(matchedLocator)).first() @@ -46,49 +44,30 @@ async function getVisibleElements(elements) { } async function findReact(matcher, locator) { - const details = locator.locator ?? { react: locator.value } - let locatorString = `_react=${details.react}` + const props = locator.locator?.props + let locatorString = `_react=${locator.value}` - if (details.props) { - locatorString += propBuilder(details.props) + if (props) { + locatorString += propBuilder(props) } return matcher.locator(locatorString).all() } async function findVue(matcher, locator) { - const details = locator.locator ?? { vue: locator.value } - let locatorString = `_vue=${details.vue}` + const props = locator.locator?.props + let locatorString = `_vue=${locator.value}` - if (details.props) { - locatorString += propBuilder(details.props) + if (props) { + locatorString += propBuilder(props) } return matcher.locator(locatorString).all() } -async function findByPlaywrightLocator(matcher, locator, { first = false } = {}) { - const details = locator.locator ?? { pw: locator.value } - const locatorValue = details.pw - - const handle = matcher.locator(locatorValue) - return first ? handle.first() : handle.all() -} - async function findByRole(matcher, locator, { first = false } = {}) { - const details = locator.locator ?? { role: locator.value } - const { role, text, name, exact, includeHidden, ...rest } = details - const options = { ...rest } - - if (includeHidden !== undefined) options.includeHidden = includeHidden - - const accessibleName = name ?? text - if (accessibleName !== undefined) { - options.name = accessibleName - if (exact === true) options.exact = true - } - - const roleLocator = matcher.getByRole(role, options) + const roleOptions = locator.getRoleOptions() + const roleLocator = matcher.getByRole(roleOptions.role, roleOptions.options) return first ? roleLocator.first() : roleLocator.all() } @@ -107,4 +86,4 @@ function propBuilder(props) { return _props } -export { buildLocatorString, findElements, findElement, getVisibleElements, findReact, findVue, findByPlaywrightLocator, findByRole } +export { buildLocatorString, findElements, findElement, getVisibleElements, findReact, findVue, findByRole } diff --git a/lib/helper/extras/PlaywrightReactVueLocator.js b/lib/helper/extras/PlaywrightReactVueLocator.js deleted file mode 100644 index 7ecb1f002..000000000 --- a/lib/helper/extras/PlaywrightReactVueLocator.js +++ /dev/null @@ -1,52 +0,0 @@ -async function findReact(matcher, locator) { - // Handle both Locator objects and raw locator objects - const reactLocator = locator.locator || locator - let _locator = `_react=${reactLocator.react}`; - let props = ''; - - if (reactLocator.props) { - props += propBuilder(reactLocator.props); - _locator += props; - } - return matcher.locator(_locator).all(); -} - -async function findVue(matcher, locator) { - // Handle both Locator objects and raw locator objects - const vueLocator = locator.locator || locator - let _locator = `_vue=${vueLocator.vue}`; - let props = ''; - - if (vueLocator.props) { - props += propBuilder(vueLocator.props); - _locator += props; - } - return matcher.locator(_locator).all(); -} - -async function findByPlaywrightLocator(matcher, locator) { - // Handle both Locator objects and raw locator objects - 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(); -} - -function propBuilder(props) { - let _props = ''; - - for (const [key, value] of Object.entries(props)) { - if (typeof value === 'object') { - for (const [k, v] of Object.entries(value)) { - _props += `[${key}.${k} = "${v}"]`; - } - } else { - _props += `[${key} = "${value}"]`; - } - } - return _props; -} - -export { findReact, findVue, findByPlaywrightLocator }; diff --git a/lib/locator.js b/lib/locator.js index b8eda835c..e1408a0dc 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -5,7 +5,7 @@ import { createRequire } from 'module' const require = createRequire(import.meta.url) let cssToXPath -const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow', 'pw', 'role'] +const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow', 'role'] /** @class */ class Locator { /** @@ -24,19 +24,16 @@ class Locator { */ this.strict = false + if (typeof locator === 'string' && this.parsedJsonAsString(locator)) { + return + } + if (typeof locator === 'object') { if (locator.constructor.name === 'Locator') { Object.assign(this, locator) return } - - this.locator = locator - this.type = Object.keys(locator)[0] - this.value = locator[this.type] - this.strict = true - - Locator.filters.forEach(f => f(locator, this)) - + this._applyObjectLocator(locator) return } @@ -53,8 +50,9 @@ class Locator { if (isShadow(locator)) { this.type = 'shadow' } - if (isPlaywrightLocator(locator)) { - this.type = 'pw' + if (isReactVueLocator(locator)) { + // React/Vue locators - keep as fuzzy type, helpers will handle them specially + this.type = 'fuzzy' } Locator.filters.forEach(f => f(locator, this)) @@ -76,8 +74,6 @@ class Locator { return this.value case 'shadow': return { shadow: this.value } - case 'pw': - return { pw: this.value } case 'role': return `[role="${this.value}"]` } @@ -86,9 +82,43 @@ class Locator { toStrict() { if (!this.type) return null + if (this.type === 'role' && this.locator) { + return this.locator + } return { [this.type]: this.value } } + parsedJsonAsString(locator) { + if (typeof locator !== 'string') { + return false + } + + const trimmed = locator.trim() + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return false + } + + try { + const parsed = JSON.parse(trimmed) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + this._applyObjectLocator(parsed) + return true + } + } catch (e) { + } + return false + } + + _applyObjectLocator(locator) { + this.strict = true + this.locator = locator + const keys = Object.keys(locator) + const [type] = keys + this.type = type + this.value = keys.length > 1 ? locator : locator[type] + Locator.filters.forEach(f => f(locator, this)) + } + /** * @returns {string} */ @@ -127,17 +157,27 @@ class Locator { /** * @returns {boolean} */ - isPlaywrightLocator() { - return this.type === 'pw' + isRole() { + return this.type === 'role' } /** - * @returns {boolean} + * @returns {{role: string, options: object}|null} */ - isRole() { - return this.type === 'role' + getRoleOptions() { + if (!this.isRole()) return null + const data = this.locator && typeof this.locator === 'object' ? this.locator : { role: this.value } + const { role, text, name, exact, includeHidden, ...rest } = data + let options = { ...rest } + const accessibleName = name ?? text + if (accessibleName !== undefined) options.name = accessibleName + if (exact !== undefined) options.exact = exact + if (includeHidden !== undefined) options.includeHidden = includeHidden + if (Object.keys(options).length === 0) options = undefined + return { role, options } } + /** * @returns {boolean} */ @@ -404,6 +444,16 @@ Locator.build = locator => { return new Locator(locator, 'css') } +/** + * @param {CodeceptJS.LocatorOrString|Locator} locator + * @param {string} [defaultType] + * @returns {Locator} + */ +Locator.from = (locator, defaultType = '') => { + if (locator instanceof Locator) return locator + return new Locator(locator, defaultType) +} + /** * Filters to modify locators * @type {Array} @@ -604,20 +654,10 @@ function removePrefix(xpath) { * @param {string} locator * @returns {boolean} */ -function isPlaywrightLocator(locator) { +function isReactVueLocator(locator) { return locator.includes('_react') || locator.includes('_vue') } -/** - * @private - * check if the locator is a role locator - * @param {{role: string}} locator - * @returns {boolean} - */ -function isRoleLocator(locator) { - return locator.role !== undefined && typeof locator.role === 'string' && Object.keys(locator).length >= 1 -} - /** * @private * @param {CodeceptJS.LocatorOrString} locator diff --git a/test/acceptance/react_test.js b/test/acceptance/react_test.js index cc67e57dd..2773def81 100644 --- a/test/acceptance/react_test.js +++ b/test/acceptance/react_test.js @@ -19,13 +19,13 @@ Scenario('component name @Puppeteer @Playwright', ({ I }) => { I.seeElement({ react: 'Demo' }) }) -Scenario('using playwright locator @Playwright', ({ I }) => { +Scenario('using react locator @Playwright', ({ I }) => { I.amOnPage('https://codecept.io/test-react-calculator/') I.click('7') - I.click({ pw: '_react=t[name = "="]' }) - I.seeElement({ pw: '_react=t[value = "7"]' }) - I.click({ pw: '_react=t[name = "+"]' }) - I.click({ pw: '_react=t[name = "3"]' }) - I.click({ pw: '_react=t[name = "="]' }) - I.seeElement({ pw: '_react=t[value = "10"]' }) + I.click({ react: 't', props: { name: '=' } }) + I.seeElement({ react: 't', props: { value: '7' } }) + I.click({ react: 't', props: { name: '+' } }) + I.click({ react: 't', props: { name: '3' } }) + I.click({ react: 't', props: { name: '=' } }) + I.seeElement({ react: 't', props: { value: '10' } }) }) diff --git a/test/data/app/view/form/custom_dropdown.php b/test/data/app/view/form/custom_dropdown.php new file mode 100644 index 000000000..886d7e1a6 --- /dev/null +++ b/test/data/app/view/form/custom_dropdown.php @@ -0,0 +1,153 @@ + + + + + + +
+
+ + + +
+ +
+ + + + \ No newline at end of file diff --git a/test/data/app/view/form/role_elements.php b/test/data/app/view/form/role_elements.php index 29df206e7..1c0d7edd4 100644 --- a/test/data/app/view/form/role_elements.php +++ b/test/data/app/view/form/role_elements.php @@ -102,9 +102,9 @@ class="custom-combobox" class="custom-combobox" role="combobox" contenteditable="true" - data-placeholder="Title" - placeholder="Title" - aria-label="Title"> + data-placeholder="Job Title" + placeholder="Job Title" + aria-label="Job Title">
diff --git a/test/helper/Playwright_test.js b/test/helper/Playwright_test.js index 75a43d485..57eb7fe03 100644 --- a/test/helper/Playwright_test.js +++ b/test/helper/Playwright_test.js @@ -501,6 +501,162 @@ describe('Playwright', function () { await I.click('Submit') assert.equal(formContents('age'), 'adult') }) + + it('should select option using css locator', async () => { + await I.amOnPage('/form/select') + await I.selectOption({ css: '#age' }, '13-21') + await I.click('Submit') + assert.equal(formContents('age'), 'teenage') + }) + + it('should select option using JSON string css locator', async () => { + await I.amOnPage('/form/select') + await I.selectOption('{"css": "#age"}', '21-') + await I.click('Submit') + assert.equal(formContents('age'), 'adult') + }) + + it('should select option using role locator', async () => { + await I.amOnPage('/form/select') + // Select elements have role="combobox" in accessibility tree + await I.selectOption({ role: 'combobox', name: 'Select your age' }, 'below 13') + await I.click('Submit') + assert.equal(formContents('age'), 'child') + }) + + it('should select option using role locator as JSON string', async () => { + await I.amOnPage('/form/select') + await I.selectOption('{"role": "combobox", "name": "Select your age"}', '60-100') + await I.click('Submit') + assert.equal(formContents('age'), 'oldfag') + }) + + it('should select option using role locator with text property', async () => { + await I.amOnPage('/form/select') + // 'text' property should be mapped to 'name' for getByRole + await I.selectOption({ role: 'combobox', text: 'Select your age' }, '100-210') + await I.click('Submit') + assert.equal(formContents('age'), 'dead') + }) + + it('should expand combobox and click option to select', async () => { + await I.amOnPage('/form/select') + + // Click to expand the combobox - this demonstrates that role locators work + await I.click({ role: 'combobox', name: 'Select your age' }) + + // Note: With native select elements, the options open in a native dropdown + // which cannot be accessed via normal DOM methods. + // For demonstration, we use selectOption after clicking + // In real-world scenarios with custom dropdowns, options can be clicked directly + await I.selectOption({ role: 'combobox', name: 'Select your age' }, '13-21') + + // Verify selection + await I.click('Submit') + assert.equal(formContents('age'), 'teenage') + }) + + it('should expand combobox and click option using JSON string locators', async () => { + await I.amOnPage('/form/select') + + // Step 1: Click the combobox using JSON string + await I.click('{"role": "combobox", "name": "Select your age"}') + + // Step 2: Select option after opening (native dropdown limitation) + await I.selectOption({ role: 'combobox', name: 'Select your age' }, '60-100') + + // Verify selection + await I.click('Submit') + assert.equal(formContents('age'), 'oldfag') + }) + }) + + describe('#click with custom dropdown', () => { + it('should expand custom dropdown and click option to select', async () => { + await I.amOnPage('/form/custom_dropdown') + + // Step 1: Click to expand the custom dropdown + await I.click({ role: 'combobox', name: 'Custom Dropdown' }) + + // Step 2: Click on an option to select it using CSS selector + // The options are now visible and clickable + await I.click({ css: '.dropdown-option[data-value="option2"]' }) + + // Verify the dropdown header changed + await I.see('Second Option', '.dropdown-header') + + // Submit the form + await I.click('Submit') + assert.equal(formContents('custom_dropdown'), 'option2') + }) + + it('should expand custom dropdown and click option using text within dropdown', async () => { + await I.amOnPage('/form/custom_dropdown') + + // Step 1: Click to expand the custom dropdown using role locator + await I.click({ role: 'combobox', name: 'Custom Dropdown' }) + + // Add a small delay to allow the dropdown to animate + await I.wait(1) + + // Verify dropdown is expanded + await I.seeElement('.dropdown-options.show') + + // Step 2: Click on option directly + await I.click({ css: '.dropdown-option[data-value="option3"]' }) + + // Verify the dropdown header changed + await I.see('Third Option', '.dropdown-header') + + // Submit and verify + await I.click('Submit') + assert.equal(formContents('custom_dropdown'), 'option3') + }) + + it('should expand custom dropdown and click option using ARIA role locators for both clicks', async () => { + await I.amOnPage('/form/custom_dropdown') + + // Step 1: Click to expand the custom dropdown using ARIA role locator + await I.click({ role: 'combobox', name: 'Custom Dropdown' }) + + // Add a small delay to allow the dropdown to animate + await I.wait(1) + + // Verify dropdown is expanded + await I.seeElement('.dropdown-options.show') + + // Step 2: Click on option using ARIA role locator + // Options have role="option" and their text as accessible name + await I.click({ role: 'option', name: 'First Option' }) + + // Verify the dropdown header changed + await I.see('First Option', '.dropdown-header') + + // Submit and verify + await I.click('Submit') + assert.equal(formContents('custom_dropdown'), 'option1') + }) + + it('should expand custom dropdown and click option using JSON string ARIA role locators', async () => { + await I.amOnPage('/form/custom_dropdown') + + // Step 1: Click to expand using JSON string ARIA role locator + await I.click('{"role": "combobox", "name": "Custom Dropdown"}') + + // Add a small delay + await I.wait(1) + + // Step 2: Click on option using JSON string ARIA role locator + // Note: When multiple elements have the same role, getByRole finds the first one + await I.click('{"role": "option", "name": "First Option"}') + + // Verify the dropdown header changed + await I.see('First Option', '.dropdown-header') + + // Submit and verify + await I.click('Submit') + assert.equal(formContents('custom_dropdown'), 'option1') + }) }) describe('#_locateClickable', () => { @@ -1186,6 +1342,50 @@ describe('Playwright', function () { I.waitForURL(/info/) I.see('Information') }) + + describe('JSON string locators with aria attributes', () => { + it('should compare object vs JSON string locators', async () => { + await I.amOnPage('/form/role_elements') + + await I.seeElement({ role: 'button', text: 'Submit' }) + + await I.seeElement('{"role": "button", "text": "Submit"}') + }) + + it('should locate elements using JSON string with role and text', async () => { + await I.amOnPage('/form/role_elements') + + await I.seeElement('{"role": "button", "text": "Submit"}') + await I.seeElement('{"role": "combobox", "text": "Title"}') + await I.seeElement('{"role": "textbox", "text": "your@email.com"}') + + await I.click('{"role": "button", "text": "Reset"}') + await I.dontSeeInField('Title', 'Test') + + await I.click('{"role": "button", "text": "Submit"}') + await I.see('Form Submitted!') + }) + + it('should interact with form fields using JSON string locators', async () => { + await I.amOnPage('/form/role_elements') + + // Fill form fields using JSON string locators + await I.fillField('{"role": "combobox", "text": "Title"}', 'Test Title from JSON') + await I.fillField('{"role": "textbox", "text": "your@email.com"}', 'json@test.com') + await I.checkOption('{"role": "checkbox"}') + + // Verify values + await I.seeInField('{"role": "combobox", "text": "Title"}', 'Test Title from JSON') + await I.seeInField('{"role": "textbox", "text": "your@email.com"}', 'json@test.com') + await I.seeCheckboxIsChecked('{"role": "checkbox"}') + + // Submit the form + await I.click('{"role": "button", "text": "Submit"}') + await I.see('Form Submitted!') + await I.see('Test Title from JSON') + await I.see('json@test.com') + }) + }) }) }) @@ -2034,10 +2234,10 @@ describe('using data-testid attribute', () => { return I._after() }) - it('should find element by pw locator', async () => { + it('should find element by css locator', async () => { await I.amOnPage('/') - const webElements = await I.grabWebElements({ pw: '[data-testid="welcome"]' }) + const webElements = await I.grabWebElements({ css: '[data-testid="welcome"]' }) assert.equal(webElements[0].constructor.name, 'WebElement') assert.equal(webElements[0].getNativeElement()._selector, '[data-testid="welcome"] >> nth=0') assert.equal(webElements.length, 1) @@ -2243,6 +2443,7 @@ describe('Playwright - storageState file path', function () { }) }) + // Global after hook to ensure process exits after all tests complete // This prevents the process from hanging due to Playwright event loops after(function () { diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 3baaf9f36..92ea12e97 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -1912,12 +1912,12 @@ export function tests() { // Test filling specific combobox by text when there are multiple await I.fillField({ role: 'combobox', text: 'Name' }, 'John Doe') await I.fillField({ role: 'combobox', text: 'Category' }, 'Technology') - await I.fillField({ role: 'combobox', text: 'Title' }, 'Software Engineer') + await I.fillField({ role: 'combobox', text: 'Job Title' }, 'Software Engineer') // Verify each field has the correct value await I.seeInField({ role: 'combobox', text: 'Name' }, 'John Doe') await I.seeInField({ role: 'combobox', text: 'Category' }, 'Technology') - await I.seeInField({ role: 'combobox', text: 'Title' }, 'Software Engineer') + await I.seeInField({ role: 'combobox', text: 'Job Title' }, 'Software Engineer') // Submit and verify data is processed correctly await I.click({ role: 'button', text: 'Submit' }) diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js index c6ec86ecd..025baf879 100644 --- a/test/unit/locator_test.js +++ b/test/unit/locator_test.js @@ -240,27 +240,6 @@ describe('Locator', () => { expect(l.value).to.equal('foo') expect(l.toString()).to.equal('foo') }) - - it('should create playwright locator - _react', () => { - const l = new Locator({ pw: '_react=button' }) - expect(l.type).to.equal('pw') - expect(l.value).to.equal('_react=button') - expect(l.toString()).to.equal('{pw: _react=button}') - }) - - it('should create playwright locator - _vue', () => { - const l = new Locator({ pw: '_vue=button' }) - expect(l.type).to.equal('pw') - expect(l.value).to.equal('_vue=button') - expect(l.toString()).to.equal('{pw: _vue=button}') - }) - - it('should create playwright locator - data-testid', () => { - const l = new Locator({ pw: '[data-testid="directions"]' }) - expect(l.type).to.equal('pw') - expect(l.value).to.equal('[data-testid="directions"]') - expect(l.toString()).to.equal('{pw: [data-testid="directions"]}') - }) }) describe('with object argument', () => { @@ -290,6 +269,110 @@ describe('Locator', () => { }) }) + describe('JSON string parsing', () => { + it('should parse JSON string to css locator', () => { + const jsonStr = '{"css": "#button"}' + const l = new Locator(jsonStr) + expect(l.type).to.equal('css') + expect(l.value).to.equal('#button') + }) + + it('should parse JSON string to xpath locator', () => { + const jsonStr = '{"xpath": "//div[@class=\\"test\\"]"}' + const l = new Locator(jsonStr) + expect(l.type).to.equal('xpath') + expect(l.value).to.equal('//div[@class="test"]') + }) + + it('should parse JSON string to id locator', () => { + const jsonStr = '{"id": "my-element"}' + const l = new Locator(jsonStr) + expect(l.type).to.equal('id') + expect(l.value).to.equal('my-element') + }) + + it('should parse JSON string to custom locator', () => { + const jsonStr = '{"byRole": "button"}' + const l = new Locator(jsonStr) + expect(l.type).to.equal('byRole') + expect(l.value).to.equal('button') + }) + + it('should handle whitespace around JSON string', () => { + const jsonStr = ' { "css": ".test" } ' + const l = new Locator(jsonStr) + expect(l.type).to.equal('css') + expect(l.value).to.equal('.test') + }) + + it('should reject invalid JSON and treat as string', () => { + const l = new Locator('{ invalid json') + expect(l.type).to.equal('fuzzy') + expect(l.value).to.equal('{ invalid json') + }) + + it('should handle aria-style locators with multiple properties', () => { + // Role locators should be handled with the role locator system + const jsonStr = '{"role": "button", "text": "Save"}' + const l = new Locator(jsonStr) + expect(l.type).to.equal('role') + expect(l.value).to.eql({ role: 'button', text: 'Save' }) + const roleOptions = l.getRoleOptions() + expect(roleOptions.role).to.equal('button') + expect(roleOptions.options.name).to.equal('Save') + expect(l.strict).to.equal(true) + }) + + it('should handle single property JSON normally', () => { + // Single property JSON should use the existing logic + const jsonStr = '{"role": "button"}' + const l = new Locator(jsonStr) + expect(l.type).to.equal('role') + expect(l.value).to.equal('button') + expect(l.isRole()).to.equal(true) + expect(l.strict).to.equal(true) + }) + + it('should ignore non-object JSON', () => { + const jsonStr = '"just a string"' + const l = new Locator(jsonStr) + expect(l.type).to.equal('fuzzy') + expect(l.value).to.equal('"just a string"') + }) + + it('should work with array values for certain locators', () => { + const jsonStr = '{"shadow": ["app", "component", "button"]}' + const l = new Locator(jsonStr) + expect(l.type).to.equal('shadow') + expect(l.value).to.eql(['app', 'component', 'button']) + }) + + it('should mark parsed locators as strict', () => { + const jsonStr = '{"css": "#test"}' + const l = new Locator(jsonStr) + expect(l.strict).to.equal(true) + }) + + it('should demonstrate equivalence between object and JSON string locators', () => { + // Same locator, different formats + const objectLocator = new Locator({ css: '#main-button' }) + const jsonLocator = new Locator('{"css": "#main-button"}') + + expect(objectLocator.type).to.equal(jsonLocator.type) + expect(objectLocator.value).to.equal(jsonLocator.value) + expect(objectLocator.strict).to.equal(jsonLocator.strict) + }) + + it('should work with complex xpath in JSON', () => { + const jsonStr = '{"xpath": "//div[contains(@class, \\"container\\")]//button"}' + const l = new Locator(jsonStr) + + expect(l.type).to.equal('xpath') + expect(l.value).to.equal('//div[contains(@class, "container")]//button') + expect(l.simplify()).to.equal('//div[contains(@class, "container")]//button') + }) + }) + it('should transform CSS to xpath', () => { const l = new Locator('p > #user', 'css') const nodes = xpath.select(l.toXPath(), doc)