diff --git a/lib/helper/errors/MultipleElementsFound.js b/lib/helper/errors/MultipleElementsFound.js index 4207fa7dd..b19c491a4 100644 --- a/lib/helper/errors/MultipleElementsFound.js +++ b/lib/helper/errors/MultipleElementsFound.js @@ -1,5 +1,60 @@ import Locator from '../../locator.js' +export function splitXPath(xpath) { + if (typeof xpath !== 'string' || xpath.length === 0) return [] + const withoutRoot = xpath.startsWith('//') ? xpath.slice(1) : xpath + return withoutRoot.split('/').filter(Boolean) +} + +export function isAncestorXPath(ancestor, descendant) { + if (!ancestor || !descendant || ancestor === descendant) return false + const ancestorSegments = splitXPath(ancestor) + const descendantSegments = splitXPath(descendant) + if (ancestorSegments.length === 0 || ancestorSegments.length >= descendantSegments.length) return false + return ancestorSegments.every((segment, index) => segment === descendantSegments[index]) +} + +export function computeParents(entries) { + const parents = new Array(entries.length).fill(-1) + const stack = [] + for (let i = 0; i < entries.length; i++) { + const xpath = entries[i].xpath + if (!xpath) continue + while (stack.length > 0 && !isAncestorXPath(entries[stack[stack.length - 1]].xpath, xpath)) { + stack.pop() + } + parents[i] = stack.length > 0 ? stack[stack.length - 1] : -1 + stack.push(i) + } + return parents +} + +export function computeDepths(entries) { + const parents = computeParents(entries) + return parents.map((parent, i) => { + if (!entries[i].xpath) return 0 + let depth = 0 + let current = parent + while (current !== -1) { + depth++ + current = parents[current] + } + return depth + }) +} + +export function formatTree(entries, depths, parents) { + return entries.map((entry, i) => { + const pad = ' '.repeat(depths[i] || 0) + if (entry.error) { + return `${pad} ${entry.index}. [Unable to get element info: ${entry.error}]` + } + const parentPos = parents ? parents[i] : -1 + const nesting = parentPos !== undefined && parentPos !== -1 ? ` (inside ${entries[parentPos].index}.)` : '' + return `${pad} ${entry.index}.${nesting} > ${entry.xpath}\n${pad} ${entry.html}` + }) +} + class MultipleElementsFound extends Error { constructor(locator, webElements) { const locatorStr = (typeof locator === 'object' && !(locator instanceof Locator)) @@ -17,7 +72,7 @@ class MultipleElementsFound extends Error { if (this._detailsFetched) return try { - const items = [] + const entries = [] const maxToShow = Math.min(this.count, 10) for (let i = 0; i < maxToShow; i++) { @@ -25,12 +80,14 @@ class MultipleElementsFound extends Error { try { const xpath = await webEl.toAbsoluteXPath() const html = await webEl.toSimplifiedHTML() - items.push(` ${i + 1}. > ${xpath}\n ${html}`) + entries.push({ index: i + 1, xpath, html }) } catch (err) { - items.push(` ${i + 1}. [Unable to get element info: ${err.message}]`) + entries.push({ index: i + 1, error: err.message }) } } + const items = formatTree(entries, computeDepths(entries), computeParents(entries)) + if (this.count > 10) { items.push(` ... and ${this.count - 10} more`) } diff --git a/test/unit/multiple_elements_found_test.js b/test/unit/multiple_elements_found_test.js new file mode 100644 index 000000000..a8d9dff12 --- /dev/null +++ b/test/unit/multiple_elements_found_test.js @@ -0,0 +1,99 @@ +import { expect } from 'chai' +import MultipleElementsFound, { + computeDepths, + computeParents, + formatTree, + isAncestorXPath, + splitXPath, +} from '../../lib/helper/errors/MultipleElementsFound.js' + +function stubWebElement(xpath, html, shouldThrow) { + return { + toAbsoluteXPath: async () => { + if (shouldThrow) throw new Error('detached') + return xpath + }, + toSimplifiedHTML: async () => { + if (shouldThrow) throw new Error('detached') + return html + }, + } +} + +describe('MultipleElementsFound tree formatting', () => { + it('splits xpath into segments', () => { + expect(splitXPath('//html/body/div[1]/span')).to.deep.equal(['html', 'body', 'div[1]', 'span']) + expect(splitXPath('')).to.deep.equal([]) + expect(splitXPath(null)).to.deep.equal([]) + }) + + it('detects ancestor by segments, not string prefix', () => { + expect(isAncestorXPath('//html/body/div[1]', '//html/body/div[1]/span')).to.equal(true) + expect(isAncestorXPath('//html/body/div[1]', '//html/body/div[10]')).to.equal(false) + expect(isAncestorXPath('//html/body/div[1]', '//html/body/div[1]')).to.equal(false) + expect(isAncestorXPath('//html/body/div[1]/span', '//html/body/div[1]')).to.equal(false) + expect(isAncestorXPath(null, '//html/body')).to.equal(false) + }) + + it('keeps siblings at depth 0', () => { + const entries = [ + { index: 1, xpath: '//html/body/button[1]', html: '' }, + { index: 2, xpath: '//html/body/button[2]', html: '' }, + ] + expect(computeDepths(entries)).to.deep.equal([0, 0]) + }) + + it('indents children of a matched parent', () => { + const entries = [ + { index: 1, xpath: '//html/body/div[1]', html: '