codeceptjs 4.0.1-beta.22 → 4.0.1-beta.25

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.
@@ -27,6 +27,7 @@ import {
27
27
  } from '../utils.js'
28
28
  import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
29
29
  import ElementNotFound from './errors/ElementNotFound.js'
30
+ import MultipleElementsFound from './errors/MultipleElementsFound.js'
30
31
  import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
31
32
  import Popup from './extras/Popup.js'
32
33
  import Console from './extras/Console.js'
@@ -107,6 +108,9 @@ const pathSeparator = path.sep
107
108
  * those cookies are used instead and the configured `storageState` is ignored (no merge).
108
109
  * May include session cookies, auth tokens, localStorage and (if captured with
109
110
  * `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.
110
114
  */
111
115
  const config = {}
112
116
 
@@ -417,6 +421,7 @@ class Playwright extends Helper {
417
421
  highlightElement: false,
418
422
  storageState: undefined,
419
423
  onResponse: null,
424
+ strict: false, // Throw error when multiple elements match single-element locator
420
425
  }
421
426
 
422
427
  process.env.testIdAttribute = 'data-testid'
@@ -1912,7 +1917,12 @@ class Playwright extends Helper {
1912
1917
  */
1913
1918
  async _locateElement(locator) {
1914
1919
  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]
1916
1926
  }
1917
1927
 
1918
1928
  /**
@@ -1927,6 +1937,7 @@ class Playwright extends Helper {
1927
1937
  const context = providedContext || (await this._getContext())
1928
1938
  const els = await findCheckable.call(this, locator, context)
1929
1939
  assertElementExists(els[0], locator, 'Checkbox or radio')
1940
+ if (this.options.strict) assertOnlyOneElement(els, locator)
1930
1941
  return els[0]
1931
1942
  }
1932
1943
 
@@ -2399,6 +2410,7 @@ class Playwright extends Helper {
2399
2410
  async fillField(field, value) {
2400
2411
  const els = await findFields.call(this, field)
2401
2412
  assertElementExists(els, field, 'Field')
2413
+ if (this.options.strict) assertOnlyOneElement(els, field)
2402
2414
  const el = els[0]
2403
2415
 
2404
2416
  await el.clear()
@@ -2431,6 +2443,7 @@ class Playwright extends Helper {
2431
2443
  async clearField(locator, options = {}) {
2432
2444
  const els = await findFields.call(this, locator)
2433
2445
  assertElementExists(els, locator, 'Field to clear')
2446
+ if (this.options.strict) assertOnlyOneElement(els, locator)
2434
2447
 
2435
2448
  const el = els[0]
2436
2449
 
@@ -2447,6 +2460,7 @@ class Playwright extends Helper {
2447
2460
  async appendField(field, value) {
2448
2461
  const els = await findFields.call(this, field)
2449
2462
  assertElementExists(els, field, 'Field')
2463
+ if (this.options.strict) assertOnlyOneElement(els, field)
2450
2464
  await highlightActiveElement.call(this, els[0])
2451
2465
  await els[0].press('End')
2452
2466
  await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
@@ -2492,7 +2506,6 @@ class Playwright extends Helper {
2492
2506
  const context = await this.context
2493
2507
  const matchedLocator = new Locator(select)
2494
2508
 
2495
- // Strict locator
2496
2509
  if (!matchedLocator.isFuzzy()) {
2497
2510
  this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
2498
2511
  const els = await this._locate(matchedLocator)
@@ -2500,16 +2513,15 @@ class Playwright extends Helper {
2500
2513
  return proceedSelect.call(this, context, els[0], option)
2501
2514
  }
2502
2515
 
2503
- // Fuzzy: try combobox
2504
2516
  this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
2505
- let els = await findByRole(context, { role: 'combobox', name: matchedLocator.value })
2506
- if (els?.length) return proceedSelect.call(this, context, els[0], option)
2517
+ const literal = xpathLocator.literal(matchedLocator.value)
2518
+
2519
+ let els = await this._locate({ xpath: Locator.select.narrow(literal) })
2520
+ if (els.length) return proceedSelect.call(this, context, els[0], option)
2507
2521
 
2508
- // Fuzzy: try listbox
2509
- els = await findByRole(context, { role: 'listbox', name: matchedLocator.value })
2510
- if (els?.length) return proceedSelect.call(this, context, els[0], option)
2522
+ els = await this._locate({ xpath: Locator.select.wide(literal) })
2523
+ if (els.length) return proceedSelect.call(this, context, els[0], option)
2511
2524
 
2512
- // Fuzzy: try native select
2513
2525
  els = await findFields.call(this, select)
2514
2526
  assertElementExists(els, select, 'Selectable element')
2515
2527
  return proceedSelect.call(this, context, els[0], option)
@@ -2559,6 +2571,10 @@ class Playwright extends Helper {
2559
2571
  *
2560
2572
  */
2561
2573
  async see(text, context = null) {
2574
+ // If only one argument passed and it's an object without custom toString(), treat as locator
2575
+ if (!context && text && typeof text === 'object' && !Array.isArray(text) && text.toString === Object.prototype.toString) {
2576
+ return this.seeElement(text)
2577
+ }
2562
2578
  return proceedSee.call(this, 'assert', text, context)
2563
2579
  }
2564
2580
 
@@ -4442,20 +4458,6 @@ async function findCustomElements(matcher, locator) {
4442
4458
  return locators
4443
4459
  }
4444
4460
 
4445
- async function findElement(matcher, locator) {
4446
- const matchedLocator = Locator.from(locator)
4447
- const roleElements = await findByRole(matcher, matchedLocator)
4448
- if (roleElements && roleElements.length > 0) return roleElements[0]
4449
-
4450
- const isReactLocator = matchedLocator.type === 'react'
4451
- const isVueLocator = matchedLocator.type === 'vue'
4452
-
4453
- if (isReactLocator) return findReact(matcher, matchedLocator)
4454
- if (isVueLocator) return findVue(matcher, matchedLocator)
4455
-
4456
- return matcher.locator(buildLocatorString(matchedLocator)).first()
4457
- }
4458
-
4459
4461
  async function getVisibleElements(elements) {
4460
4462
  const visibleElements = []
4461
4463
  for (const element of elements) {
@@ -4505,47 +4507,42 @@ async function proceedClick(locator, context = null, options = {}) {
4505
4507
  }
4506
4508
 
4507
4509
  async function findClickable(matcher, locator) {
4508
- // Convert to Locator first to handle JSON strings properly
4509
- const matchedLocator = new Locator(locator)
4510
+ if (locator.react) return findReact(matcher, locator)
4511
+ if (locator.vue) return findVue(matcher, locator)
4510
4512
 
4511
- // Handle role locators from Locator
4512
- if (matchedLocator.isRole()) {
4513
- return findByRole(matcher, matchedLocator)
4513
+ locator = new Locator(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
4514
4518
  }
4515
4519
 
4516
- if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
4517
-
4518
4520
  let els
4519
- const literal = xpathLocator.literal(matchedLocator.value)
4520
-
4521
- try {
4522
- els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
4523
- if (els.length) return els
4524
- } catch (err) {
4525
- // getByRole not supported or failed
4526
- }
4527
-
4528
- try {
4529
- els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
4530
- if (els.length) return els
4531
- } catch (err) {
4532
- // getByRole not supported or failed
4533
- }
4521
+ const literal = xpathLocator.literal(locator.value)
4534
4522
 
4535
4523
  els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
4536
- if (els.length) return els
4524
+ if (els.length) {
4525
+ if (this.options.strict) assertOnlyOneElement(els, locator)
4526
+ return els
4527
+ }
4537
4528
 
4538
4529
  els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
4539
- if (els.length) return els
4530
+ if (els.length) {
4531
+ if (this.options.strict) assertOnlyOneElement(els, locator)
4532
+ return els
4533
+ }
4540
4534
 
4541
4535
  try {
4542
4536
  els = await findElements.call(this, matcher, Locator.clickable.self(literal))
4543
- if (els.length) return els
4537
+ if (els.length) {
4538
+ if (this.options.strict) assertOnlyOneElement(els, locator)
4539
+ return els
4540
+ }
4544
4541
  } catch (err) {
4545
4542
  // Do nothing
4546
4543
  }
4547
4544
 
4548
- return findElements.call(this, matcher, matchedLocator.value) // by css or xpath
4545
+ return findElements.call(this, matcher, locator.value) // by css or xpath
4549
4546
  }
4550
4547
 
4551
4548
  async function proceedSee(assertType, text, context, strict = false) {
@@ -4648,7 +4645,7 @@ async function proceedSelect(context, el, option) {
4648
4645
  const role = await el.getAttribute('role')
4649
4646
  const options = Array.isArray(option) ? option : [option]
4650
4647
 
4651
- if (role === 'combobox') {
4648
+ if (role === 'combobox' || role === 'button') {
4652
4649
  this.debugSection('SelectOption', 'Expanding combobox')
4653
4650
  await highlightActiveElement.call(this, el)
4654
4651
  const [ariaOwns, ariaControls] = await Promise.all([el.getAttribute('aria-owns'), el.getAttribute('aria-controls')])
@@ -4798,6 +4795,12 @@ function assertElementExists(res, locator, prefix, suffix) {
4798
4795
  }
4799
4796
  }
4800
4797
 
4798
+ function assertOnlyOneElement(elements, locator) {
4799
+ if (elements.length > 1) {
4800
+ throw new MultipleElementsFound(locator, elements)
4801
+ }
4802
+ }
4803
+
4801
4804
  function $XPath(element, selector) {
4802
4805
  const found = document.evaluate(selector, element || document.body, null, 5, null)
4803
4806
  const res = []
@@ -5034,7 +5037,7 @@ async function saveTraceForContext(context, name) {
5034
5037
  }
5035
5038
 
5036
5039
  async function highlightActiveElement(element) {
5037
- if ((this.options.highlightElement || store.onPause) && store.debugMode) {
5040
+ if (this.options.highlightElement || store.onPause || store.debugMode) {
5038
5041
  await element.evaluate(el => {
5039
5042
  const prevStyle = el.style.boxShadow
5040
5043
  el.style.boxShadow = '0px 0px 4px 3px rgba(147, 51, 234, 0.8)' // Bright purple that works on both dark/light modes
@@ -1690,6 +1690,10 @@ class Puppeteer extends Helper {
1690
1690
  * {{ react }}
1691
1691
  */
1692
1692
  async see(text, context = null) {
1693
+ // If only one argument passed and it's an object without custom toString(), treat as locator
1694
+ if (!context && text && typeof text === 'object' && !Array.isArray(text) && text.toString === Object.prototype.toString) {
1695
+ return this.seeElement(text)
1696
+ }
1693
1697
  return proceedSee.call(this, 'assert', text, context)
1694
1698
  }
1695
1699
 
@@ -1563,6 +1563,10 @@ class WebDriver extends Helper {
1563
1563
  * {{ react }}
1564
1564
  */
1565
1565
  async see(text, context = null) {
1566
+ // If only one argument passed and it's an object without custom toString(), treat as locator
1567
+ if (!context && text && typeof text === 'object' && !Array.isArray(text) && text.toString === Object.prototype.toString) {
1568
+ return this.seeElement(text)
1569
+ }
1566
1570
  return proceedSee.call(this, 'assert', text, context)
1567
1571
  }
1568
1572
 
@@ -2399,6 +2403,10 @@ class WebDriver extends Helper {
2399
2403
  })
2400
2404
  }
2401
2405
 
2406
+ async _waitForAction() {
2407
+ return this.wait(0.1)
2408
+ }
2409
+
2402
2410
  /**
2403
2411
  * {{> waitForEnabled }}
2404
2412
  */
@@ -3030,7 +3038,10 @@ async function proceedSelect(el, option) {
3030
3038
  const listboxId = ariaOwns || ariaControls
3031
3039
  let listboxEls = listboxId ? await this.browser.$$(`#${listboxId}`) : []
3032
3040
  if (!listboxEls.length) {
3033
- listboxEls = await this.browser.$$('[role="listbox"]')
3041
+ listboxEls = await this.browser.findElementsFromElement(elementId, 'xpath', 'following-sibling::*[@role="listbox"]')
3042
+ }
3043
+ if (!listboxEls.length) {
3044
+ listboxEls = await this.browser.findElementsFromElement(elementId, 'xpath', 'ancestor::*[@role="listbox"]')
3034
3045
  }
3035
3046
  if (!listboxEls.length) throw new Error('Cannot find listbox for combobox')
3036
3047
  const listbox = listboxEls[0]
@@ -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
package/lib/locator.js CHANGED
@@ -6,6 +6,9 @@ const require = createRequire(import.meta.url)
6
6
  let cssToXPath
7
7
 
8
8
  const locatorTypes = ['css', 'by', 'xpath', 'id', 'name', 'fuzzy', 'frame', 'shadow', 'role']
9
+
10
+ // Roles that can be used as shorthand locators: { button: 'text' } -> { role: 'button', text: 'text' }
11
+ const shorthandRoles = ['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox', 'heading', 'listitem', 'menuitem', 'tab', 'option', 'searchbox', 'alert', 'dialog']
9
12
  /** @class */
10
13
  class Locator {
11
14
  /**
@@ -114,6 +117,16 @@ class Locator {
114
117
  this.locator = locator
115
118
  const keys = Object.keys(locator)
116
119
  const [type] = keys
120
+
121
+ // Transform shorthand role locators: { button: 'text' } -> { role: 'button', text: 'text' }
122
+ if (keys.length === 1 && shorthandRoles.includes(type)) {
123
+ this.locator = { role: type, text: locator[type] }
124
+ this.type = 'role'
125
+ this.value = this.locator
126
+ Locator.filters.forEach(f => f(this.locator, this))
127
+ return
128
+ }
129
+
117
130
  this.type = type
118
131
  this.value = keys.length > 1 ? locator : locator[type]
119
132
  Locator.filters.forEach(f => f(locator, this))
@@ -500,7 +513,7 @@ Locator.clickable = {
500
513
  `.//*[@aria-label = ${literal}]`,
501
514
  `.//*[@title = ${literal}]`,
502
515
  `.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`,
503
- `.//*[@role='link' | @role='tab' | @role='menuitem' | @role='menuitemcheckbox' | @role='menuitemradio' | @role='option' | @role='radio' | @role='checkbox' | @role='switch' | @role='treeitem' | @role='gridcell' | @role='columnheader' | @role='rowheader' | @role='scrollbar' | @role='slider' | @role='spinbutton'][normalize-space(.)='${literal}']`,
516
+ `.//*[@role='button' or @role='link' or @role='tab' or @role='menuitem' or @role='menuitemcheckbox' or @role='menuitemradio' or @role='option' or @role='radio' or @role='checkbox' or @role='switch' or @role='treeitem' or @role='gridcell' or @role='columnheader' or @role='rowheader' or @role='scrollbar' or @role='slider' or @role='spinbutton'][normalize-space(.)=${literal}]`,
504
517
  ]),
505
518
 
506
519
  /**
@@ -570,6 +583,35 @@ Locator.checkable = {
570
583
  }
571
584
 
572
585
  Locator.select = {
586
+ /**
587
+ * Narrow strategy for finding select elements.
588
+ * Tries exact role match first (combobox/listbox by name), then select by name.
589
+ * @param {string} literal
590
+ * @returns {string}
591
+ */
592
+ narrow: literal =>
593
+ xpathLocator.combine([
594
+ `.//*[@role = 'combobox'][normalize-space(@name) = ${literal}]`,
595
+ `.//*[@role = 'listbox'][normalize-space(@name) = ${literal}]`,
596
+ `.//select[@name = ${literal}]`,
597
+ ]),
598
+
599
+ /**
600
+ * Wide strategy for finding select elements.
601
+ * Includes select, button, combobox, listbox with various accessibility attributes.
602
+ * @param {string} literal
603
+ * @returns {string}
604
+ */
605
+ wide: literal =>
606
+ xpathLocator.combine([
607
+ `.//select[((./@name = ${literal}) or ./@id = //label[@for][normalize-space(string(.)) = ${literal}]/@for or ./@placeholder = ${literal})]`,
608
+ `.//*[@role='button' or @role='combobox' or @role='listbox'][normalize-space(string(.)) = ${literal}]`,
609
+ `.//*[@role='button' or @role='combobox' or @role='listbox'][@aria-label = ${literal}]`,
610
+ `.//*[@role='button' or @role='combobox' or @role='listbox'][@title = ${literal}]`,
611
+ `.//*[@role='button' or @role='combobox' or @role='listbox'][@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`,
612
+ `.//label[normalize-space(string(.)) = ${literal}]//*[self::button or @role='button' or @role='combobox' or @role='listbox']`,
613
+ ]),
614
+
573
615
  /**
574
616
  * @param {string} opt
575
617
  * @returns {string}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeceptjs",
3
- "version": "4.0.1-beta.22",
3
+ "version": "4.0.1-beta.25",
4
4
  "type": "module",
5
5
  "description": "Supercharged End 2 End Testing Framework for NodeJS",
6
6
  "keywords": [