Skip to content

Commit 604a651

Browse files
author
DavertMik
committed
added strict mode to PW
1 parent b5d81a0 commit 604a651

File tree

4 files changed

+372
-20
lines changed

4 files changed

+372
-20
lines changed

lib/helper/Playwright.js

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '../utils.js'
2828
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
2929
import ElementNotFound from './errors/ElementNotFound.js'
30+
import MultipleElementsFound from './errors/MultipleElementsFound.js'
3031
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
3132
import Popup from './extras/Popup.js'
3233
import Console from './extras/Console.js'
@@ -107,6 +108,9 @@ const pathSeparator = path.sep
107108
* those cookies are used instead and the configured `storageState` is ignored (no merge).
108109
* May include session cookies, auth tokens, localStorage and (if captured with
109110
* `grabStorageState({ indexedDB: true })`) IndexedDB data; treat as sensitive and do not commit.
111+
* @prop {boolean} [strict=false] - throw error when multiple elements match a single-element locator.
112+
* When enabled, methods like `click`, `fillField`, `selectOption`, etc. will throw a
113+
* `MultipleElementsFound` error if more than one element matches the locator.
110114
*/
111115
const config = {}
112116

@@ -417,6 +421,7 @@ class Playwright extends Helper {
417421
highlightElement: false,
418422
storageState: undefined,
419423
onResponse: null,
424+
strict: false, // Throw error when multiple elements match single-element locator
420425
}
421426

