diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 43cb5c337..e4e98af3e 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -27,6 +27,7 @@ import { } from '../utils.js' import { isColorProperty, convertColorToRGBA } from '../colorUtils.js' import ElementNotFound from './errors/ElementNotFound.js' +import MultipleElementsFound from './errors/MultipleElementsFound.js' import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js' import Popup from './extras/Popup.js' import Console from './extras/Console.js' @@ -392,6 +393,7 @@ class Playwright extends Helper { highlightElement: false, storageState: undefined, onResponse: null, + strict: false, } process.env.testIdAttribute = 'data-testid' @@ -1753,7 +1755,12 @@ class Playwright extends Helper { */ async _locateElement(locator) { const context = await this._getContext() - return findElement(context, locator) + const elements = await findElements.call(this, context, locator) + if (elements.length === 0) { + throw new ElementNotFound(locator, 'Element', 'was not found') + } + if (this.options.strict) assertOnlyOneElement(elements, locator) + return elements[0] } /** @@ -1768,6 +1775,7 @@ class Playwright extends Helper { const context = providedContext || (await this._getContext()) const els = await findCheckable.call(this, locator, context) assertElementExists(els[0], locator, 'Checkbox or radio') + if (this.options.strict) assertOnlyOneElement(els, locator) return els[0] } @@ -2240,6 +2248,7 @@ class Playwright extends Helper { async fillField(field, value) { const els = await findFields.call(this, field) assertElementExists(els, field, 'Field') + if (this.options.strict) assertOnlyOneElement(els, field) const el = els[0] await el.clear() @@ -2272,6 +2281,7 @@ class Playwright extends Helper { async clearField(locator, options = {}) { const els = await findFields.call(this, locator) assertElementExists(els, locator, 'Field to clear') + if (this.options.strict) assertOnlyOneElement(els, locator) const el = els[0] @@ -2288,6 +2298,7 @@ class Playwright extends Helper { async appendField(field, value) { const els = await findFields.call(this, field) assertElementExists(els, field, 'Field') + if (this.options.strict) assertOnlyOneElement(els, field) await highlightActiveElement.call(this, els[0]) await els[0].press('End') await els[0].type(value.toString(), { delay: this.options.pressKeyDelay }) @@ -2330,23 +2341,30 @@ class Playwright extends Helper { * {{> selectOption }} */ async selectOption(select, option) { - const els = await findFields.call(this, select) - assertElementExists(els, select, 'Selectable field') - const el = els[0] - - await highlightActiveElement.call(this, el) - let optionToSelect = '' + const context = await this.context + const matchedLocator = new Locator(select) - try { - optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim() - } catch (e) { - optionToSelect = option + // Strict locator + if (!matchedLocator.isFuzzy()) { + this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`) + const els = await this._locate(matchedLocator) + assertElementExists(els, select, 'Selectable element') + return proceedSelect.call(this, context, els[0], option) } - if (!Array.isArray(option)) option = [optionToSelect] + // Fuzzy: try combobox + this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`) + let els = await findByRole(context, { role: 'combobox', name: matchedLocator.value }) + if (els?.length) return proceedSelect.call(this, context, els[0], option) - await el.selectOption(option) - return this._waitForAction() + // Fuzzy: try listbox + els = await findByRole(context, { role: 'listbox', name: matchedLocator.value }) + if (els?.length) return proceedSelect.call(this, context, els[0], option) + + // Fuzzy: try native select + els = await findFields.call(this, select) + assertElementExists(els, select, 'Selectable element') + return proceedSelect.call(this, context, els[0], option) } /** @@ -4102,6 +4120,14 @@ async function handleRoleLocator(context, locator) { return context.getByRole(locator.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() +} + 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 @@ -4184,34 +4210,53 @@ async function proceedClick(locator, context = null, options = {}) { async function findClickable(matcher, locator) { const matchedLocator = new Locator(locator) - if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator) + if (!matchedLocator.isFuzzy()) { + const els = await findElements.call(this, matcher, matchedLocator) + if (this.options.strict) assertOnlyOneElement(els, locator) + return els + } let els const literal = xpathLocator.literal(matchedLocator.value) try { els = await matcher.getByRole('button', { name: matchedLocator.value }).all() - if (els.length) return els + if (els.length) { + if (this.options.strict) assertOnlyOneElement(els, locator) + return els + } } catch (err) { // getByRole not supported or failed } try { els = await matcher.getByRole('link', { name: matchedLocator.value }).all() - if (els.length) return els + if (els.length) { + if (this.options.strict) assertOnlyOneElement(els, locator) + return els + } } catch (err) { // getByRole not supported or failed } els = await findElements.call(this, matcher, Locator.clickable.narrow(literal)) - if (els.length) return els + if (els.length) { + if (this.options.strict) assertOnlyOneElement(els, locator) + return els + } els = await findElements.call(this, matcher, Locator.clickable.wide(literal)) - if (els.length) return els + if (els.length) { + if (this.options.strict) assertOnlyOneElement(els, locator) + return els + } try { els = await findElements.call(this, matcher, Locator.clickable.self(literal)) - if (els.length) return els + if (els.length) { + if (this.options.strict) assertOnlyOneElement(els, locator) + return els + } } catch (err) { // Do nothing } @@ -4314,6 +4359,52 @@ async function findFields(locator) { return this._locate({ css: locator }) } +async function proceedSelect(context, el, option) { + const role = await el.getAttribute('role') + const options = Array.isArray(option) ? option : [option] + + if (role === 'combobox') { + this.debugSection('SelectOption', 'Expanding combobox') + await highlightActiveElement.call(this, el) + const [ariaOwns, ariaControls] = await Promise.all([el.getAttribute('aria-owns'), el.getAttribute('aria-controls')]) + await el.click() + await this._waitForAction() + + const listboxId = ariaOwns || ariaControls + let listbox = listboxId ? context.locator(`#${listboxId}`).first() : null + if (!listbox || !(await listbox.count())) listbox = context.getByRole('listbox').first() + + for (const opt of options) { + const optEl = listbox.getByRole('option', { name: opt }).first() + this.debugSection('SelectOption', `Clicking: "${opt}"`) + await highlightActiveElement.call(this, optEl) + await optEl.click() + } + return this._waitForAction() + } + + if (role === 'listbox') { + for (const opt of options) { + const optEl = el.getByRole('option', { name: opt }).first() + this.debugSection('SelectOption', `Clicking: "${opt}"`) + await highlightActiveElement.call(this, optEl) + await optEl.click() + } + return this._waitForAction() + } + + await highlightActiveElement.call(this, el) + let optionToSelect = option + try { + optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim() + } catch (e) { + optionToSelect = option + } + if (!Array.isArray(option)) option = [optionToSelect] + await el.selectOption(option) + return this._waitForAction() +} + async function proceedSeeInField(assertType, field, value) { const els = await findFields.call(this, field) assertElementExists(els, field, 'Field') @@ -4429,6 +4520,12 @@ function assertElementExists(res, locator, prefix, suffix) { } } +function assertOnlyOneElement(elements, locator) { + if (elements.length > 1) { + throw new MultipleElementsFound(locator, elements) + } +} + function $XPath(element, selector) { const found = document.evaluate(selector, element || document.body, null, 5, null) const res = [] diff --git a/lib/helper/errors/MultipleElementsFound.js b/lib/helper/errors/MultipleElementsFound.js new file mode 100644 index 000000000..46c16801b --- /dev/null +++ b/lib/helper/errors/MultipleElementsFound.js @@ -0,0 +1,135 @@ +import Locator from '../../locator.js' + +/** + * Error thrown when strict mode is enabled and multiple elements are found + * for a single-element locator operation (click, fillField, etc.) + */ +class MultipleElementsFound extends Error { + /** + * @param {Locator|string|object} locator - The locator used + * @param {Array} elements - Array of Playwright element handles found + */ + constructor(locator, elements) { + super(`Multiple elements (${elements.length}) found for "${locator}". Call fetchDetails() for full information.`) + this.name = 'MultipleElementsFound' + this.locator = locator + this.elements = elements + this.count = elements.length + this._detailsFetched = false + } + + /** + * Fetch detailed information about the found elements asynchronously + * This updates the error message with XPath and element previews + */ + async fetchDetails() { + if (this._detailsFetched) return + + try { + if (typeof this.locator === 'object' && !(this.locator instanceof Locator)) { + this.locator = JSON.stringify(this.locator) + } + + const locatorObj = new Locator(this.locator) + const elementList = await this._generateElementList(this.elements, this.count) + + this.message = `Multiple elements (${this.count}) found for "${locatorObj.toString()}" in strict mode.\n` + + elementList + + `\nUse a more specific locator or use grabWebElements() to handle multiple elements.` + } catch (err) { + this.message = `Multiple elements (${this.count}) found. Failed to fetch details: ${err.message}` + } + + this._detailsFetched = true + } + + /** + * Generate a formatted list of found elements with their XPath and preview + * @param {Array} elements + * @param {number} count + * @returns {Promise} + */ + async _generateElementList(elements, count) { + const items = [] + const maxToShow = Math.min(count, 10) + + for (let i = 0; i < maxToShow; i++) { + const el = elements[i] + try { + const info = await this._getElementInfo(el) + items.push(` ${i + 1}. ${info.xpath} (${info.preview})`) + } catch (err) { + // Element might be detached or inaccessible + items.push(` ${i + 1}. [Unable to get element info: ${err.message}]`) + } + } + + if (count > 10) { + items.push(` ... and ${count - 10} more`) + } + + return items.join('\n') + } + + /** + * Get XPath and preview for an element by running JavaScript in browser context + * @param {HTMLElement} element + * @returns {Promise<{xpath: string, preview: string}>} + */ + async _getElementInfo(element) { + return element.evaluate((el) => { + // Generate a unique XPath for this element + const getUniqueXPath = (element) => { + if (element.id) { + return `//*[@id="${element.id}"]` + } + + const parts = [] + let current = element + + while (current && current.nodeType === Node.ELEMENT_NODE) { + let index = 0 + let sibling = current.previousSibling + + while (sibling) { + if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) { + index++ + } + sibling = sibling.previousSibling + } + + const tagName = current.tagName.toLowerCase() + const pathIndex = index > 0 ? `[${index + 1}]` : '' + parts.unshift(`${tagName}${pathIndex}`) + + current = current.parentElement + + // Stop at body to keep XPath reasonable + if (current && current.tagName === 'BODY') { + parts.unshift('body') + break + } + } + + return '/' + parts.join('/') + } + + // Get a preview of the element (tag, classes, id) + const getPreview = (element) => { + const tag = element.tagName.toLowerCase() + const id = element.id ? `#${element.id}` : '' + const classes = element.className + ? '.' + element.className.split(' ').filter(c => c).join('.') + : '' + return `${tag}${id}${classes || ''}` + } + + return { + xpath: getUniqueXPath(el), + preview: getPreview(el), + } + }) + } +} + +export default MultipleElementsFound diff --git a/lib/locator.js b/lib/locator.js index b8eda835c..5ddb3bd7d 100644 --- a/lib/locator.js +++ b/lib/locator.js @@ -40,6 +40,11 @@ class Locator { return } + // Try to parse JSON strings that look like objects + if (this.parsedJsonAsString(locator)) { + return + } + this.type = defaultType || 'fuzzy' this.output = locator this.value = locator @@ -89,6 +94,33 @@ class 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.locator = parsed + this.type = Object.keys(parsed)[0] + this.value = parsed[this.type] + this.strict = true + + Locator.filters.forEach(f => f(parsed, this)) + return true + } + } catch (e) { + // continue with normal string processing + } + return false + } + /** * @returns {string} */ diff --git a/test/data/app/view/form/custom_select.php b/test/data/app/view/form/custom_select.php new file mode 100644 index 000000000..91bcc83cf --- /dev/null +++ b/test/data/app/view/form/custom_select.php @@ -0,0 +1,170 @@ + + + + Custom Select (Combobox/Listbox) + + + +

