codeceptjs 4.0.0-rc.1 → 4.0.0-rc.11

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 (49) hide show
  1. package/README.md +39 -27
  2. package/bin/mcp-server.js +637 -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/dontSeeCurrentPathEquals.mustache +10 -0
  8. package/docs/webapi/dontSeeElement.mustache +4 -0
  9. package/docs/webapi/dontSeeInField.mustache +5 -0
  10. package/docs/webapi/fillField.mustache +5 -0
  11. package/docs/webapi/moveCursorTo.mustache +5 -1
  12. package/docs/webapi/seeCurrentPathEquals.mustache +10 -0
  13. package/docs/webapi/seeElement.mustache +4 -0
  14. package/docs/webapi/seeInField.mustache +5 -0
  15. package/docs/webapi/selectOption.mustache +5 -0
  16. package/docs/webapi/uncheckOption.mustache +1 -1
  17. package/lib/codecept.js +20 -17
  18. package/lib/command/init.js +0 -3
  19. package/lib/command/run-workers.js +1 -0
  20. package/lib/container.js +19 -4
  21. package/lib/element/WebElement.js +81 -2
  22. package/lib/els.js +12 -6
  23. package/lib/helper/Appium.js +8 -8
  24. package/lib/helper/Playwright.js +224 -138
  25. package/lib/helper/Puppeteer.js +211 -69
  26. package/lib/helper/WebDriver.js +183 -64
  27. package/lib/helper/errors/MultipleElementsFound.js +27 -110
  28. package/lib/helper/errors/NonFocusedType.js +8 -0
  29. package/lib/helper/extras/elementSelection.js +58 -0
  30. package/lib/helper/extras/focusCheck.js +43 -0
  31. package/lib/helper/scripts/dropFile.js +11 -0
  32. package/lib/html.js +14 -1
  33. package/lib/listener/globalRetry.js +32 -6
  34. package/lib/mocha/cli.js +10 -0
  35. package/lib/plugin/aiTrace.js +464 -0
  36. package/lib/plugin/retryFailedStep.js +28 -19
  37. package/lib/plugin/stepByStepReport.js +5 -1
  38. package/lib/step/config.js +15 -2
  39. package/lib/step/record.js +1 -1
  40. package/lib/utils.js +48 -0
  41. package/lib/workers.js +49 -7
  42. package/package.json +5 -3
  43. package/typings/index.d.ts +19 -0
  44. package/lib/listener/enhancedGlobalRetry.js +0 -110
  45. package/lib/plugin/enhancedRetryFailedStep.js +0 -99
  46. package/lib/plugin/htmlReporter.js +0 -3648
  47. package/lib/retryCoordinator.js +0 -207
  48. package/typings/promiseBasedTypes.d.ts +0 -9469
  49. package/typings/types.d.ts +0 -11402
@@ -7,6 +7,7 @@ import promiseRetry from 'promise-retry'
7
7
  import Locator from '../locator.js'
8
8
  import recorder from '../recorder.js'
9
9
  import store from '../store.js'
10
+ import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js'
10
11
  import { includes as stringIncludes } from '../assert/include.js'
11
12
  import { urlEquals, equals } from '../assert/equal.js'
12
13
  import { empty } from '../assert/empty.js'
@@ -23,7 +24,11 @@ import {
23
24
  clearString,
24
25
  requireWithFallback,
25
26
  normalizeSpacesInString,
27
+ normalizePath,
28
+ resolveUrl,
26
29
  relativeDir,
30
+ getMimeType,
31
+ base64EncodeFile,
27
32
  } from '../utils.js'
28
33
  import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
29
34
  import ElementNotFound from './errors/ElementNotFound.js'
@@ -32,7 +37,9 @@ import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefu
32
37
  import Popup from './extras/Popup.js'
33
38
  import Console from './extras/Console.js'
34
39
  import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
40
+ import { dropFile } from './scripts/dropFile.js'
35
41
  import WebElement from '../element/WebElement.js'
42
+ import { selectElement } from './extras/elementSelection.js'
36
43
 