422427
process.env.testIdAttribute = 'data-testid'
@@ -1912,7 +1917,12 @@ class Playwright extends Helper {
19121917
*/
19131918
async _locateElement(locator) {
19141919
const context = await this._getContext()
1915-
return findElement(context, locator)
1920+
const elements = await findElements.call(this, context, locator)
1921+
if (elements.length === 0) {
1922+
throw new ElementNotFound(locator, 'Element', 'was not found')
1923+
}
1924+
if (this.options.strict) assertOnlyOneElement(elements, locator)
1925+
return elements[0]
19161926
}
19171927

19181928
/**
@@ -1927,6 +1937,7 @@ class Playwright extends Helper {
19271937
const context = providedContext || (await this._getContext())
19281938
const els = await findCheckable.call(this, locator, context)
19291939
assertElementExists(els[0], locator, 'Checkbox or radio')
1940+
if (this.options.strict) assertOnlyOneElement(els, locator)
19301941
return els[0]
19311942
}
19321943

@@ -2399,6 +2410,7 @@ class Playwright extends Helper {
23992410
async fillField(field, value) {
24002411
const els = await findFields.call(this, field)
24012412
assertElementExists(els, field, 'Field')
2413+
if (this.options.strict) assertOnlyOneElement(els, field)
24022414
const el = els[0]
24032415

24042416
await el.clear()
@@ -2431,6 +2443,7 @@ class Playwright extends Helper {
24312443
async clearField(locator, options = {}) {
24322444
const els = await findFields.call(this, locator)
24332445
assertElementExists(els, locator, 'Field to clear')
2446+
if (this.options.strict) assertOnlyOneElement(els, locator)
24342447

24352448
const el = els[0]
24362449

@@ -2447,6 +2460,7 @@ class Playwright extends Helper {
24472460
async appendField(field, value) {
24482461
const els = await findFields.call(this, field)
24492462
assertElementExists(els, field, 'Field')
2463+
if (this.options.strict) assertOnlyOneElement(els, field)
24502464
await highlightActiveElement.call(this, els[0])
24512465
await els[0].press('End')
24522466
await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
@@ -4444,20 +4458,6 @@ async function findCustomElements(matcher, locator) {
44444458
return locators
44454459
}
44464460

4447-
async function findElement(matcher, locator) {
4448-
const matchedLocator = Locator.from(locator)
4449-
const roleElements = await findByRole(matcher, matchedLocator)
4450-
if (roleElements && roleElements.length > 0) return roleElements[0]
4451-
4452-
const isReactLocator = matchedLocator.type === 'react'
4453-
const isVueLocator = matchedLocator.type === 'vue'
4454-
4455-
if (isReactLocator) return findReact(matcher, matchedLocator)
4456-
if (isVueLocator) return findVue(matcher, matchedLocator)
4457-
4458-
return matcher.locator(buildLocatorString(matchedLocator)).first()
4459-
}
4460-
44614461
async function getVisibleElements(elements) {
44624462
const visibleElements = []
44634463
for (const element of elements) {
@@ -4511,20 +4511,33 @@ async function findClickable(matcher, locator) {
45114511
if (locator.vue) return findVue(matcher, locator)
45124512

45134513
locator = new Locator(locator)
4514-
if (!locator.isFuzzy()) return findElements.call(this, matcher, locator)
4514+
if (!locator.isFuzzy()) {
4515+
const els = await findElements.call(this, matcher, locator)
4516+
if (this.options.strict) assertOnlyOneElement(els, locator)
4517+
return els
4518+
}
45154519

45164520
let els
45174521
const literal = xpathLocator.literal(locator.value)
45184522

45194523
els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
4520-
if (els.length) return els
4524+
if (els.length) {
4525+
if (this.options.strict) assertOnlyOneElement(els, locator)
4526+
return els
4527+
}
45214528

45224529
els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
4523-
if (els.length) return els
4530+
if (els.length) {
4531+
if (this.options.strict) assertOnlyOneElement(els, locator)
4532+
return els
4533+
}
45244534

45254535
try {
45264536
els = await findElements.call(this, matcher, Locator.clickable.self(literal))
4527-
if (els.length) return els
4537+
if (els.length) {
4538+
if (this.options.strict) assertOnlyOneElement(els, locator)
4539+
return els
4540+
}
45284541
} catch (err) {
45294542
// Do nothing
45304543
}
@@ -4782,6 +4795,12 @@ function assertElementExists(res, locator, prefix, suffix) {
47824795
}
47834796
}
47844797

4798+
function assertOnlyOneElement(elements, locator) {
4799+
if (elements.length > 1) {
4800+
throw new MultipleElementsFound(locator, elements)
4801+
}
4802+
}
4803+
47854804
function $XPath(element, selector) {
47864805
const found = document.evaluate(selector, element || document.body, null, 5, null)
47874806
const res = []
@@ -5018,7 +5037,7 @@ async function saveTraceForContext(context, name) {
50185037
}
50195038

50205039
async function highlightActiveElement(element) {
5021-
if ((this.options.highlightElement || store.onPause) && store.debugMode) {
5040+
if (this.options.highlightElement || store.onPause || store.debugMode) {
50225041
await element.evaluate(el => {
50235042
const prevStyle = el.style.boxShadow
50245043
el.style.boxShadow = '0px 0px 4px 3px rgba(147, 51, 234, 0.8)' // Bright purple that works on both dark/light modes
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import Locator from '../../locator.js'
2+
3+
/**
4+
* Error thrown when strict mode is enabled and multiple elements are found
5+
* for a single-element locator operation (click, fillField, etc.)
6+
*/
7+
class MultipleElementsFound extends Error {
8+
/**
9+
* @param {Locator|string|object} locator - The locator used
10+
* @param {Array<HTMLElement>} elements - Array of Playwright element handles found
11+
*/
12+
constructor(locator, elements) {
13+
super(`Multiple elements (${elements.length}) found for "${locator}". Call fetchDetails() for full information.`)
14+
this.name = 'MultipleElementsFound'
15+
this.locator = locator
16+
this.elements = elements
17+
this.count = elements.length
18+
this._detailsFetched = false
19+
}
20+
21+
/**
22+
* Fetch detailed information about the found elements asynchronously
23+
* This updates the error message with XPath and element previews
24+
*/
25+
async fetchDetails() {
26+
if (this._detailsFetched) return
27+
28+
try {
29+
if (typeof this.locator === 'object' && !(this.locator instanceof Locator)) {
30+
this.locator = JSON.stringify(this.locator)
31+
}
32+
33+
const locatorObj = new Locator(this.locator)
34+
const elementList = await this._generateElementList(this.elements, this.count)
35+
36+
this.message = `Multiple elements (${this.count}) found for "${locatorObj.toString()}" in strict mode.\n` +
37+
elementList +
38+
`\nUse a more specific locator or use grabWebElements() to handle multiple elements.`
39+
} catch (err) {
40+
this.message = `Multiple elements (${this.count}) found. Failed to fetch details: ${err.message}`
41+
}
42+
43+
this._detailsFetched = true
44+
}
45+
46+
/**
47+
* Generate a formatted list of found elements with their XPath and preview
48+
* @param {Array<HTMLElement>} elements
49+
* @param {number} count
50+
* @returns {Promise<string>}
51+
*/
52+
async _generateElementList(elements, count) {
53+
const items = []
54+
const maxToShow = Math.min(count, 10)
55+
56+
for (let i = 0; i < maxToShow; i++) {
57+
const el = elements[i]
58+
try {
59+
const info = await this._getElementInfo(el)
60+
items.push(` ${i + 1}. ${info.xpath} (${info.preview})`)
61+
} catch (err) {
62+
// Element might be detached or inaccessible
63+
items.push(` ${i + 1}. [Unable to get element info: ${err.message}]`)
64+
}
65+
}
66+
67+
if (count > 10) {
68+
items.push(` ... and ${count - 10} more`)
69+
}
70+
71+
return items.join('\n')
72+
}
73+
74+
/**
75+
* Get XPath and preview for an element by running JavaScript in browser context
76+
* @param {HTMLElement} element
77+
* @returns {Promise<{xpath: string, preview: string}>}
78+
*/
79+
async _getElementInfo(element) {
80+
return element.evaluate((el) => {
81+
// Generate a unique XPath for this element
82+
const getUniqueXPath = (element) => {
83+
if (element.id) {
84+
return `//*[@id="${element.id}"]`
85+
}
86+
87+
const parts = []
88+
let current = element
89+
90+
while (current && current.nodeType === Node.ELEMENT_NODE) {
91+
let index = 0
92+
let sibling = current.previousSibling
93+
94+
while (sibling) {
95+
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) {
96+
index++
97+
}
98+
sibling = sibling.previousSibling
99+
}
100+
101+
const tagName = current.tagName.toLowerCase()
102+
const pathIndex = index > 0 ? `[${index + 1}]` : ''
103+
parts.unshift(`${tagName}${pathIndex}`)
104+
105+
current = current.parentElement
106+
107+
// Stop at body to keep XPath reasonable
108+
if (current && current.tagName === 'BODY') {
109+
parts.unshift('body')
110+
break
111+
}
112+
}
113+
114+
return '/' + parts.join('/')
115+
}
116+
117+
// Get a preview of the element (tag, classes, id)
118+
const getPreview = (element) => {
119+
const tag = element.tagName.toLowerCase()
120+
const id = element.id ? `#${element.id}` : ''
121+
const classes = element.className
122+
? '.' + element.className.split(' ').filter(c => c).join('.')
123+
: ''
124+
return `${tag}${id}${classes || ''}`
125+
}
126+
127+
return {
128+
xpath: getUniqueXPath(el),
129+
preview: getPreview(el),
130+
}
131+
})
132+
}
133+
}
134+
135+
export default MultipleElementsFound
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<html>
2+
<body>
3+
<h1>Strict Mode Test Page</h1>
4+
5+
<h2>Multiple Buttons (Same Class)</h2>
6+
<button class="duplicate-btn">Button 1</button>
7+
<button class="duplicate-btn">Button 2</button>
8+
<button class="duplicate-btn">Button 3</button>
9+
10+
<h2>Single Button</h2>
11+
<button class="single-btn">Single Button</button>
12+
13+
<h2>Multiple Inputs (Same Name)</h2>
14+
<form>
15+
<input type="text" name="duplicate-field" placeholder="Field 1" />
16+
<input type="text" name="duplicate-field" placeholder="Field 2" />
17+
<input type="text" name="duplicate-field" placeholder="Field 3" />
18+
</form>
19+
20+
<h2>Single Input</h2>
21+
<form>
22+
<input type="text" name="single-field" placeholder="Single Field" />
23+
</form>
24+
25+
<h2>Multiple Checkboxes (Same Name)</h2>
26+
<form>
27+
<label><input type="checkbox" name="terms" value="1" /> Option 1</label><br>
28+
<label><input type="checkbox" name="terms" value="2" /> Option 2</label><br>
29+
<label><input type="checkbox" name="terms" value="3" /> Option 3</label>
30+
</form>
31+
32+
<h2>Single Checkbox</h2>
33+
<form>
34+
<label><input type="checkbox" name="agree" value="yes" /> I Agree</label>
35+
</form>
36+
37+
<h2>Multiple Divs (Same Class)</h2>
38+
<div class="test-div">Div 1</div>
39+
<div class="test-div">Div 2</div>
40+
<div class="test-div">Div 3</div>
41+
42+
<h2>Buttons with IDs for fetchDetails test</h2>
43+
<button id="details-btn-0" class="details-test-btn">Details Button 0</button>
44+
<button id="details-btn-1" class="details-test-btn">Details Button 1</button>
45+
<button id="details-btn-2" class="details-test-btn">Details Button 2</button>
46+
47+
</body>
48+
</html>

0 commit comments

Comments
 (0)