Custom Select Components

+ +
+ +
+ +
+
London
+
Porto
+
New York
+
Paris
+
+
+ +
+ +
+ +
+ +
+
Berlin
+
Madrid
+
Rome
+
+
+ +
+ +
+ +
+
Red
+
Green
+
Blue
+
Yellow
+
+ +
+ +
+ +
+
Important
+
Urgent
+
Review
+
Later
+
+ +
+ +
Selected: none
+ + + + diff --git a/test/data/app/view/form/strict_mode.php b/test/data/app/view/form/strict_mode.php new file mode 100644 index 000000000..4f520bba9 --- /dev/null +++ b/test/data/app/view/form/strict_mode.php @@ -0,0 +1,48 @@ + + +

Strict Mode Test Page

+ +

Multiple Buttons (Same Class)

+ + + + +

Single Button

+ + +

Multiple Inputs (Same Name)

+
+ + + +
+ +

Single Input

+
+ +
+ +

Multiple Checkboxes (Same Name)

+
+
+
+ +
+ +

Single Checkbox

+
+ +
+ +

Multiple Divs (Same Class)

+
Div 1
+
Div 2
+
Div 3
+ +

Buttons with IDs for fetchDetails test

+ + + + + + diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 3baaf9f36..cbce432f3 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -469,6 +469,52 @@ export function tests() { await I.click('Submit') assert.equal(formContents('age'), 'adult') }) + + describe('custom combobox/listbox', () => { + it('should select from custom combobox by fuzzy label', async () => { + await I.amOnPage('/form/custom_select') + await I.selectOption('Country', 'Porto') + await I.see('country: pt', '#result') + }) + + it('should select from multiple custom comboboxes', async () => { + await I.amOnPage('/form/custom_select') + await I.selectOption('Country', 'New York') + await I.selectOption('City', 'Madrid') + await I.see('country: us', '#result') + await I.see('city: madrid', '#result') + }) + + it('should select from standalone listbox', async () => { + await I.amOnPage('/form/custom_select') + await I.selectOption('Favorite Color', 'Blue') + await I.see('color: blue', '#result') + }) + + it('should select from combobox using strict css locator', async () => { + await I.amOnPage('/form/custom_select') + await I.selectOption({ css: '#country-trigger' }, 'Paris') + await I.see('country: fr', '#result') + }) + + it('should select from listbox using strict css locator', async () => { + await I.amOnPage('/form/custom_select') + await I.selectOption({ css: '#color-listbox' }, 'Green') + await I.see('color: green', '#result') + }) + + it('should select multiple options from multiselect listbox', async () => { + await I.amOnPage('/form/custom_select') + await I.selectOption('Tags', ['Important', 'Urgent']) + await I.see('tags: important,urgent', '#result') + }) + + it('should select multiple options using strict css locator', async () => { + await I.amOnPage('/form/custom_select') + await I.selectOption({ css: '#tags-listbox' }, ['Review', 'Later']) + await I.see('tags: review,later', '#result') + }) + }) }) describe('#executeScript', () => { diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js index c6ec86ecd..070bf8826 100644 --- a/test/unit/locator_test.js +++ b/test/unit/locator_test.js @@ -288,6 +288,95 @@ describe('Locator', () => { expect(l.toString()).to.equal('{id: foo}') }) }) + + 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', () => { + const jsonStr = '{"role": "button", "text": "Save"}' + const l = new Locator(jsonStr) + expect(l.type).to.equal('role') + expect(l.value).to.equal('button') + 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', () => { + 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', () => {