Skip to content

Commit 460c26f

Browse files
DavertMikDavertMik
andauthored
feat(strict-mode): render nested matches as tree in fetchDetails() (#5710)
* feat(strict-mode): render nested matches as tree in fetchDetails() * feat(strict-mode): mark nested matches with explicit (inside N.) label --------- Co-authored-by: DavertMik <davert@testomat.io>
1 parent d2f7a2a commit 460c26f

2 files changed

Lines changed: 159 additions & 3 deletions

File tree

lib/helper/errors/MultipleElementsFound.js

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,60 @@
11
import Locator from '../../locator.js'
22

3+
export function splitXPath(xpath) {
4+
if (typeof xpath !== 'string' || xpath.length === 0) return []
5+
const withoutRoot = xpath.startsWith('//') ? xpath.slice(1) : xpath
6+
return withoutRoot.split('/').filter(Boolean)
7+
}
8+
9+
export function isAncestorXPath(ancestor, descendant) {
10+
if (!ancestor || !descendant || ancestor === descendant) return false
11+
const ancestorSegments = splitXPath(ancestor)
12+
const descendantSegments = splitXPath(descendant)
13+
if (ancestorSegments.length === 0 || ancestorSegments.length >= descendantSegments.length) return false
14+
return ancestorSegments.every((segment, index) => segment === descendantSegments[index])
15+
}
16+
17+
export function computeParents(entries) {
18+
const parents = new Array(entries.length).fill(-1)
19+
const stack = []
20+
for (let i = 0; i < entries.length; i++) {
21+
const xpath = entries[i].xpath
22+
if (!xpath) continue
23+
while (stack.length > 0 && !isAncestorXPath(entries[stack[stack.length - 1]].xpath, xpath)) {
24+
stack.pop()
25+
}
26+
parents[i] = stack.length > 0 ? stack[stack.length - 1] : -1
27+
stack.push(i)
28+
}
29+
return parents
30+
}
31+
32+
export function computeDepths(entries) {
33+
const parents = computeParents(entries)
34+
return parents.map((parent, i) => {
35+
if (!entries[i].xpath) return 0
36+
let depth = 0
37+
let current = parent
38+
while (current !== -1) {
39+
depth++
40+
current = parents[current]
41+
}
42+
return depth
43+
})
44+
}
45+
46+
export function formatTree(entries, depths, parents) {
47+
return entries.map((entry, i) => {
48+
const pad = ' '.repeat(depths[i] || 0)
49+
if (entry.error) {
50+
return `${pad} ${entry.index}. [Unable to get element info: ${entry.error}]`
51+
}
52+
const parentPos = parents ? parents[i] : -1
53+
const nesting = parentPos !== undefined && parentPos !== -1 ? ` (inside ${entries[parentPos].index}.)` : ''
54+
return `${pad} ${entry.index}.${nesting} > ${entry.xpath}\n${pad} ${entry.html}`
55+
})
56+
}
57+
358
class MultipleElementsFound extends Error {
459
constructor(locator, webElements) {
560
const locatorStr = (typeof locator === 'object' && !(locator instanceof Locator))
@@ -17,20 +72,22 @@ class MultipleElementsFound extends Error {
1772
if (this._detailsFetched) return
1873

1974
try {
20-
const items = []
75+
const entries = []
2176
const maxToShow = Math.min(this.count, 10)
2277

2378
for (let i = 0; i < maxToShow; i++) {
2479
const webEl = this.webElements[i]
2580
try {
2681
const xpath = await webEl.toAbsoluteXPath()
2782
const html = await webEl.toSimplifiedHTML()
28-
items.push(` ${i + 1}. > ${xpath}\n ${html}`)
83+
entries.push({ index: i + 1, xpath, html })
2984
} catch (err) {
30-
items.push(` ${i + 1}. [Unable to get element info: ${err.message}]`)
85+
entries.push({ index: i + 1, error: err.message })
3186
}
3287
}
3388

89+
const items = formatTree(entries, computeDepths(entries), computeParents(entries))
90+
3491
if (this.count > 10) {
3592
items.push(` ... and ${this.count - 10} more`)
3693
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { expect } from 'chai'
2+
import MultipleElementsFound, {
3+
computeDepths,
4+
computeParents,
5+
formatTree,
6+
isAncestorXPath,
7+
splitXPath,
8+
} from '../../lib/helper/errors/MultipleElementsFound.js'
9+
10+
function stubWebElement(xpath, html, shouldThrow) {
11+
return {
12+
toAbsoluteXPath: async () => {
13+
if (shouldThrow) throw new Error('detached')
14+
return xpath
15+
},
16+
toSimplifiedHTML: async () => {
17+
if (shouldThrow) throw new Error('detached')
18+
return html
19+
},
20+
}
21+
}
22+
23+
describe('MultipleElementsFound tree formatting', () => {
24+
it('splits xpath into segments', () => {
25+
expect(splitXPath('//html/body/div[1]/span')).to.deep.equal(['html', 'body', 'div[1]', 'span'])
26+
expect(splitXPath('')).to.deep.equal([])
27+
expect(splitXPath(null)).to.deep.equal([])
28+
})
29+
30+
it('detects ancestor by segments, not string prefix', () => {
31+
expect(isAncestorXPath('//html/body/div[1]', '//html/body/div[1]/span')).to.equal(true)
32+
expect(isAncestorXPath('//html/body/div[1]', '//html/body/div[10]')).to.equal(false)
33+
expect(isAncestorXPath('//html/body/div[1]', '//html/body/div[1]')).to.equal(false)
34+
expect(isAncestorXPath('//html/body/div[1]/span', '//html/body/div[1]')).to.equal(false)
35+
expect(isAncestorXPath(null, '//html/body')).to.equal(false)
36+
})
37+
38+
it('keeps siblings at depth 0', () => {
39+
const entries = [
40+
{ index: 1, xpath: '//html/body/button[1]', html: '<button>1</button>' },
41+
{ index: 2, xpath: '//html/body/button[2]', html: '<button>2</button>' },
42+
]
43+
expect(computeDepths(entries)).to.deep.equal([0, 0])
44+
})
45+
46+
it('indents children of a matched parent', () => {
47+
const entries = [
48+
{ index: 1, xpath: '//html/body/div[1]', html: '<div class="item">' },
49+
{ index: 2, xpath: '//html/body/div[1]/div[1]', html: '<div class="item">' },
50+
{ index: 3, xpath: '//html/body/div[1]/div[2]', html: '<div class="item">' },
51+
]
52+
expect(computeParents(entries)).to.deep.equal([-1, 0, 0])
53+
expect(computeDepths(entries)).to.deep.equal([0, 1, 1])
54+
const items = formatTree(entries, [0, 1, 1], [-1, 0, 0])
55+
expect(items[0]).to.equal(' 1. > //html/body/div[1]\n <div class="item">')
56+
expect(items[1]).to.equal(' 2. (inside 1.) > //html/body/div[1]/div[1]\n <div class="item">')
57+
expect(items[2]).to.equal(' 3. (inside 1.) > //html/body/div[1]/div[2]\n <div class="item">')
58+
})
59+
60+
it('marks the immediate parent for deeper nesting', () => {
61+
const entries = [
62+
{ index: 1, xpath: '//html/body/div[1]', html: '<div>' },
63+
{ index: 2, xpath: '//html/body/div[1]/ul', html: '<ul>' },
64+
{ index: 3, xpath: '//html/body/div[1]/ul/li', html: '<li>' },
65+
{ index: 4, xpath: '//html/body/div[2]', html: '<div>' },
66+
]
67+
expect(computeParents(entries)).to.deep.equal([-1, 0, 1, -1])
68+
expect(computeDepths(entries)).to.deep.equal([0, 1, 2, 0])
69+
const items = formatTree(entries, [0, 1, 2, 0], [-1, 0, 1, -1])
70+
expect(items[2]).to.include('3. (inside 2.) >')
71+
expect(items[3]).to.equal(' 4. > //html/body/div[2]\n <div>')
72+
})
73+
74+
it('renders failed lookups as roots and keeps global numbering', async () => {
75+
const err = new MultipleElementsFound('.item', [
76+
stubWebElement('//html/body/div[1]', '<div class="item">'),
77+
stubWebElement(null, null, true),
78+
stubWebElement('//html/body/div[1]/div[1]', '<div class="item">'),
79+
])
80+
await err.fetchDetails()
81+
expect(err.message).to.include(' 1. > //html/body/div[1]')
82+
expect(err.message).to.include(' 2. [Unable to get element info: detached]')
83+
expect(err.message).to.include(' 3. (inside 1.) > //html/body/div[1]/div[1]')
84+
})
85+
86+
it('renders nested fetchDetails output with indentation', async () => {
87+
const err = new MultipleElementsFound('.item', [
88+
stubWebElement('//html/body/div[1]', '<div class="item">'),
89+
stubWebElement('//html/body/div[1]/div[1]', '<div class="item">'),
90+
stubWebElement('//html/body/div[1]/div[2]', '<div class="item">'),
91+
])
92+
await err.fetchDetails()
93+
const lines = err.message.split('\n')
94+
expect(lines[1]).to.equal(' 1. > //html/body/div[1]')
95+
expect(lines[3]).to.equal(' 2. (inside 1.) > //html/body/div[1]/div[1]')
96+
expect(lines[5]).to.equal(' 3. (inside 1.) > //html/body/div[1]/div[2]')
97+
expect(err.message).to.include('Use a more specific locator')
98+
})
99+
})

0 commit comments

Comments
 (0)