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
63 changes: 60 additions & 3 deletions lib/helper/errors/MultipleElementsFound.js
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -17,20 +72,22 @@ 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++) {
const webEl = this.webElements[i]
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`)
}
Expand Down
99 changes: 99 additions & 0 deletions test/unit/multiple_elements_found_test.js
Original file line number Diff line number Diff line change
@@ -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: '<button>1</button>' },
{ index: 2, xpath: '//html/body/button[2]', html: '<button>2</button>' },
]
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: '<div class="item">' },
{ index: 2, xpath: '//html/body/div[1]/div[1]', html: '<div class="item">' },
{ index: 3, xpath: '//html/body/div[1]/div[2]', html: '<div class="item">' },
]
expect(computeParents(entries)).to.deep.equal([-1, 0, 0])
expect(computeDepths(entries)).to.deep.equal([0, 1, 1])
const items = formatTree(entries, [0, 1, 1], [-1, 0, 0])
expect(items[0]).to.equal(' 1. > //html/body/div[1]\n <div class="item">')
expect(items[1]).to.equal(' 2. (inside 1.) > //html/body/div[1]/div[1]\n <div class="item">')
expect(items[2]).to.equal(' 3. (inside 1.) > //html/body/div[1]/div[2]\n <div class="item">')
})

it('marks the immediate parent for deeper nesting', () => {
const entries = [
{ index: 1, xpath: '//html/body/div[1]', html: '<div>' },
{ index: 2, xpath: '//html/body/div[1]/ul', html: '<ul>' },
{ index: 3, xpath: '//html/body/div[1]/ul/li', html: '<li>' },
{ index: 4, xpath: '//html/body/div[2]', html: '<div>' },
]
expect(computeParents(entries)).to.deep.equal([-1, 0, 1, -1])
expect(computeDepths(entries)).to.deep.equal([0, 1, 2, 0])
const items = formatTree(entries, [0, 1, 2, 0], [-1, 0, 1, -1])
expect(items[2]).to.include('3. (inside 2.) >')
expect(items[3]).to.equal(' 4. > //html/body/div[2]\n <div>')
})

it('renders failed lookups as roots and keeps global numbering', async () => {
const err = new MultipleElementsFound('.item', [
stubWebElement('//html/body/div[1]', '<div class="item">'),
stubWebElement(null, null, true),
stubWebElement('//html/body/div[1]/div[1]', '<div class="item">'),
])
await err.fetchDetails()
expect(err.message).to.include(' 1. > //html/body/div[1]')
expect(err.message).to.include(' 2. [Unable to get element info: detached]')
expect(err.message).to.include(' 3. (inside 1.) > //html/body/div[1]/div[1]')
})

it('renders nested fetchDetails output with indentation', async () => {
const err = new MultipleElementsFound('.item', [
stubWebElement('//html/body/div[1]', '<div class="item">'),
stubWebElement('//html/body/div[1]/div[1]', '<div class="item">'),
stubWebElement('//html/body/div[1]/div[2]', '<div class="item">'),
])
await err.fetchDetails()
const lines = err.message.split('\n')
expect(lines[1]).to.equal(' 1. > //html/body/div[1]')
expect(lines[3]).to.equal(' 2. (inside 1.) > //html/body/div[1]/div[1]')
expect(lines[5]).to.equal(' 3. (inside 1.) > //html/body/div[1]/div[2]')
expect(err.message).to.include('Use a more specific locator')
})
})
Loading