From 327dcdaa3c9b4a5e9a8c33809a077c8260f305d5 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 10 Sep 2026 02:42:26 +0300 Subject: [PATCH 1/2] feat(strict-mode): render nested matches as tree in fetchDetails() --- lib/helper/errors/MultipleElementsFound.js | 47 ++++++++++- test/unit/multiple_elements_found_test.js | 93 ++++++++++++++++++++++ 2 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 test/unit/multiple_elements_found_test.js diff --git a/lib/helper/errors/MultipleElementsFound.js b/lib/helper/errors/MultipleElementsFound.js index 4207fa7dd..627cf8a71 100644 --- a/lib/helper/errors/MultipleElementsFound.js +++ b/lib/helper/errors/MultipleElementsFound.js @@ -1,5 +1,44 @@ 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 computeDepths(entries) { + const depths = new Array(entries.length).fill(0) + 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() + } + depths[i] = stack.length + stack.push(i) + } + return depths +} + +export function formatTree(entries, depths) { + 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}]` + } + return `${pad} ${entry.index}. > ${entry.xpath}\n${pad} ${entry.html}` + }) +} + class MultipleElementsFound extends Error { constructor(locator, webElements) { const locatorStr = (typeof locator === 'object' && !(locator instanceof Locator)) @@ -17,7 +56,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 +64,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)) + 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..42a7c188a --- /dev/null +++ b/test/unit/multiple_elements_found_test.js @@ -0,0 +1,93 @@ +import { expect } from 'chai' +import MultipleElementsFound, { + computeDepths, + 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: '
' }, + { index: 2, xpath: '//html/body/div[1]/div[1]', html: '
' }, + { index: 3, xpath: '//html/body/div[1]/div[2]', html: '
' }, + ] + expect(computeDepths(entries)).to.deep.equal([0, 1, 1]) + const items = formatTree(entries, [0, 1, 1]) + expect(items[0]).to.equal(' 1. > //html/body/div[1]\n
') + expect(items[1]).to.equal(' 2. > //html/body/div[1]/div[1]\n
') + expect(items[2]).to.equal(' 3. > //html/body/div[1]/div[2]\n
') + }) + + it('supports deeper nesting and returns to root level', () => { + const entries = [ + { index: 1, xpath: '//html/body/div[1]', html: '
' }, + { index: 2, xpath: '//html/body/div[1]/ul', html: '
    ' }, + { index: 3, xpath: '//html/body/div[1]/ul/li', html: '
  • ' }, + { index: 4, xpath: '//html/body/div[2]', html: '
    ' }, + ] + expect(computeDepths(entries)).to.deep.equal([0, 1, 2, 0]) + }) + + it('renders failed lookups as roots and keeps global numbering', async () => { + const err = new MultipleElementsFound('.item', [ + stubWebElement('//html/body/div[1]', '
    '), + stubWebElement(null, null, true), + stubWebElement('//html/body/div[1]/div[1]', '
    '), + ]) + 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. > //html/body/div[1]/div[1]') + }) + + it('renders nested fetchDetails output with indentation', async () => { + const err = new MultipleElementsFound('.item', [ + stubWebElement('//html/body/div[1]', '
    '), + stubWebElement('//html/body/div[1]/div[1]', '
    '), + stubWebElement('//html/body/div[1]/div[2]', '
    '), + ]) + 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. > //html/body/div[1]/div[1]') + expect(lines[5]).to.equal(' 3. > //html/body/div[1]/div[2]') + expect(err.message).to.include('Use a more specific locator') + }) +}) From 73d6258106c56c5ad63d49d85cd4dc65375a35cc Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 10 Sep 2026 02:45:46 +0300 Subject: [PATCH 2/2] feat(strict-mode): mark nested matches with explicit (inside N.) label --- lib/helper/errors/MultipleElementsFound.js | 30 +++++++++++++++++----- test/unit/multiple_elements_found_test.js | 20 ++++++++++----- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/lib/helper/errors/MultipleElementsFound.js b/lib/helper/errors/MultipleElementsFound.js index 627cf8a71..b19c491a4 100644 --- a/lib/helper/errors/MultipleElementsFound.js +++ b/lib/helper/errors/MultipleElementsFound.js @@ -14,8 +14,8 @@ export function isAncestorXPath(ancestor, descendant) { return ancestorSegments.every((segment, index) => segment === descendantSegments[index]) } -export function computeDepths(entries) { - const depths = new Array(entries.length).fill(0) +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 @@ -23,19 +23,35 @@ export function computeDepths(entries) { while (stack.length > 0 && !isAncestorXPath(entries[stack[stack.length - 1]].xpath, xpath)) { stack.pop() } - depths[i] = stack.length + parents[i] = stack.length > 0 ? stack[stack.length - 1] : -1 stack.push(i) } - return depths + 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) { +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}]` } - return `${pad} ${entry.index}. > ${entry.xpath}\n${pad} ${entry.html}` + 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}` }) } @@ -70,7 +86,7 @@ class MultipleElementsFound extends Error { } } - const items = formatTree(entries, computeDepths(entries)) + 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 index 42a7c188a..a8d9dff12 100644 --- a/test/unit/multiple_elements_found_test.js +++ b/test/unit/multiple_elements_found_test.js @@ -1,6 +1,7 @@ import { expect } from 'chai' import MultipleElementsFound, { computeDepths, + computeParents, formatTree, isAncestorXPath, splitXPath, @@ -48,21 +49,26 @@ describe('MultipleElementsFound tree formatting', () => { { index: 2, xpath: '//html/body/div[1]/div[1]', html: '
    ' }, { index: 3, xpath: '//html/body/div[1]/div[2]', html: '
    ' }, ] + 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]) + const items = formatTree(entries, [0, 1, 1], [-1, 0, 0]) expect(items[0]).to.equal(' 1. > //html/body/div[1]\n
    ') - expect(items[1]).to.equal(' 2. > //html/body/div[1]/div[1]\n
    ') - expect(items[2]).to.equal(' 3. > //html/body/div[1]/div[2]\n
    ') + expect(items[1]).to.equal(' 2. (inside 1.) > //html/body/div[1]/div[1]\n
    ') + expect(items[2]).to.equal(' 3. (inside 1.) > //html/body/div[1]/div[2]\n
    ') }) - it('supports deeper nesting and returns to root level', () => { + it('marks the immediate parent for deeper nesting', () => { const entries = [ { index: 1, xpath: '//html/body/div[1]', html: '
    ' }, { index: 2, xpath: '//html/body/div[1]/ul', html: '
      ' }, { index: 3, xpath: '//html/body/div[1]/ul/li', html: '
    • ' }, { index: 4, xpath: '//html/body/div[2]', html: '
      ' }, ] + 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
      ') }) it('renders failed lookups as roots and keeps global numbering', async () => { @@ -74,7 +80,7 @@ describe('MultipleElementsFound tree formatting', () => { 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. > //html/body/div[1]/div[1]') + expect(err.message).to.include(' 3. (inside 1.) > //html/body/div[1]/div[1]') }) it('renders nested fetchDetails output with indentation', async () => { @@ -86,8 +92,8 @@ describe('MultipleElementsFound tree formatting', () => { 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. > //html/body/div[1]/div[1]') - expect(lines[5]).to.equal(' 3. > //html/body/div[1]/div[2]') + 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') }) })