Skip to content

Commit c670e49

Browse files
DavertMikclaude
andcommitted
feat: improve MultipleElementsFound error and add strict mode to all helpers
Add strict mode support to Puppeteer and WebDriver helpers (previously only Playwright). When `strict: true`, single-element operations (click, fillField, etc.) throw MultipleElementsFound if more than one element matches. Refactor MultipleElementsFound to use WebElement[] with async fetchDetails() that shows absolute XPath and minified outerHTML for each matched element. Add toAbsoluteXPath() and toOuterHTML() to WebElement class. Auto-call fetchDetails() in CLI error display. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0c0a519 commit c670e49

File tree

7 files changed

+198
-123
lines changed

7 files changed

+198
-123
lines changed

lib/element/WebElement.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,52 @@ class WebElement {
306306
* @returns {string} Normalized CSS selector
307307
* @private
308308
*/
309+
async toAbsoluteXPath() {
310+
const xpathFn = (el) => {
311+
const parts = []
312+
let current = el
313+
while (current && current.nodeType === Node.ELEMENT_NODE) {
314+
let index = 0
315+
let sibling = current.previousSibling
316+
while (sibling) {
317+
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) {
318+
index++
319+
}
320+
sibling = sibling.previousSibling
321+
}
322+
const tagName = current.tagName.toLowerCase()
323+
const pathIndex = index > 0 ? `[${index + 1}]` : ''
324+
parts.unshift(`${tagName}${pathIndex}`)
325+
current = current.parentElement
326+
}
327+
return '/' + parts.join('/')
328+
}
329+
330+
switch (this.helperType) {
331+
case 'playwright':
332+
return this.element.evaluate(xpathFn)
333+
case 'puppeteer':
334+
return this.element.evaluate(xpathFn)
335+
case 'webdriver':
336+
return this.helper.browser.execute(xpathFn, this.element)
337+
default:
338+
throw new Error(`Unsupported helper type: ${this.helperType}`)
339+
}
340+
}
341+
342+
async toOuterHTML() {
343+
switch (this.helperType) {
344+
case 'playwright':
345+
return this.element.evaluate(el => el.outerHTML)
346+
case 'puppeteer':
347+
return this.element.evaluate(el => el.outerHTML)
348+
case 'webdriver':
349+
return this.helper.browser.execute(el => el.outerHTML, this.element)
350+
default:
351+
throw new Error(`Unsupported helper type: ${this.helperType}`)
352+
}
353+
}
354+
309355
_normalizeLocator(locator) {
310356
if (typeof locator === 'string') {
311357
return locator

lib/helper/Playwright.js

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,7 +1763,7 @@ class Playwright extends Helper {
17631763
if (elements.length === 0) {
17641764
throw new ElementNotFound(locator, 'Element', 'was not found')
17651765
}
1766-
if (this.options.strict) assertOnlyOneElement(elements, locator)
1766+
if (this.options.strict) assertOnlyOneElement(elements, locator, this)
17671767
return elements[0]
17681768
}
17691769

@@ -1779,7 +1779,7 @@ class Playwright extends Helper {
17791779
const context = providedContext || (await this._getContext())
17801780
const els = await findCheckable.call(this, locator, context)
17811781
assertElementExists(els[0], locator, 'Checkbox or radio')
1782-
if (this.options.strict) assertOnlyOneElement(els, locator)
1782+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
17831783
return els[0]
17841784
}
17851785

@@ -2266,7 +2266,7 @@ class Playwright extends Helper {
22662266
async fillField(field, value, context = null) {
22672267
const els = await findFields.call(this, field, context)
22682268
assertElementExists(els, field, 'Field')
2269-
if (this.options.strict) assertOnlyOneElement(els, field)
2269+
if (this.options.strict) assertOnlyOneElement(els, field, this)
22702270
const el = els[0]
22712271

22722272
await el.clear()
@@ -2285,7 +2285,7 @@ class Playwright extends Helper {
22852285
async clearField(locator, context = null) {
22862286
const els = await findFields.call(this, locator, context)
22872287
assertElementExists(els, locator, 'Field to clear')
2288-
if (this.options.strict) assertOnlyOneElement(els, locator)
2288+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
22892289

22902290
const el = els[0]
22912291

@@ -2302,7 +2302,7 @@ class Playwright extends Helper {
23022302
async appendField(field, value, context = null) {
23032303
const els = await findFields.call(this, field, context)
23042304
assertElementExists(els, field, 'Field')
2305-
if (this.options.strict) assertOnlyOneElement(els, field)
2305+
if (this.options.strict) assertOnlyOneElement(els, field, this)
23062306
await highlightActiveElement.call(this, els[0])
23072307
await els[0].press('End')
23082308
await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
@@ -4300,7 +4300,7 @@ async function findClickable(matcher, locator) {
43004300

43014301
if (!matchedLocator.isFuzzy()) {
43024302
const els = await findElements.call(this, matcher, matchedLocator)
4303-
if (this.options.strict) assertOnlyOneElement(els, locator)
4303+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
43044304
return els
43054305
}
43064306

@@ -4310,7 +4310,7 @@ async function findClickable(matcher, locator) {
43104310
try {
43114311
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
43124312
if (els.length) {
4313-
if (this.options.strict) assertOnlyOneElement(els, locator)
4313+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
43144314
return els
43154315
}
43164316
} catch (err) {
@@ -4320,7 +4320,7 @@ async function findClickable(matcher, locator) {
43204320
try {
43214321
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
43224322
if (els.length) {
4323-
if (this.options.strict) assertOnlyOneElement(els, locator)
4323+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
43244324
return els
43254325
}
43264326
} catch (err) {
@@ -4329,20 +4329,20 @@ async function findClickable(matcher, locator) {
43294329

43304330
els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
43314331
if (els.length) {
4332-
if (this.options.strict) assertOnlyOneElement(els, locator)
4332+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
43334333
return els
43344334
}
43354335

43364336
els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
43374337
if (els.length) {
4338-
if (this.options.strict) assertOnlyOneElement(els, locator)
4338+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
43394339
return els
43404340
}
43414341

43424342
try {
43434343
els = await findElements.call(this, matcher, Locator.clickable.self(literal))
43444344
if (els.length) {
4345-
if (this.options.strict) assertOnlyOneElement(els, locator)
4345+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
43464346
return els
43474347
}
43484348
} catch (err) {
@@ -4619,9 +4619,10 @@ function assertElementExists(res, locator, prefix, suffix) {
46194619
}
46204620
}
46214621

4622-
function assertOnlyOneElement(elements, locator) {
4622+
function assertOnlyOneElement(elements, locator, helper) {
46234623
if (elements.length > 1) {
4624-
throw new MultipleElementsFound(locator, elements)
4624+
const webElements = elements.map(el => new WebElement(el, helper))
4625+
throw new MultipleElementsFound(locator, webElements)
46254626
}
46264627
}
46274628

lib/helper/Puppeteer.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
} from '../utils.js'
3434
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
3535
import ElementNotFound from './errors/ElementNotFound.js'
36+
import MultipleElementsFound from './errors/MultipleElementsFound.js'
3637
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
3738
import Popup from './extras/Popup.js'
3839
import Console from './extras/Console.js'
@@ -270,6 +271,7 @@ class Puppeteer extends Helper {
270271
show: false,
271272
defaultPopupAction: 'accept',
272273
highlightElement: false,
274+
strict: false,
273275
}
274276

275277
return Object.assign(defaults, config)
@@ -988,6 +990,14 @@ class Puppeteer extends Helper {
988990
*/
989991
async _locateElement(locator) {
990992
const context = await this.context
993+
if (this.options.strict) {
994+
const elements = await findElements.call(this, context, locator)
995+
if (elements.length === 0) {
996+
throw new ElementNotFound(locator, 'Element', 'was not found')
997+
}
998+
assertOnlyOneElement(elements, locator, this)
999+
return elements[0]
1000+
}
9911001
return findElement.call(this, context, locator)
9921002
}
9931003

@@ -1005,6 +1015,7 @@ class Puppeteer extends Helper {
10051015
if (!els || els.length === 0) {
10061016
throw new ElementNotFound(locator, 'Checkbox or radio')
10071017
}
1018+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
10081019
return els[0]
10091020
}
10101021

@@ -1564,6 +1575,7 @@ class Puppeteer extends Helper {
15641575
async fillField(field, value, context = null) {
15651576
const els = await findVisibleFields.call(this, field, context)
15661577
assertElementExists(els, field, 'Field')
1578+
if (this.options.strict) assertOnlyOneElement(els, field, this)
15671579
const el = els[0]
15681580
const tag = await el.getProperty('tagName').then(el => el.jsonValue())
15691581
const editable = await el.getProperty('contenteditable').then(el => el.jsonValue())
@@ -1594,6 +1606,7 @@ class Puppeteer extends Helper {
15941606
async appendField(field, value, context = null) {
15951607
const els = await findVisibleFields.call(this, field, context)
15961608
assertElementExists(els, field, 'Field')
1609+
if (this.options.strict) assertOnlyOneElement(els, field, this)
15971610
highlightActiveElement.call(this, els[0], await this._getContext())
15981611
await els[0].press('End')
15991612
await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
@@ -3098,6 +3111,7 @@ async function proceedClick(locator, context = null, options = {}) {
30983111
} else {
30993112
assertElementExists(els, locator, 'Clickable element')
31003113
}
3114+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
31013115

31023116
highlightActiveElement.call(this, els[0], await this._getContext())
31033117

@@ -3425,6 +3439,13 @@ function assertElementExists(res, locator, prefix, suffix) {
34253439
}
34263440
}
34273441

3442+
function assertOnlyOneElement(elements, locator, helper) {
3443+
if (elements.length > 1) {
3444+
const webElements = elements.map(el => new WebElement(el, helper))
3445+
throw new MultipleElementsFound(locator, webElements)
3446+
}
3447+
}
3448+
34283449
function $XPath(element, selector) {
34293450
const found = document.evaluate(selector, element || document.body, null, 5, null)
34303451
const res = []

lib/helper/WebDriver.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from '../utils.js'
3131
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
3232
import ElementNotFound from './errors/ElementNotFound.js'
33+
import MultipleElementsFound from './errors/MultipleElementsFound.js'
3334
import ConnectionRefused from './errors/ConnectionRefused.js'
3435
import Locator from '../locator.js'
3536
import { highlightElement } from './scripts/highlightElement.js'
@@ -503,6 +504,7 @@ class WebDriver extends Helper {
503504
keepBrowserState: false,
504505
deprecationWarnings: false,
505506
highlightElement: false,
507+
strict: false,
506508
}
507509

508510
// override defaults with config
@@ -1090,6 +1092,7 @@ class WebDriver extends Helper {
10901092
} else {
10911093
assertElementExists(res, locator, 'Clickable element')
10921094
}
1095+
if (this.options.strict) assertOnlyOneElement(res, locator, this)
10931096
const elem = usingFirstElement(res)
10941097
highlightActiveElement.call(this, elem)
10951098
return this.browser[clickMethod](getElementId(elem))
@@ -1109,6 +1112,7 @@ class WebDriver extends Helper {
11091112
} else {
11101113
assertElementExists(res, locator, 'Clickable element')
11111114
}
1115+
if (this.options.strict) assertOnlyOneElement(res, locator, this)
11121116
const elem = usingFirstElement(res)
11131117
highlightActiveElement.call(this, elem)
11141118

@@ -1136,6 +1140,7 @@ class WebDriver extends Helper {
11361140
} else {
11371141
assertElementExists(res, locator, 'Clickable element')
11381142
}
1143+
if (this.options.strict) assertOnlyOneElement(res, locator, this)
11391144

11401145
const elem = usingFirstElement(res)
11411146
highlightActiveElement.call(this, elem)
@@ -1156,6 +1161,7 @@ class WebDriver extends Helper {
11561161
} else {
11571162
assertElementExists(res, locator, 'Clickable element')
11581163
}
1164+
if (this.options.strict) assertOnlyOneElement(res, locator, this)
11591165

11601166
const el = usingFirstElement(res)
11611167

@@ -1272,6 +1278,7 @@ class WebDriver extends Helper {
12721278
async fillField(field, value, context = null) {
12731279
const res = await findFields.call(this, field, context)
12741280
assertElementExists(res, field, 'Field')
1281+
if (this.options.strict) assertOnlyOneElement(res, field, this)
12751282
const elem = usingFirstElement(res)
12761283
highlightActiveElement.call(this, elem)
12771284
try {
@@ -1295,6 +1302,7 @@ class WebDriver extends Helper {
12951302
async appendField(field, value, context = null) {
12961303
const res = await findFields.call(this, field, context)
12971304
assertElementExists(res, field, 'Field')
1305+
if (this.options.strict) assertOnlyOneElement(res, field, this)
12981306
const elem = usingFirstElement(res)
12991307
highlightActiveElement.call(this, elem)
13001308
return elem.addValue(value.toString())
@@ -1307,6 +1315,7 @@ class WebDriver extends Helper {
13071315
async clearField(field, context = null) {
13081316
const res = await findFields.call(this, field, context)
13091317
assertElementExists(res, field, 'Field')
1318+
if (this.options.strict) assertOnlyOneElement(res, field, this)
13101319
const elem = usingFirstElement(res)
13111320
highlightActiveElement.call(this, elem)
13121321
return elem.clearValue(getElementId(elem))
@@ -3289,6 +3298,13 @@ function usingFirstElement(els) {
32893298
return els[0]
32903299
}
32913300

3301+
function assertOnlyOneElement(elements, locator, helper) {
3302+
if (elements.length > 1) {
3303+
const webElements = elements.map(el => new WebElement(el, helper))
3304+
throw new MultipleElementsFound(locator, webElements)
3305+
}
3306+
}
3307+
32923308
function getElementId(el) {
32933309
// W3C WebDriver web element identifier
32943310
// https://w3c.github.io/webdriver/#dfn-web-element-identifier

0 commit comments

Comments
 (0)