codeceptjs 4.0.0-rc.2 → 4.0.0-rc.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +39 -27
  2. package/bin/mcp-server.js +610 -0
  3. package/docs/webapi/appendField.mustache +5 -0
  4. package/docs/webapi/attachFile.mustache +12 -0
  5. package/docs/webapi/checkOption.mustache +1 -1
  6. package/docs/webapi/clearField.mustache +5 -0
  7. package/docs/webapi/dontSeeElement.mustache +4 -0
  8. package/docs/webapi/dontSeeInField.mustache +5 -0
  9. package/docs/webapi/fillField.mustache +5 -0
  10. package/docs/webapi/moveCursorTo.mustache +5 -1
  11. package/docs/webapi/seeElement.mustache +4 -0
  12. package/docs/webapi/seeInField.mustache +5 -0
  13. package/docs/webapi/selectOption.mustache +5 -0
  14. package/docs/webapi/uncheckOption.mustache +1 -1
  15. package/lib/codecept.js +20 -17
  16. package/lib/command/init.js +0 -3
  17. package/lib/command/run-workers.js +1 -0
  18. package/lib/container.js +19 -4
  19. package/lib/element/WebElement.js +52 -0
  20. package/lib/helper/Appium.js +8 -8
  21. package/lib/helper/Playwright.js +169 -87
  22. package/lib/helper/Puppeteer.js +181 -64
  23. package/lib/helper/WebDriver.js +141 -53
  24. package/lib/helper/errors/MultipleElementsFound.js +27 -110
  25. package/lib/helper/scripts/dropFile.js +11 -0
  26. package/lib/html.js +14 -1
  27. package/lib/listener/globalRetry.js +32 -6
  28. package/lib/mocha/cli.js +10 -0
  29. package/lib/plugin/aiTrace.js +464 -0
  30. package/lib/plugin/retryFailedStep.js +28 -19
  31. package/lib/plugin/stepByStepReport.js +5 -1
  32. package/lib/utils.js +48 -0
  33. package/lib/workers.js +49 -7
  34. package/package.json +5 -3
  35. package/lib/listener/enhancedGlobalRetry.js +0 -110
  36. package/lib/plugin/enhancedRetryFailedStep.js +0 -99
  37. package/lib/plugin/htmlReporter.js +0 -3648
  38. package/lib/retryCoordinator.js +0 -207
  39. package/typings/promiseBasedTypes.d.ts +0 -9469
  40. package/typings/types.d.ts +0 -11402
@@ -26,14 +26,20 @@ import {
26
26
  isModifierKey,
27
27
  requireWithFallback,
28
28
  normalizeSpacesInString,
29
+ normalizePath,
30
+ resolveUrl,
31
+ getMimeType,
32
+ base64EncodeFile,
29
33
  } from '../utils.js'
30
34
  import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
31
35
  import ElementNotFound from './errors/ElementNotFound.js'
36
+ import MultipleElementsFound from './errors/MultipleElementsFound.js'
32
37
  import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
33
38
  import Popup from './extras/Popup.js'
34
39
  import Console from './extras/Console.js'
35
40
  import { highlightElement } from './scripts/highlightElement.js'
36
41
  import { blurElement } from './scripts/blurElement.js'
42
+ import { dropFile } from './scripts/dropFile.js'
37
43
  import { dontSeeElementError, seeElementError, dontSeeElementInDOMError, seeElementInDOMError } from './errors/ElementAssertion.js'
38
44
  import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
39
45
  import WebElement from '../element/WebElement.js'
@@ -266,6 +272,7 @@ class Puppeteer extends Helper {
266
272
  show: false,
267
273
  defaultPopupAction: 'accept',
268
274
  highlightElement: false,
275
+ strict: false,
269
276
  }
270
277
 
271
278
  return Object.assign(defaults, config)