37
44
  let playwright
38
45
  let perfTiming
@@ -1490,8 +1497,23 @@ class Playwright extends Helper {
1490
1497
  *
1491
1498
  */
1492
1499
  async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
1493
- const el = await this._locateElement(locator)
1494
- assertElementExists(el, locator)
1500
+ let context = null
1501
+ if (typeof offsetX !== 'number') {
1502
+ context = offsetX
1503
+ offsetX = 0
1504
+ }
1505
+
1506
+ let el
1507
+ if (context) {
1508
+ const contextEls = await this._locate(context)
1509
+ assertElementExists(contextEls, context, 'Context element')
1510
+ el = await findElements.call(this, contextEls[0], locator)
1511
+ assertElementExists(el, locator)
1512
+ el = el[0]
1513
+ } else {
1514
+ el = await this._locateElement(locator)
1515
+ assertElementExists(el, locator)
1516
+ }
1495
1517
 
1496
1518
  // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
1497
1519
  const { x, y } = await clickablePoint(el)
@@ -1759,8 +1781,7 @@ class Playwright extends Helper {
1759
1781
  if (elements.length === 0) {
1760
1782
  throw new ElementNotFound(locator, 'Element', 'was not found')
1761
1783
  }
1762
- if (this.options.strict) assertOnlyOneElement(elements, locator)
1763
- return elements[0]
1784
+ return selectElement(elements, locator, this)
1764
1785
  }
1765
1786
 
1766
1787
  /**
@@ -1775,8 +1796,7 @@ class Playwright extends Helper {
1775
1796
  const context = providedContext || (await this._getContext())
1776
1797
  const els = await findCheckable.call(this, locator, context)
1777
1798
  assertElementExists(els[0], locator, 'Checkbox or radio')
1778
- if (this.options.strict) assertOnlyOneElement(els, locator)
1779
- return els[0]
1799
+ return selectElement(els, locator, this)
1780
1800
  }
1781
1801
 
1782
1802
  /**
@@ -1944,8 +1964,15 @@ class Playwright extends Helper {
1944
1964
  * {{> seeElement }}
1945
1965
  *
1946
1966
  */
1947
- async seeElement(locator) {
1948
- let els = await this._locate(locator)
1967
+ async seeElement(locator, context = null) {
1968
+ let els
1969
+ if (context) {
1970
+ const contextEls = await this._locate(context)
1971
+ assertElementExists(contextEls, context, 'Context element')
1972
+ els = await findElements.call(this, contextEls[0], locator)
1973
+ } else {
1974
+ els = await this._locate(locator)
1975
+ }
1949
1976
  els = await Promise.all(els.map(el => el.isVisible()))
1950
1977
  try {
1951
1978
  return empty('visible elements').negate(els.filter(v => v).fill('ELEMENT'))
@@ -1958,8 +1985,15 @@ class Playwright extends Helper {
1958
1985
  * {{> dontSeeElement }}
1959
1986
  *
1960
1987
  */
1961
- async dontSeeElement(locator) {
1962
- let els = await this._locate(locator)
1988
+ async dontSeeElement(locator, context = null) {
1989
+ let els
1990
+ if (context) {
1991
+ const contextEls = await this._locate(context)
1992
+ assertElementExists(contextEls, context, 'Context element')
1993
+ els = await findElements.call(this, contextEls[0], locator)
1994
+ } else {
1995
+ els = await this._locate(locator)
1996
+ }
1963
1997
  els = await Promise.all(els.map(el => el.isVisible()))
1964
1998
  try {
1965
1999
  return empty('visible elements').assert(els.filter(v => v).fill('ELEMENT'))
@@ -2198,6 +2232,7 @@ class Playwright extends Helper {
2198
2232
  * {{> pressKeyWithKeyNormalization }}
2199
2233
  */
2200
2234
  async pressKey(key) {
2235
+ await checkFocusBeforePressKey(this, key)
2201
2236
  const modifiers = []
2202
2237
  if (Array.isArray(key)) {
2203
2238
  for (let k of key) {
@@ -2226,6 +2261,8 @@ class Playwright extends Helper {
2226
2261
  * {{> type }}
2227
2262
  */
2228
2263
  async type(keys, delay = null) {
2264
+ await checkFocusBeforeType(this)
2265
+
2229
2266
  // Always use page.keyboard.type for any string (including single character and national characters).
2230
2267
  if (!Array.isArray(keys)) {
2231
2268
  keys = keys.toString()
@@ -2245,11 +2282,10 @@ class Playwright extends Helper {
2245
2282
  * {{> fillField }}
2246
2283
  *
2247
2284
  */
2248
- async fillField(field, value) {
2249
- const els = await findFields.call(this, field)
2285
+ async fillField(field, value, context = null) {
2286
+ const els = await findFields.call(this, field, context)
2250
2287
  assertElementExists(els, field, 'Field')
2251
- if (this.options.strict) assertOnlyOneElement(els, field)
2252
- const el = els[0]
2288
+ const el = selectElement(els, field, this)
2253
2289
 
2254
2290
  await el.clear()
2255
2291
  if (store.debugMode) this.debugSection('Focused', await elToString(el, 1))
@@ -2262,28 +2298,13 @@ class Playwright extends Helper {
2262
2298
  }
2263
2299
 
2264
2300
  /**
2265
- * Clears the text input element: `<input>`, `<textarea>` or `[contenteditable]` .
2266
- *
2267
- *
2268
- * Examples:
2269
- *
2270
- * ```js
2271
- * I.clearField('.text-area')
2272
- *
2273
- * // if this doesn't work use force option
2274
- * I.clearField('#submit', { force: true })
2275
- * ```
2276
- * Use `force` to bypass the [actionability](https://playwright.dev/docs/actionability) checks.
2277
- *
2278
- * @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
2279
- * @param {any} [options] [Additional options](https://playwright.dev/docs/api/class-locator#locator-clear) for available options object as 2nd argument.
2301
+ * {{> clearField }}
2280
2302
  */
2281
- async clearField(locator, options = {}) {
2282
- const els = await findFields.call(this, locator)
2303
+ async clearField(locator, context = null) {
2304
+ const els = await findFields.call(this, locator, context)
2283
2305
  assertElementExists(els, locator, 'Field to clear')
2284
- if (this.options.strict) assertOnlyOneElement(els, locator)
2285
2306
 
2286
- const el = els[0]
2307
+ const el = selectElement(els, locator, this)
2287
2308
 
2288
2309
  await highlightActiveElement.call(this, el)
2289
2310
 
@@ -2295,76 +2316,101 @@ class Playwright extends Helper {
2295
2316
  /**
2296
2317
  * {{> appendField }}
2297
2318
  */
2298
- async appendField(field, value) {
2299
- const els = await findFields.call(this, field)
2319
+ async appendField(field, value, context = null) {
2320
+ const els = await findFields.call(this, field, context)
2300
2321
  assertElementExists(els, field, 'Field')
2301
- if (this.options.strict) assertOnlyOneElement(els, field)
2302
- await highlightActiveElement.call(this, els[0])
2303
- await els[0].press('End')
2304
- await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
2322
+ const el = selectElement(els, field, this)
2323
+ await highlightActiveElement.call(this, el)
2324
+ await el.press('End')
2325
+ await el.type(value.toString(), { delay: this.options.pressKeyDelay })
2305
2326
  return this._waitForAction()
2306
2327
  }
2307
2328
 
2308
2329
  /**
2309
2330
  * {{> seeInField }}
2310
2331
  */
2311
- async seeInField(field, value) {
2332
+ async seeInField(field, value, context = null) {
2312
2333
  const _value = typeof value === 'boolean' ? value : value.toString()
2313
- return proceedSeeInField.call(this, 'assert', field, _value)
2334
+ return proceedSeeInField.call(this, 'assert', field, _value, context)
2314
2335
  }
2315
2336
 
2316
2337
  /**
2317
2338
  * {{> dontSeeInField }}
2318
2339
  */
2319
- async dontSeeInField(field, value) {
2340
+ async dontSeeInField(field, value, context = null) {
2320
2341
  const _value = typeof value === 'boolean' ? value : value.toString()
2321
- return proceedSeeInField.call(this, 'negate', field, _value)
2342
+ return proceedSeeInField.call(this, 'negate', field, _value, context)
2322
2343
  }
2323
2344
 
2324
2345
  /**
2325
2346
  * {{> attachFile }}
2326
2347
  *
2327
2348
  */
2328
- async attachFile(locator, pathToFile) {
2349
+ async attachFile(locator, pathToFile, context = null) {
2329
2350
  const file = path.join(global.codecept_dir, pathToFile)
2330
2351
 
2331
2352
  if (!fileExists(file)) {
2332
2353
  throw new Error(`File at ${file} can not be found on local system`)
2333
2354
  }
2334
- const els = await findFields.call(this, locator)
2335
- assertElementExists(els, locator, 'Field')
2336
- await els[0].setInputFiles(file)
2355
+ const els = await findFields.call(this, locator, context)
2356
+ if (els.length) {
2357
+ const el = selectElement(els, locator, this)
2358
+ const tag = await el.evaluate(el => el.tagName)
2359
+ const type = await el.evaluate(el => el.type)
2360
+ if (tag === 'INPUT' && type === 'file') {
2361
+ await el.setInputFiles(file)
2362
+ return this._waitForAction()
2363
+ }
2364
+ }
2365
+
2366
+ const targetEls = els.length ? els : await this._locate(locator)
2367
+ assertElementExists(targetEls, locator, 'Element')
2368
+ const el = selectElement(targetEls, locator, this)
2369
+ const fileData = {
2370
+ base64Content: base64EncodeFile(file),
2371
+ fileName: path.basename(file),
2372
+ mimeType: getMimeType(path.basename(file)),
2373
+ }
2374
+ await el.evaluate(dropFile, fileData)
2337
2375
  return this._waitForAction()
2338
2376
  }
2339
2377
 
2340
2378
  /**
2341
2379
  * {{> selectOption }}
2342
2380
  */
2343
- async selectOption(select, option) {
2344
- const context = await this.context
2381
+ async selectOption(select, option, context = null) {
2382
+ const pageContext = await this.context
2345
2383
  const matchedLocator = new Locator(select)
2346
2384
 
2385
+ let contextEl
2386
+ if (context) {
2387
+ const contextEls = await this._locate(context)
2388
+ assertElementExists(contextEls, context, 'Context element')
2389
+ contextEl = contextEls[0]
2390
+ }
2391
+
2347
2392
  // Strict locator
2348
2393
  if (!matchedLocator.isFuzzy()) {
2349
2394
  this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
2350
- const els = await this._locate(matchedLocator)
2395
+ const els = contextEl ? await findElements.call(this, contextEl, matchedLocator) : await this._locate(matchedLocator)
2351
2396
  assertElementExists(els, select, 'Selectable element')
2352
- return proceedSelect.call(this, context, els[0], option)
2397
+ return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2353
2398
  }
2354
2399
 
2355
2400
  // Fuzzy: try combobox
2356
2401
  this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
2357
- let els = await findByRole(context, { role: 'combobox', name: matchedLocator.value })
2358
- if (els?.length) return proceedSelect.call(this, context, els[0], option)
2402
+ const comboboxSearchCtx = contextEl || pageContext
2403
+ let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value })
2404
+ if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2359
2405
 
2360
2406
  // Fuzzy: try listbox
2361
- els = await findByRole(context, { role: 'listbox', name: matchedLocator.value })
2362
- if (els?.length) return proceedSelect.call(this, context, els[0], option)
2407
+ els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
2408
+ if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2363
2409
 
2364
2410
  // Fuzzy: try native select
2365
- els = await findFields.call(this, select)
2411
+ els = await findFields.call(this, select, context)
2366
2412
  assertElementExists(els, select, 'Selectable element')
2367
- return proceedSelect.call(this, context, els[0], option)
2413
+ return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2368
2414
  }
2369
2415
 
2370
2416
  /**
@@ -2405,6 +2451,26 @@ class Playwright extends Helper {
2405
2451
  urlEquals(this.options.url).negate(url, await this._getPageUrl())
2406
2452
  }
2407
2453
 
2454
+ /**
2455
+ * {{> seeCurrentPathEquals }}
2456
+ */
2457
+ async seeCurrentPathEquals(path) {
2458
+ const currentUrl = await this._getPageUrl()
2459
+ const baseUrl = this.options.url || 'http://localhost'
2460
+ const actualPath = new URL(currentUrl, baseUrl).pathname
2461
+ return equals('url path').assert(normalizePath(path), normalizePath(actualPath))
2462
+ }
2463
+
2464
+ /**
2465
+ * {{> dontSeeCurrentPathEquals }}
2466
+ */
2467
+ async dontSeeCurrentPathEquals(path) {
2468
+ const currentUrl = await this._getPageUrl()
2469
+ const baseUrl = this.options.url || 'http://localhost'
2470
+ const actualPath = new URL(currentUrl, baseUrl).pathname
2471
+ return equals('url path').negate(normalizePath(path), normalizePath(actualPath))
2472
+ }
2473
+
2408
2474
  /**
2409
2475
  * {{> see }}
2410
2476
  *
@@ -2638,15 +2704,12 @@ class Playwright extends Helper {
2638
2704
  *
2639
2705
  */
2640
2706
  async grabTextFrom(locator) {
2641
- // Handle role locators with text/exact options
2642
- if (isRoleLocatorObject(locator)) {
2643
- const elements = await handleRoleLocator(this.page, locator)
2644
- if (elements && elements.length > 0) {
2645
- const text = await elements[0].textContent()
2646
- assertElementExists(text, JSON.stringify(locator))
2647
- this.debugSection('Text', text)
2648
- return text
2649
- }
2707
+ const roleElements = await handleRoleLocator(this.page, locator)
2708
+ if (roleElements && roleElements.length > 0) {
2709
+ const text = await roleElements[0].textContent()
2710
+ assertElementExists(text, JSON.stringify(locator))
2711
+ this.debugSection('Text', text)
2712
+ return text
2650
2713
  }
2651
2714
 
2652
2715
  const locatorObj = new Locator(locator, 'css')
@@ -2874,7 +2937,7 @@ class Playwright extends Helper {
2874
2937
  const els = await this._locate(matchedLocator)
2875
2938
  assertElementExists(els, locator)
2876
2939
  const snapshot = await els[0].ariaSnapshot()
2877
- this.debugSection('Aria Snapshot', snapshot)
2940
+ this.debugSection('Aria Snapshot', `${snapshot.split('\n').length} lines`)
2878
2941
  return snapshot
2879
2942
  }
2880
2943
 
@@ -3362,6 +3425,7 @@ class Playwright extends Helper {
3362
3425
  */
3363
3426
  async waitInUrl(urlPart, sec = null) {
3364
3427
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3428
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
3365
3429
 
3366
3430
  return this.page
3367
3431
  .waitForFunction(
@@ -3369,13 +3433,13 @@ class Playwright extends Helper {
3369
3433
  const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
3370
3434
  return currUrl.indexOf(urlPart) > -1
3371
3435
  },
3372
- urlPart,
3436
+ expectedUrl,
3373
3437
  { timeout: waitTimeout },
3374
3438
  )
3375
3439
  .catch(async e => {
3376
- const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
3440
+ const currUrl = await this._getPageUrl()
3377
3441
  if (/Timeout/i.test(e.message)) {
3378
- throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
3442
+ throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`)
3379
3443
  } else {
3380
3444
  throw e
3381
3445
  }
@@ -3387,26 +3451,46 @@ class Playwright extends Helper {
3387
3451
  */
3388
3452
  async waitUrlEquals(urlPart, sec = null) {
3389
3453
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3390
-
3391
- const baseUrl = this.options.url
3392
- let expectedUrl = urlPart
3393
- if (urlPart.indexOf('http') < 0) {
3394
- expectedUrl = baseUrl + urlPart
3395
- }
3454
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
3396
3455
 
3397
3456
  try {
3398
3457
  await this.page.waitForURL(
3399
- url => url.href.includes(expectedUrl),
3458
+ url => url.href === expectedUrl,
3400
3459
  { timeout: waitTimeout },
3401
3460
  )
3402
3461
  } catch (e) {
3403
3462
  const currUrl = await this._getPageUrl()
3404
3463
  if (/Timeout/i.test(e.message)) {
3405
- if (!currUrl.includes(expectedUrl)) {
3406
- throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`)
3407
- } else {
3408
- throw new Error(`expected url not loaded, error message: ${e.message}`)
3409
- }
3464
+ throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`)
3465
+ } else {
3466
+ throw e
3467
+ }
3468
+ }
3469
+ }
3470
+
3471
+ /**
3472
+ * {{> waitCurrentPathEquals }}
3473
+ */
3474
+ async waitCurrentPathEquals(path, sec = null) {
3475
+ const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3476
+ const normalizedPath = normalizePath(path)
3477
+
3478
+ try {
3479
+ await this.page.waitForFunction(
3480
+ expectedPath => {
3481
+ const actualPath = window.location.pathname
3482
+ const normalizePath = p => (p === '' || p === '/' ? '/' : p.replace(/\/+/g, '/').replace(/\/$/, '') || '/')
3483
+ return normalizePath(actualPath) === expectedPath
3484
+ },
3485
+ normalizedPath,
3486
+ { timeout: waitTimeout },
3487
+ )
3488
+ } catch (e) {
3489
+ const currentUrl = await this._getPageUrl()
3490
+ const baseUrl = this.options.url || 'http://localhost'
3491
+ const actualPath = new URL(currentUrl, baseUrl).pathname
3492
+ if (/Timeout/i.test(e.message)) {
3493
+ throw new Error(`expected path to be ${normalizedPath}, but found ${normalizePath(actualPath)}`)
3410
3494
  } else {
3411
3495
  throw e
3412
3496
  }
@@ -4092,9 +4176,15 @@ class Playwright extends Helper {
4092
4176
 
4093
4177
  export default Playwright
4094
4178
 
4095
- function buildLocatorString(locator) {
4179
+ export function buildLocatorString(locator) {
4096
4180
  if (locator.isXPath()) {
4097
- return `xpath=${locator.value}`
4181
+ // Make XPath relative so it works correctly within scoped contexts (e.g. within()).
4182
+ // Playwright's XPath engine auto-converts "//..." to ".//..." when the root is not a Document,
4183
+ // but only when the selector starts with "/". Locator methods like at() wrap XPath in
4184
+ // parentheses (e.g. "(//...)[position()=1]"), bypassing that auto-conversion.
4185
+ // We fix this by prepending "." before the first "//" that follows any leading parentheses.
4186
+ const value = locator.value.replace(/^(\(*)\/\//, '$1.//')
4187
+ return `xpath=${value}`
4098
4188
  }
4099
4189
  if (locator.isShadow()) {
4100
4190
  // Convert shadow locator to CSS with >> chaining operator
@@ -4105,25 +4195,22 @@ function buildLocatorString(locator) {
4105
4195
  return locator.simplify()
4106
4196
  }
4107
4197
 
4108
- /**
4109
- * Checks if a locator is a role locator object (e.g., {role: 'button', text: 'Submit', exact: true})
4110
- */
4111
- function isRoleLocatorObject(locator) {
4112
- return locator && typeof locator === 'object' && locator.role && !locator.type
4113
- }
4114
-
4115
4198
  /**
4116
4199
  * Handles role locator objects by converting them to Playwright's getByRole() API
4200
+ * Accepts both raw objects ({role: 'button', text: 'Submit'}) and Locator-wrapped role objects.
4117
4201
  * Returns elements array if role locator, null otherwise
4118
4202
  */
4119
4203
  async function handleRoleLocator(context, locator) {
4120
- if (!isRoleLocatorObject(locator)) return null
4204
+ const loc = new Locator(locator)
4205
+ if (!loc.isRole()) return null
4121
4206
 
4207
+ const roleObj = loc.locator || {}
4122
4208
  const options = {}
4123
- if (locator.text) options.name = locator.text
4124
- if (locator.exact !== undefined) options.exact = locator.exact
4209
+ if (roleObj.text) options.name = roleObj.text
4210
+ if (roleObj.name) options.name = roleObj.name
4211
+ if (roleObj.exact !== undefined) options.exact = roleObj.exact
4125
4212
 
4126
- return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4213
+ return context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined).all()
4127
4214
  }
4128
4215
 
4129
4216
  async function findByRole(context, locator) {
@@ -4192,16 +4279,22 @@ async function proceedClick(locator, context = null, options = {}) {
4192
4279
  assertElementExists(els, locator, 'Clickable element')
4193
4280
  }
4194
4281
 
4195
- await highlightActiveElement.call(this, els[0])
4196
- if (store.debugMode) this.debugSection('Clicked', await elToString(els[0], 1))
4282
+ const opts = store.currentStep?.opts
4283
+ let element
4284
+ if (opts?.elementIndex != null) {
4285
+ element = selectElement(els, locator, this)
4286
+ } else {
4287
+ const strict = (opts?.exact === false || opts?.strictMode === false) ? false : (this.options.strict || opts?.exact === true || opts?.strictMode === true)
4288
+ if (strict) assertOnlyOneElement(els, locator, this)
4289
+ element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0]
4290
+ }
4291
+
4292
+ await highlightActiveElement.call(this, element)
4293
+ if (store.debugMode) this.debugSection('Clicked', await elToString(element, 1))
4197
4294
 
4198
- /*
4199
- using the force true options itself but instead dispatching a click
4200
- */
4201
4295
  if (options.force) {
4202
- await els[0].dispatchEvent('click')
4296
+ await element.dispatchEvent('click')
4203
4297
  } else {
4204
- const element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0]
4205
4298
  await element.click(options)
4206
4299
  }
4207
4300
  const promises = []
@@ -4218,7 +4311,6 @@ async function findClickable(matcher, locator) {
4218
4311
 
4219
4312
  if (!matchedLocator.isFuzzy()) {
4220
4313
  const els = await findElements.call(this, matcher, matchedLocator)
4221
- if (this.options.strict) assertOnlyOneElement(els, locator)
4222
4314
  return els
4223
4315
  }
4224
4316
 
@@ -4227,42 +4319,27 @@ async function findClickable(matcher, locator) {
4227
4319
 
4228
4320
  try {
4229
4321
  els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
4230
- if (els.length) {
4231
- if (this.options.strict) assertOnlyOneElement(els, locator)
4232
- return els
4233
- }
4322
+ if (els.length) return els
4234
4323
  } catch (err) {
4235
4324
  // getByRole not supported or failed
4236
4325
  }
4237
4326
 
4238
4327
  try {
4239
4328
  els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
4240
- if (els.length) {
4241
- if (this.options.strict) assertOnlyOneElement(els, locator)
4242
- return els
4243
- }
4329
+ if (els.length) return els
4244
4330
  } catch (err) {
4245
4331
  // getByRole not supported or failed
4246
4332
  }
4247
4333
 
4248
4334
  els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
4249
- if (els.length) {
4250
- if (this.options.strict) assertOnlyOneElement(els, locator)
4251
- return els
4252
- }
4335
+ if (els.length) return els
4253
4336
 
4254
4337
  els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
4255
- if (els.length) {
4256
- if (this.options.strict) assertOnlyOneElement(els, locator)
4257
- return els
4258
- }
4338
+ if (els.length) return els
4259
4339
 
4260
4340
  try {
4261
4341
  els = await findElements.call(this, matcher, Locator.clickable.self(literal))
4262
- if (els.length) {
4263
- if (this.options.strict) assertOnlyOneElement(els, locator)
4264
- return els
4265
- }
4342
+ if (els.length) return els
4266
4343
  } catch (err) {
4267
4344
  // Do nothing
4268
4345
  }
@@ -4335,34 +4412,42 @@ async function proceedIsChecked(assertType, option) {
4335
4412
  return truth(`checkable ${option}`, 'to be checked')[assertType](selected)
4336
4413
  }
4337
4414
 
4338
- async function findFields(locator) {
4339
- // Handle role locators with text/exact options
4340
- if (isRoleLocatorObject(locator)) {
4341
- const page = await this.page
4342
- const roleElements = await handleRoleLocator(page, locator)
4343
- if (roleElements) return roleElements
4415
+ async function findFields(locator, context = null) {
4416
+ let contextEl
4417
+ if (context) {
4418
+ const contextEls = await this._locate(context)
4419
+ assertElementExists(contextEls, context, 'Context element')
4420
+ contextEl = contextEls[0]
4344
4421
  }
4345
4422
 
4423
+ const locateFn = contextEl
4424
+ ? loc => findElements.call(this, contextEl, loc)
4425
+ : loc => this._locate(loc)
4426
+
4427
+ const matcher = contextEl || (await this.page)
4428
+ const roleElements = await handleRoleLocator(matcher, locator)
4429
+ if (roleElements) return roleElements
4430
+
4346
4431
  const matchedLocator = new Locator(locator)
4347
4432
  if (!matchedLocator.isFuzzy()) {
4348
- return this._locate(matchedLocator)
4433
+ return locateFn(matchedLocator)
4349
4434
  }
4350
4435
  const literal = xpathLocator.literal(locator)
4351
4436
 
4352
- let els = await this._locate({ xpath: Locator.field.labelEquals(literal) })
4437
+ let els = await locateFn({ xpath: Locator.field.labelEquals(literal) })
4353
4438
  if (els.length) {
4354
4439
  return els
4355
4440
  }
4356
4441
 
4357
- els = await this._locate({ xpath: Locator.field.labelContains(literal) })
4442
+ els = await locateFn({ xpath: Locator.field.labelContains(literal) })
4358
4443
  if (els.length) {
4359
4444
  return els
4360
4445
  }
4361
- els = await this._locate({ xpath: Locator.field.byName(literal) })
4446
+ els = await locateFn({ xpath: Locator.field.byName(literal) })
4362
4447
  if (els.length) {
4363
4448
  return els
4364
4449
  }
4365
- return this._locate({ css: locator })
4450
+ return locateFn({ css: locator })
4366
4451
  }
4367
4452
 
4368
4453
  async function proceedSelect(context, el, option) {
@@ -4411,8 +4496,8 @@ async function proceedSelect(context, el, option) {
4411
4496
  return this._waitForAction()
4412
4497
  }
4413
4498
 
4414
- async function proceedSeeInField(assertType, field, value) {
4415
- const els = await findFields.call(this, field)
4499
+ async function proceedSeeInField(assertType, field, value, context) {
4500
+ const els = await findFields.call(this, field, context)
4416
4501
  assertElementExists(els, field, 'Field')
4417
4502
  const el = els[0]
4418
4503
  const tag = await el.evaluate(e => e.tagName)
@@ -4526,9 +4611,10 @@ function assertElementExists(res, locator, prefix, suffix) {
4526
4611
  }
4527
4612
  }
4528
4613
 
4529
- function assertOnlyOneElement(elements, locator) {
4614
+ function assertOnlyOneElement(elements, locator, helper) {
4530
4615
  if (elements.length > 1) {
4531
- throw new MultipleElementsFound(locator, elements)
4616
+ const webElements = elements.map(el => new WebElement(el, helper))
4617
+ throw new MultipleElementsFound(locator, webElements)
4532
4618
  }
4533
4619
  }
4534
4620