@@ -814,9 +821,26 @@ class Puppeteer extends Helper {
814
821
  * {{ react }}
815
822
  */
816
823
  async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
817
- const el = await this._locateElement(locator)
818
- if (!el) {
819
- throw new ElementNotFound(locator, 'Element to move cursor to')
824
+ let context = null
825
+ if (typeof offsetX !== 'number') {
826
+ context = offsetX
827
+ offsetX = 0
828
+ }
829
+
830
+ let el
831
+ if (context) {
832
+ const contextEls = await findElements.call(this, this.page, context)
833
+ assertElementExists(contextEls, context, 'Context element')
834
+ const els = await findElements.call(this, contextEls[0], locator)
835
+ if (!els || els.length === 0) {
836
+ throw new ElementNotFound(locator, 'Element to move cursor to')
837
+ }
838
+ el = els[0]
839
+ } else {
840
+ el = await this._locateElement(locator)
841
+ if (!el) {
842
+ throw new ElementNotFound(locator, 'Element to move cursor to')
843
+ }
820
844
  }
821
845
 
822
846
  // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
@@ -984,6 +1008,14 @@ class Puppeteer extends Helper {
984
1008
  */
985
1009
  async _locateElement(locator) {
986
1010
  const context = await this.context
1011
+ if (this.options.strict) {
1012
+ const elements = await findElements.call(this, context, locator)
1013
+ if (elements.length === 0) {
1014
+ throw new ElementNotFound(locator, 'Element', 'was not found')
1015
+ }
1016
+ assertOnlyOneElement(elements, locator, this)
1017
+ return elements[0]
1018
+ }
987
1019
  return findElement.call(this, context, locator)
988
1020
  }
989
1021
 
@@ -1001,6 +1033,7 @@ class Puppeteer extends Helper {
1001
1033
  if (!els || els.length === 0) {
1002
1034
  throw new ElementNotFound(locator, 'Checkbox or radio')
1003
1035
  }
1036
+ if (this.options.strict) assertOnlyOneElement(els, locator, this)
1004
1037
  return els[0]
1005
1038
  }
1006
1039
 
@@ -1158,8 +1191,16 @@ class Puppeteer extends Helper {
1158
1191
  * {{> seeElement }}
1159
1192
  * {{ react }}
1160
1193
  */
1161
- async seeElement(locator) {
1162
- let els = await this._locate(locator)
1194
+ async seeElement(locator, context = null) {
1195
+ let els
1196
+ if (context) {
1197
+ const contextPage = await this.context
1198
+ const contextEls = await findElements.call(this, contextPage, context)
1199
+ assertElementExists(contextEls, context, 'Context element')
1200
+ els = await findElements.call(this, contextEls[0], locator)
1201
+ } else {
1202
+ els = await this._locate(locator)
1203
+ }
1163
1204
  els = (await Promise.all(els.map(el => el.boundingBox() && el))).filter(v => v)
1164
1205
  // Puppeteer visibility was ignored? | Remove when Puppeteer is fixed
1165
1206
  els = await Promise.all(els.map(async el => (await el.evaluate(node => window.getComputedStyle(node).visibility !== 'hidden' && window.getComputedStyle(node).display !== 'none')) && el))
@@ -1174,8 +1215,16 @@ class Puppeteer extends Helper {
1174
1215
  * {{> dontSeeElement }}
1175
1216
  * {{ react }}
1176
1217
  */
1177
- async dontSeeElement(locator) {
1178
- let els = await this._locate(locator)
1218
+ async dontSeeElement(locator, context = null) {
1219
+ let els
1220
+ if (context) {
1221
+ const contextPage = await this.context
1222
+ const contextEls = await findElements.call(this, contextPage, context)
1223
+ assertElementExists(contextEls, context, 'Context element')
1224
+ els = await findElements.call(this, contextEls[0], locator)
1225
+ } else {
1226
+ els = await this._locate(locator)
1227
+ }
1179
1228
  els = (await Promise.all(els.map(el => el.boundingBox() && el))).filter(v => v)
1180
1229
  // Puppeteer visibility was ignored? | Remove when Puppeteer is fixed
1181
1230
  els = await Promise.all(els.map(async el => (await el.evaluate(node => window.getComputedStyle(node).visibility !== 'hidden' && window.getComputedStyle(node).display !== 'none')) && el))
@@ -1541,9 +1590,10 @@ class Puppeteer extends Helper {
1541
1590
  * {{> fillField }}
1542
1591
  * {{ react }}
1543
1592
  */
1544
- async fillField(field, value) {
1545
- const els = await findVisibleFields.call(this, field)
1593
+ async fillField(field, value, context = null) {
1594
+ const els = await findVisibleFields.call(this, field, context)
1546
1595
  assertElementExists(els, field, 'Field')
1596
+ if (this.options.strict) assertOnlyOneElement(els, field, this)
1547
1597
  const el = els[0]
1548
1598
  const tag = await el.getProperty('tagName').then(el => el.jsonValue())
1549
1599
  const editable = await el.getProperty('contenteditable').then(el => el.jsonValue())
@@ -1562,8 +1612,8 @@ class Puppeteer extends Helper {
1562
1612
  /**
1563
1613
  * {{> clearField }}
1564
1614
  */
1565
- async clearField(field) {
1566
- return this.fillField(field, '')
1615
+ async clearField(field, context = null) {
1616
+ return this.fillField(field, '', context)
1567
1617
  }
1568
1618
 
1569
1619
  /**
@@ -1571,9 +1621,10 @@ class Puppeteer extends Helper {
1571
1621
  *
1572
1622
  * {{ react }}
1573
1623
  */
1574
- async appendField(field, value) {
1575
- const els = await findVisibleFields.call(this, field)
1624
+ async appendField(field, value, context = null) {
1625
+ const els = await findVisibleFields.call(this, field, context)
1576
1626
  assertElementExists(els, field, 'Field')
1627
+ if (this.options.strict) assertOnlyOneElement(els, field, this)
1577
1628
  highlightActiveElement.call(this, els[0], await this._getContext())
1578
1629
  await els[0].press('End')
1579
1630
  await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
@@ -1583,17 +1634,17 @@ class Puppeteer extends Helper {
1583
1634
  /**
1584
1635
  * {{> seeInField }}
1585
1636
  */
1586
- async seeInField(field, value) {
1637
+ async seeInField(field, value, context = null) {
1587
1638
  const _value = typeof value === 'boolean' ? value : value.toString()
1588
- return proceedSeeInField.call(this, 'assert', field, _value)
1639
+ return proceedSeeInField.call(this, 'assert', field, _value, context)
1589
1640
  }
1590
1641
 
1591
1642
  /**
1592
1643
  * {{> dontSeeInField }}
1593
1644
  */
1594
- async dontSeeInField(field, value) {
1645
+ async dontSeeInField(field, value, context = null) {
1595
1646
  const _value = typeof value === 'boolean' ? value : value.toString()
1596
- return proceedSeeInField.call(this, 'negate', field, _value)
1647
+ return proceedSeeInField.call(this, 'negate', field, _value, context)
1597
1648
  }
1598
1649
 
1599
1650
  /**
@@ -1601,46 +1652,69 @@ class Puppeteer extends Helper {
1601
1652
  *
1602
1653
  * {{> attachFile }}
1603
1654
  */
1604
- async attachFile(locator, pathToFile) {
1655
+ async attachFile(locator, pathToFile, context = null) {
1605
1656
  const file = path.join(global.codecept_dir, pathToFile)
1606
1657
 
1607
1658
  if (!fileExists(file)) {
1608
1659
  throw new Error(`File at ${file} can not be found on local system`)
1609
1660
  }
1610
- const els = await findFields.call(this, locator)
1611
- assertElementExists(els, locator, 'Field')
1612
- await els[0].uploadFile(file)
1661
+ const els = await findFields.call(this, locator, context)
1662
+ if (els.length) {
1663
+ const tag = await els[0].evaluate(el => el.tagName)
1664
+ const type = await els[0].evaluate(el => el.type)
1665
+ if (tag === 'INPUT' && type === 'file') {
1666
+ await els[0].uploadFile(file)
1667
+ return this._waitForAction()
1668
+ }
1669
+ }
1670
+
1671
+ const targetEls = els.length ? els : await this._locate(locator)
1672
+ assertElementExists(targetEls, locator, 'Element')
1673
+ const fileData = {
1674
+ base64Content: base64EncodeFile(file),
1675
+ fileName: path.basename(file),
1676
+ mimeType: getMimeType(path.basename(file)),
1677
+ }
1678
+ await targetEls[0].evaluate(dropFile, fileData)
1613
1679
  return this._waitForAction()
1614
1680
  }
1615
1681
 
1616
1682
  /**
1617
1683
  * {{> selectOption }}
1618
1684
  */
1619
- async selectOption(select, option) {
1620
- const context = await this._getContext()
1685
+ async selectOption(select, option, context = null) {
1686
+ const pageContext = await this._getContext()
1621
1687
  const matchedLocator = new Locator(select)
1622
1688
 
1689
+ let contextEl
1690
+ if (context) {
1691
+ const contextEls = await findElements.call(this, pageContext, context)
1692
+ assertElementExists(contextEls, context, 'Context element')
1693
+ contextEl = contextEls[0]
1694
+ }
1695
+
1623
1696
  // Strict locator
1624
1697
  if (!matchedLocator.isFuzzy()) {
1625
1698
  this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
1626
- const els = await this._locate(select)
1699
+ const els = contextEl ? await findElements.call(this, contextEl, select) : await this._locate(select)
1627
1700
  assertElementExists(els, select, 'Selectable element')
1628
- return proceedSelect.call(this, context, els[0], option)
1701
+ return proceedSelect.call(this, pageContext, els[0], option)
1629
1702
  }
1630
1703
 
1631
1704
  // Fuzzy: try combobox
1632
1705
  this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
1633
- let els = await findByRole(context, { role: 'combobox', name: matchedLocator.value })
1634
- if (els?.length) return proceedSelect.call(this, context, els[0], option)
1706
+ const comboboxSearchCtx = contextEl || pageContext
1707
+ let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value })
1708
+ if (els?.length) return proceedSelect.call(this, pageContext, els[0], option)
1635
1709
 
1636
1710
  // Fuzzy: try listbox
1637
- els = await findByRole(context, { role: 'listbox', name: matchedLocator.value })
1638
- if (els?.length) return proceedSelect.call(this, context, els[0], option)
1711
+ els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
1712
+ if (els?.length) return proceedSelect.call(this, pageContext, els[0], option)
1639
1713
 
1640
1714
  // Fuzzy: try native select
1641
- const visibleEls = await findVisibleFields.call(this, select)
1715
+ const visibleEls = await findVisibleFields.call(this, select, context)
1642
1716
  assertElementExists(visibleEls, select, 'Selectable field')
1643
- return proceedSelect.call(this, context, visibleEls[0], option)
1717
+ return proceedSelect.call(this, pageContext, visibleEls[0], option)
1644
1718
  }
1645
1719
 
1646
1720
  /**
@@ -1691,7 +1765,7 @@ class Puppeteer extends Helper {
1691
1765
  const currentUrl = await this._getPageUrl()
1692
1766
  const baseUrl = this.options.url || 'http://localhost'
1693
1767
  const actualPath = new URL(currentUrl, baseUrl).pathname
1694
- return equals('url path').assert(path, actualPath)
1768
+ return equals('url path').assert(normalizePath(path), normalizePath(actualPath))
1695
1769
  }
1696
1770
 
1697
1771
  /**
@@ -1701,7 +1775,7 @@ class Puppeteer extends Helper {
1701
1775
  const currentUrl = await this._getPageUrl()
1702
1776
  const baseUrl = this.options.url || 'http://localhost'
1703
1777
  const actualPath = new URL(currentUrl, baseUrl).pathname
1704
- return equals('url path').negate(path, actualPath)
1778
+ return equals('url path').negate(normalizePath(path), normalizePath(actualPath))
1705
1779
  }
1706
1780
 
1707
1781
  /**
@@ -2441,6 +2515,7 @@ class Puppeteer extends Helper {
2441
2515
  */
2442
2516
  async waitInUrl(urlPart, sec = null) {
2443
2517
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
2518
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
2444
2519
 
2445
2520
  return this.page
2446
2521
  .waitForFunction(
@@ -2449,12 +2524,12 @@ class Puppeteer extends Helper {
2449
2524
  return currUrl.indexOf(urlPart) > -1
2450
2525
  },
2451
2526
  { timeout: waitTimeout },
2452
- urlPart,
2527
+ expectedUrl,
2453
2528
  )
2454
2529
  .catch(async e => {
2455
- const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
2530
+ const currUrl = await this._getPageUrl()
2456
2531
  if (/Waiting failed:/i.test(e.message) || /failed: timeout/i.test(e.message)) {
2457
- throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
2532
+ throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`)
2458
2533
  } else {
2459
2534
  throw e
2460
2535
  }
@@ -2466,18 +2541,13 @@ class Puppeteer extends Helper {
2466
2541
  */
2467
2542
  async waitUrlEquals(urlPart, sec = null) {
2468
2543
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
2469
-
2470
- const baseUrl = this.options.url
2471
- let expectedUrl = urlPart
2472
- if (urlPart.indexOf('http') < 0) {
2473
- expectedUrl = baseUrl + urlPart
2474
- }
2544
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
2475
2545
 
2476
2546
  return this.page
2477
2547
  .waitForFunction(
2478
2548
  url => {
2479
2549
  const currUrl = decodeURIComponent(window.location.href)
2480
- return currUrl.indexOf(url) > -1
2550
+ return currUrl === url
2481
2551
  },
2482
2552
  { timeout: waitTimeout },
2483
2553
  expectedUrl,
@@ -2485,11 +2555,36 @@ class Puppeteer extends Helper {
2485
2555
  .catch(async e => {
2486
2556
  const currUrl = await this._getPageUrl()
2487
2557
  if (/Waiting failed/i.test(e.message) || /failed: timeout/i.test(e.message)) {
2488
- if (!currUrl.includes(expectedUrl)) {
2489
- throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`)
2490
- } else {
2491
- throw new Error(`expected url not loaded, error message: ${e.message}`)
2492
- }
2558
+ throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`)
2559
+ } else {
2560
+ throw e
2561
+ }
2562
+ })
2563
+ }
2564
+
2565
+ /**
2566
+ * {{> waitCurrentPathEquals }}
2567
+ */
2568
+ async waitCurrentPathEquals(path, sec = null) {
2569
+ const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
2570
+ const normalizedPath = normalizePath(path)
2571
+
2572
+ return this.page
2573
+ .waitForFunction(
2574
+ expectedPath => {
2575
+ const actualPath = window.location.pathname
2576
+ const normalizePath = p => (p === '' || p === '/' ? '/' : p.replace(/\/+/g, '/').replace(/\/$/, '') || '/')
2577
+ return normalizePath(actualPath) === expectedPath
2578
+ },
2579
+ { timeout: waitTimeout },
2580
+ normalizedPath,
2581
+ )
2582
+ .catch(async e => {
2583
+ const currUrl = await this._getPageUrl()
2584
+ const baseUrl = this.options.url || 'http://localhost'
2585
+ const actualPath = new URL(currUrl, baseUrl).pathname
2586
+ if (/Waiting failed/i.test(e.message) || /failed: timeout/i.test(e.message)) {
2587
+ throw new Error(`expected path to be ${normalizedPath}, but found ${normalizePath(actualPath)}`)
2493
2588
  } else {
2494
2589
  throw e
2495
2590
  }
@@ -3026,6 +3121,7 @@ async function proceedClick(locator, context = null, options = {}) {
3026
3121
  } else {
3027
3122
  assertElementExists(els, locator, 'Clickable element')
3028
3123
  }
3124
+ if (this.options.strict) assertOnlyOneElement(els, locator, this)
3029
3125
 
3030
3126
  highlightActiveElement.call(this, els[0], await this._getContext())
3031
3127
 
@@ -3160,43 +3256,57 @@ async function proceedIsChecked(assertType, option) {
3160
3256
  return truth(`checkable ${option}`, 'to be checked')[assertType](selected)
3161
3257
  }
3162
3258
 
3163
- async function findVisibleFields(locator) {
3164
- const els = await findFields.call(this, locator)
3259
+ async function findVisibleFields(locator, context = null) {
3260
+ const els = await findFields.call(this, locator, context)
3165
3261
  const visible = await Promise.all(els.map(el => el.boundingBox()))
3166
3262
  return els.filter((el, index) => visible[index])
3167
3263
  }
3168
3264
 
3169
- async function findFields(locator) {
3265
+ async function findFields(locator, context = null) {
3266
+ let contextEl
3267
+ if (context) {
3268
+ const contextPage = await this.context
3269
+ const contextEls = await findElements.call(this, contextPage, context)
3270
+ assertElementExists(contextEls, context, 'Context element')
3271
+ contextEl = contextEls[0]
3272
+ }
3273
+
3274
+ const locateFn = contextEl
3275
+ ? loc => findElements.call(this, contextEl, loc)
3276
+ : loc => this._locate(loc)
3277
+
3170
3278
  const matchedLocator = new Locator(locator)
3171
3279
  if (!matchedLocator.isFuzzy()) {
3172
- return this._locate(matchedLocator)
3280
+ return locateFn(matchedLocator)
3173
3281
  }
3174
3282
  const literal = xpathLocator.literal(matchedLocator.value)
3175
3283
 
3176
- let els = await this._locate({ xpath: Locator.field.labelEquals(literal) })
3284
+ let els = await locateFn({ xpath: Locator.field.labelEquals(literal) })
3177
3285
  if (els.length) {
3178
3286
  return els
3179
3287
  }
3180
3288
 
3181
- els = await this._locate({ xpath: Locator.field.labelContains(literal) })
3289
+ els = await locateFn({ xpath: Locator.field.labelContains(literal) })
3182
3290
  if (els.length) {
3183
3291
  return els
3184
3292
  }
3185
- els = await this._locate({ xpath: Locator.field.byName(literal) })
3293
+ els = await locateFn({ xpath: Locator.field.byName(literal) })
3186
3294
  if (els.length) {
3187
3295
  return els
3188
3296
  }
3189
3297
 
3190
3298
  // Try ARIA selector for accessible name
3191
- try {
3192
- const page = await this.context
3193
- els = await page.$$(`::-p-aria(${matchedLocator.value})`)
3194
- if (els.length) return els
3195
- } catch (err) {
3196
- // ARIA selector not supported or failed
3299
+ if (!contextEl) {
3300
+ try {
3301
+ const page = await this.context
3302
+ els = await page.$$(`::-p-aria(${matchedLocator.value})`)
3303
+ if (els.length) return els
3304
+ } catch (err) {
3305
+ // ARIA selector not supported or failed
3306
+ }
3197
3307
  }
3198
3308
 
3199
- return this._locate({ css: matchedLocator.value })
3309
+ return locateFn({ css: matchedLocator.value })
3200
3310
  }
3201
3311
 
3202
3312
  async function proceedDragAndDrop(sourceLocator, destinationLocator) {
@@ -3225,8 +3335,8 @@ async function proceedDragAndDrop(sourceLocator, destinationLocator) {
3225
3335
  await this._waitForAction()
3226
3336
  }
3227
3337
 
3228
- async function proceedSeeInField(assertType, field, value) {
3229
- const els = await findVisibleFields.call(this, field)
3338
+ async function proceedSeeInField(assertType, field, value, context) {
3339
+ const els = await findVisibleFields.call(this, field, context)
3230
3340
  assertElementExists(els, field, 'Field')
3231
3341
  const el = els[0]
3232
3342
  const tag = await el.getProperty('tagName').then(el => el.jsonValue())
@@ -3339,6 +3449,13 @@ function assertElementExists(res, locator, prefix, suffix) {
3339
3449
  }
3340
3450
  }
3341
3451
 
3452
+ function assertOnlyOneElement(elements, locator, helper) {
3453
+ if (elements.length > 1) {
3454
+ const webElements = elements.map(el => new WebElement(el, helper))
3455
+ throw new MultipleElementsFound(locator, webElements)
3456
+ }
3457
+ }
3458
+
3342
3459
  function $XPath(element, selector) {
3343
3460
  const found = document.evaluate(selector, element || document.body, null, 5, null)
3344
3461
  const res = []