codeceptjs 4.0.0-beta.9.esm-aria → 4.0.0-rc.10

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 (69) hide show
  1. package/README.md +39 -27
  2. package/bin/codecept.js +2 -2
  3. package/bin/mcp-server.js +610 -0
  4. package/docs/webapi/appendField.mustache +5 -0
  5. package/docs/webapi/attachFile.mustache +12 -0
  6. package/docs/webapi/checkOption.mustache +1 -1
  7. package/docs/webapi/clearField.mustache +5 -0
  8. package/docs/webapi/dontSeeCurrentPathEquals.mustache +10 -0
  9. package/docs/webapi/dontSeeElement.mustache +4 -0
  10. package/docs/webapi/dontSeeInField.mustache +5 -0
  11. package/docs/webapi/fillField.mustache +5 -0
  12. package/docs/webapi/moveCursorTo.mustache +5 -1
  13. package/docs/webapi/seeCurrentPathEquals.mustache +10 -0
  14. package/docs/webapi/seeElement.mustache +4 -0
  15. package/docs/webapi/seeInField.mustache +5 -0
  16. package/docs/webapi/selectOption.mustache +5 -0
  17. package/docs/webapi/uncheckOption.mustache +1 -1
  18. package/lib/actor.js +12 -8
  19. package/lib/codecept.js +51 -18
  20. package/lib/command/definitions.js +14 -7
  21. package/lib/command/init.js +2 -4
  22. package/lib/command/run-workers.js +13 -2
  23. package/lib/command/workers/runTests.js +121 -9
  24. package/lib/config.js +24 -33
  25. package/lib/container.js +177 -28
  26. package/lib/element/WebElement.js +81 -2
  27. package/lib/els.js +12 -6
  28. package/lib/helper/Appium.js +8 -8
  29. package/lib/helper/GraphQL.js +6 -4
  30. package/lib/helper/JSONResponse.js +3 -4
  31. package/lib/helper/Playwright.js +339 -505
  32. package/lib/helper/Puppeteer.js +324 -89
  33. package/lib/helper/REST.js +15 -9
  34. package/lib/helper/WebDriver.js +311 -81
  35. package/lib/helper/errors/ElementNotFound.js +5 -2
  36. package/lib/helper/errors/MultipleElementsFound.js +52 -0
  37. package/lib/helper/extras/elementSelection.js +58 -0
  38. package/lib/helper/scripts/dropFile.js +11 -0
  39. package/lib/html.js +14 -1
  40. package/lib/listener/config.js +11 -3
  41. package/lib/listener/globalRetry.js +32 -6
  42. package/lib/listener/helpers.js +2 -14
  43. package/lib/locator.js +32 -0
  44. package/lib/mocha/cli.js +16 -0
  45. package/lib/mocha/factory.js +7 -27
  46. package/lib/mocha/gherkin.js +4 -4
  47. package/lib/mocha/test.js +4 -2
  48. package/lib/output.js +2 -2
  49. package/lib/plugin/aiTrace.js +464 -0
  50. package/lib/plugin/auth.js +2 -1
  51. package/lib/plugin/retryFailedStep.js +28 -19
  52. package/lib/plugin/stepByStepReport.js +5 -1
  53. package/lib/step/base.js +14 -1
  54. package/lib/step/config.js +15 -2
  55. package/lib/step/meta.js +18 -1
  56. package/lib/step/record.js +9 -1
  57. package/lib/utils/loaderCheck.js +162 -0
  58. package/lib/utils/typescript.js +449 -0
  59. package/lib/utils.js +48 -0
  60. package/lib/workers.js +163 -54
  61. package/package.json +43 -32
  62. package/typings/index.d.ts +120 -4
  63. package/lib/helper/extras/PlaywrightLocator.js +0 -110
  64. package/lib/listener/enhancedGlobalRetry.js +0 -110
  65. package/lib/plugin/enhancedRetryFailedStep.js +0 -99
  66. package/lib/plugin/htmlReporter.js +0 -3648
  67. package/lib/retryCoordinator.js +0 -207
  68. package/typings/promiseBasedTypes.d.ts +0 -11011
  69. package/typings/types.d.ts +0 -13073
@@ -23,21 +23,26 @@ import {
23
23
  clearString,
24
24
  requireWithFallback,
25
25
  normalizeSpacesInString,
26
+ normalizePath,
27
+ resolveUrl,
26
28
  relativeDir,
29
+ getMimeType,
30
+ base64EncodeFile,
27
31
  } from '../utils.js'
28
32
  import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
29
33
  import ElementNotFound from './errors/ElementNotFound.js'
34
+ import MultipleElementsFound from './errors/MultipleElementsFound.js'
30
35
  import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
31
36
  import Popup from './extras/Popup.js'
32
37
  import Console from './extras/Console.js'
33
38
  import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
39
+ import { dropFile } from './scripts/dropFile.js'
34
40
  import WebElement from '../element/WebElement.js'
41
+ import { selectElement } from './extras/elementSelection.js'
35
42
 
36
43
  let playwright
37
44
  let perfTiming
38
45
  let defaultSelectorEnginesInitialized = false
39
- let registeredCustomLocatorStrategies = new Set()
40
- let globalCustomLocatorStrategies = new Map()
41
46
 
42
47
  // Use global object to track selector registration across workers
43
48
  if (typeof global.__playwrightSelectorsRegistered === 'undefined') {
@@ -100,7 +105,6 @@ const pathSeparator = path.sep
100
105
  * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
101
106
  * @prop {object} [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har).
102
107
  * @prop {string} [testIdAttribute=data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id).
103
- * @prop {object} [customLocatorStrategies] - custom locator strategies. An object with keys as strategy names and values as JavaScript functions. Example: `{ byRole: (selector, root) => { return root.querySelector(`[role="${selector}"]`) } }`
104
108
  * @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object)
105
109
  * passed directly to `browser.newContext`.
106
110
  * If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`),
@@ -355,28 +359,6 @@ class Playwright extends Helper {
355
359
  this.recordingWebSocketMessages = false
356
360
  this.recordedWebSocketMessagesAtLeastOnce = false
357
361
  this.cdpSession = null
358
-
359
- // Filter out invalid customLocatorStrategies (empty arrays, objects without functions)
360
- // This can happen in worker threads where config is serialized/deserialized
361
- let validCustomLocators = null
362
- if (typeof config.customLocatorStrategies === 'object' && config.customLocatorStrategies !== null) {
363
- // Check if it's an empty array or object with no function properties
364
- const entries = Object.entries(config.customLocatorStrategies)
365
- const hasFunctions = entries.some(([_, value]) => typeof value === 'function')
366
- if (hasFunctions) {
367
- validCustomLocators = config.customLocatorStrategies
368
- }
369
- }
370
-
371
- this.customLocatorStrategies = validCustomLocators
372
- this._customLocatorsRegistered = false
373
-
374
- // Add custom locator strategies to global registry for early registration
375
- if (this.customLocatorStrategies) {
376
- for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
377
- globalCustomLocatorStrategies.set(strategyName, strategyFunction)
378
- }
379
- }
380
362
 
381
363
  // Add test failure tracking to prevent false positives
382
364
  this.testFailures = []
@@ -416,6 +398,8 @@ class Playwright extends Helper {
416
398
  ignoreHTTPSErrors: false, // Adding it here o that context can be set up to ignore the SSL errors,
417
399
  highlightElement: false,
418
400
  storageState: undefined,
401
+ onResponse: null,
402
+ strict: false,
419
403
  }
420
404
 
421
405
  process.env.testIdAttribute = 'data-testid'
@@ -522,16 +506,6 @@ class Playwright extends Helper {
522
506
  }
523
507
  }
524
508
 
525
- // Ensure custom locators from this instance are in the global registry
526
- // This is critical for worker threads where globalCustomLocatorStrategies is a new Map
527
- if (this.customLocatorStrategies) {
528
- for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
529
- if (!globalCustomLocatorStrategies.has(strategyName)) {
530
- globalCustomLocatorStrategies.set(strategyName, strategyFunction)
531
- }
532
- }
533
- }
534
-
535
509
  // register an internal selector engine for reading value property of elements in a selector
536
510
  try {
537
511
  // Always wrap in try-catch since selectors might be registered globally across workers
@@ -562,54 +536,6 @@ class Playwright extends Helper {
562
536
  // Ignore if already set
563
537
  }
564
538
  }
565
-
566
- // Register all custom locator strategies from the global registry
567
- for (const [strategyName, strategyFunction] of globalCustomLocatorStrategies.entries()) {
568
- if (!registeredCustomLocatorStrategies.has(strategyName)) {
569
- try {
570
- // Create a selector engine factory function exactly like createValueEngine pattern
571
- // Capture variables in closure to avoid reference issues
572
- const createCustomEngine = ((name, func) => {
573
- return () => {
574
- return {
575
- create() {
576
- return null
577
- },
578
- query(root, selector) {
579
- try {
580
- if (!root) return null
581
- const result = func(selector, root)
582
- return Array.isArray(result) ? result[0] : result
583
- } catch (error) {
584
- console.warn(`Error in custom locator "${name}":`, error)
585
- return null
586
- }
587
- },
588
- queryAll(root, selector) {
589
- try {
590
- if (!root) return []
591
- const result = func(selector, root)
592
- return Array.isArray(result) ? result : result ? [result] : []
593
- } catch (error) {
594
- console.warn(`Error in custom locator "${name}":`, error)
595
- return []
596
- }
597
- },
598
- }
599
- }
600
- })(strategyName, strategyFunction)
601
-
602
- await playwright.selectors.register(strategyName, createCustomEngine)
603
- registeredCustomLocatorStrategies.add(strategyName)
604
- } catch (error) {
605
- if (!error.message.includes('already registered')) {
606
- console.warn(`Failed to register custom locator strategy '${strategyName}':`, error)
607
- } else {
608
- console.log(`Custom locator strategy '${strategyName}' already registered`)
609
- }
610
- }
611
- }
612
- }
613
539
  } catch (e) {
614
540
  console.warn(e)
615
541
  }
@@ -794,10 +720,7 @@ class Playwright extends Helper {
794
720
  await Promise.allSettled(pages.map(p => p.close().catch(() => {})))
795
721
  }
796
722
  // Use timeout to prevent hanging (10s should be enough for browser cleanup)
797
- await Promise.race([
798
- this._stopBrowser(),
799
- new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout')), 10000)),
800
- ])
723
+ await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout')), 10000))])
801
724
  } catch (e) {
802
725
  console.warn('Warning during browser restart in _after:', e.message)
803
726
  // Force cleanup even on timeout
@@ -840,10 +763,7 @@ class Playwright extends Helper {
840
763
  if (this.isRunning) {
841
764
  try {
842
765
  // Add timeout protection to prevent hanging
843
- await Promise.race([
844
- this._stopBrowser(),
845
- new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in afterSuite')), 10000)),
846
- ])
766
+ await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in afterSuite')), 10000))])
847
767
  } catch (e) {
848
768
  console.warn('Warning during suite cleanup:', e.message)
849
769
  // Track suite cleanup failures
@@ -928,7 +848,7 @@ class Playwright extends Helper {
928
848
  }
929
849
 
930
850
  async _finishTest() {
931
- if ((restartsSession() || restartsContext() || restartsBrowser()) && this.isRunning) {
851
+ if (this.isRunning) {
932
852
  try {
933
853
  await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Test finish timeout')), 10000))])
934
854
  } catch (e) {
@@ -954,10 +874,7 @@ class Playwright extends Helper {
954
874
  if (this.isRunning) {
955
875
  try {
956
876
  // Add timeout protection to prevent hanging
957
- await Promise.race([
958
- this._stopBrowser(),
959
- new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in cleanup')), 10000)),
960
- ])
877
+ await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in cleanup')), 10000))])
961
878
  } catch (e) {
962
879
  console.warn('Warning during final cleanup:', e.message)
963
880
  // Force cleanup on timeout
@@ -970,10 +887,7 @@ class Playwright extends Helper {
970
887
  if (this.browser) {
971
888
  try {
972
889
  // Add timeout protection to prevent hanging
973
- await Promise.race([
974
- this._stopBrowser(),
975
- new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in forced cleanup')), 10000)),
976
- ])
890
+ await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in forced cleanup')), 10000))])
977
891
  } catch (e) {
978
892
  console.warn('Warning during forced cleanup:', e.message)
979
893
  // Force cleanup on timeout
@@ -1288,30 +1202,6 @@ class Playwright extends Helper {
1288
1202
  return this.browser
1289
1203
  }
1290
1204
 
1291
- _lookupCustomLocator(customStrategy) {
1292
- if (typeof this.customLocatorStrategies !== 'object' || this.customLocatorStrategies === null) {
1293
- return null
1294
- }
1295
- const strategy = this.customLocatorStrategies[customStrategy]
1296
- return typeof strategy === 'function' ? strategy : null
1297
- }
1298
-
1299
- _isCustomLocator(locator) {
1300
- const locatorObj = new Locator(locator)
1301
- if (locatorObj.isCustom()) {
1302
- const customLocator = this._lookupCustomLocator(locatorObj.type)
1303
- if (customLocator) {
1304
- return true
1305
- }
1306
- throw new Error('Please define "customLocatorStrategies" as an Object and the Locator Strategy as a "function".')
1307
- }
1308
- return false
1309
- }
1310
-
1311
- _isCustomLocatorStrategyDefined() {
1312
- return !!(this.customLocatorStrategies && Object.keys(this.customLocatorStrategies).length > 0)
1313
- }
1314
-
1315
1205
  /**
1316
1206
  * Create a new browser context with a page. \
1317
1207
  * Usually it should be run from a custom helper after call of `_startBrowser()`
@@ -1323,63 +1213,11 @@ class Playwright extends Helper {
1323
1213
  }
1324
1214
  this.browserContext = await this.browser.newContext(contextOptions)
1325
1215
 
1326
- // Register custom locator strategies for this context
1327
- await this._registerCustomLocatorStrategies()
1328
-
1329
1216
  const page = await this.browserContext.newPage()
1330
1217
  targetCreatedHandler.call(this, page)
1331
1218
  await this._setPage(page)
1332
1219
  }
1333
1220
 
1334
- async _registerCustomLocatorStrategies() {
1335
- if (!this.customLocatorStrategies) return
1336
-
1337
- for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
1338
- if (!registeredCustomLocatorStrategies.has(strategyName)) {
1339
- try {
1340
- const createCustomEngine = ((name, func) => {
1341
- return () => {
1342
- return {
1343
- create(root, target) {
1344
- return null
1345
- },
1346
- query(root, selector) {
1347
- try {
1348
- if (!root) return null
1349
- const result = func(selector, root)
1350
- return Array.isArray(result) ? result[0] : result
1351
- } catch (error) {
1352
- console.warn(`Error in custom locator "${name}":`, error)
1353
- return null
1354
- }
1355
- },
1356
- queryAll(root, selector) {
1357
- try {
1358
- if (!root) return []
1359
- const result = func(selector, root)
1360
- return Array.isArray(result) ? result : result ? [result] : []
1361
- } catch (error) {
1362
- console.warn(`Error in custom locator "${name}":`, error)
1363
- return []
1364
- }
1365
- },
1366
- }
1367
- }
1368
- })(strategyName, strategyFunction)
1369
-
1370
- await playwright.selectors.register(strategyName, createCustomEngine)
1371
- registeredCustomLocatorStrategies.add(strategyName)
1372
- } catch (error) {
1373
- if (!error.message.includes('already registered')) {
1374
- console.warn(`Failed to register custom locator strategy '${strategyName}':`, error)
1375
- } else {
1376
- console.log(`Custom locator strategy '${strategyName}' already registered`)
1377
- }
1378
- }
1379
- }
1380
- }
1381
- }
1382
-
1383
1221
  _getType() {
1384
1222
  return this.browser._type
1385
1223
  }
@@ -1390,7 +1228,7 @@ class Playwright extends Helper {
1390
1228
  this.context = null
1391
1229
  this.frame = null
1392
1230
  popupStore.clear()
1393
-
1231
+
1394
1232
  // Remove all event listeners to prevent hanging
1395
1233
  if (this.browser) {
1396
1234
  try {
@@ -1399,7 +1237,8 @@ class Playwright extends Helper {
1399
1237
  // Ignore errors if browser is already closed
1400
1238
  }
1401
1239
  }
1402
-
1240
+
1241
+ // Close browserContext if recordHar is enabled
1403
1242
  if (this.options.recordHar && this.browserContext) {
1404
1243
  try {
1405
1244
  await this.browserContext.close()
@@ -1408,22 +1247,17 @@ class Playwright extends Helper {
1408
1247
  }
1409
1248
  }
1410
1249
  this.browserContext = null
1411
-
1250
+
1251
+ // Initiate browser close without waiting for it to complete
1252
+ // The browser process will be cleaned up when the Node process exits
1412
1253
  if (this.browser) {
1413
1254
  try {
1414
- // Add timeout to prevent browser.close() from hanging indefinitely
1415
- await Promise.race([
1416
- this.browser.close(),
1417
- new Promise((_, reject) =>
1418
- setTimeout(() => reject(new Error('Browser close timeout')), 5000)
1419
- )
1420
- ])
1255
+ // Fire and forget - don't wait for close to complete
1256
+ this.browser.close().catch(() => {
1257
+ // Silently ignore any errors during async close
1258
+ })
1421
1259
  } catch (e) {
1422
- // Ignore errors if browser is already closed or timeout
1423
- if (!e.message?.includes('Browser close timeout')) {
1424
- // Non-timeout error, can be ignored as well
1425
- }
1426
- // Force cleanup even on error
1260
+ // Ignore any synchronous errors
1427
1261
  }
1428
1262
  }
1429
1263
  this.browser = null
@@ -1539,7 +1373,7 @@ class Playwright extends Helper {
1539
1373
  acceptDownloads: true,
1540
1374
  ...this.options.emulate,
1541
1375
  }
1542
-
1376
+
1543
1377
  try {
1544
1378
  this.browserContext = await this.browser.newContext(contextOptions)
1545
1379
  } catch (err) {
@@ -1662,8 +1496,23 @@ class Playwright extends Helper {
1662
1496
  *
1663
1497
  */
1664
1498
  async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
1665
- const el = await this._locateElement(locator)
1666
- assertElementExists(el, locator)
1499
+ let context = null
1500
+ if (typeof offsetX !== 'number') {
1501
+ context = offsetX
1502
+ offsetX = 0
1503
+ }
1504
+
1505
+ let el
1506
+ if (context) {
1507
+ const contextEls = await this._locate(context)
1508
+ assertElementExists(contextEls, context, 'Context element')
1509
+ el = await findElements.call(this, contextEls[0], locator)
1510
+ assertElementExists(el, locator)
1511
+ el = el[0]
1512
+ } else {
1513
+ el = await this._locateElement(locator)
1514
+ assertElementExists(el, locator)
1515
+ }
1667
1516
 
1668
1517
  // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
1669
1518
  const { x, y } = await clickablePoint(el)
@@ -1927,7 +1776,11 @@ class Playwright extends Helper {
1927
1776
  */
1928
1777
  async _locateElement(locator) {
1929
1778
  const context = await this._getContext()
1930
- return findElement(context, locator)
1779
+ const elements = await findElements.call(this, context, locator)
1780
+ if (elements.length === 0) {
1781
+ throw new ElementNotFound(locator, 'Element', 'was not found')
1782
+ }
1783
+ return selectElement(elements, locator, this)
1931
1784
  }
1932
1785
 
1933
1786
  /**
@@ -1942,7 +1795,7 @@ class Playwright extends Helper {
1942
1795
  const context = providedContext || (await this._getContext())
1943
1796
  const els = await findCheckable.call(this, locator, context)
1944
1797
  assertElementExists(els[0], locator, 'Checkbox or radio')
1945
- return els[0]
1798
+ return selectElement(els, locator, this)
1946
1799
  }
1947
1800
 
1948
1801
  /**
@@ -2110,8 +1963,15 @@ class Playwright extends Helper {
2110
1963
  * {{> seeElement }}
2111
1964
  *
2112
1965
  */
2113
- async seeElement(locator) {
2114
- let els = await this._locate(locator)
1966
+ async seeElement(locator, context = null) {
1967
+ let els
1968
+ if (context) {
1969
+ const contextEls = await this._locate(context)
1970
+ assertElementExists(contextEls, context, 'Context element')
1971
+ els = await findElements.call(this, contextEls[0], locator)
1972
+ } else {
1973
+ els = await this._locate(locator)
1974
+ }
2115
1975
  els = await Promise.all(els.map(el => el.isVisible()))
2116
1976
  try {
2117
1977
  return empty('visible elements').negate(els.filter(v => v).fill('ELEMENT'))
@@ -2124,8 +1984,15 @@ class Playwright extends Helper {
2124
1984
  * {{> dontSeeElement }}
2125
1985
  *
2126
1986
  */
2127
- async dontSeeElement(locator) {
2128
- let els = await this._locate(locator)
1987
+ async dontSeeElement(locator, context = null) {
1988
+ let els
1989
+ if (context) {
1990
+ const contextEls = await this._locate(context)
1991
+ assertElementExists(contextEls, context, 'Context element')
1992
+ els = await findElements.call(this, contextEls[0], locator)
1993
+ } else {
1994
+ els = await this._locate(locator)
1995
+ }
2129
1996
  els = await Promise.all(els.map(el => el.isVisible()))
2130
1997
  try {
2131
1998
  return empty('visible elements').assert(els.filter(v => v).fill('ELEMENT'))
@@ -2411,10 +2278,10 @@ class Playwright extends Helper {
2411
2278
  * {{> fillField }}
2412
2279
  *
2413
2280
  */
2414
- async fillField(field, value) {
2415
- const els = await findFields.call(this, field)
2281
+ async fillField(field, value, context = null) {
2282
+ const els = await findFields.call(this, field, context)
2416
2283
  assertElementExists(els, field, 'Field')
2417
- const el = els[0]
2284
+ const el = selectElement(els, field, this)
2418
2285
 
2419
2286
  await el.clear()
2420
2287
  if (store.debugMode) this.debugSection('Focused', await elToString(el, 1))
@@ -2427,27 +2294,13 @@ class Playwright extends Helper {
2427
2294
  }
2428
2295
 
2429
2296
  /**
2430
- * Clears the text input element: `<input>`, `<textarea>` or `[contenteditable]` .
2431
- *
2432
- *
2433
- * Examples:
2434
- *
2435
- * ```js
2436
- * I.clearField('.text-area')
2437
- *
2438
- * // if this doesn't work use force option
2439
- * I.clearField('#submit', { force: true })
2440
- * ```
2441
- * Use `force` to bypass the [actionability](https://playwright.dev/docs/actionability) checks.
2442
- *
2443
- * @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
2444
- * @param {any} [options] [Additional options](https://playwright.dev/docs/api/class-locator#locator-clear) for available options object as 2nd argument.
2297
+ * {{> clearField }}
2445
2298
  */
2446
- async clearField(locator, options = {}) {
2447
- const els = await findFields.call(this, locator)
2299
+ async clearField(locator, context = null) {
2300
+ const els = await findFields.call(this, locator, context)
2448
2301
  assertElementExists(els, locator, 'Field to clear')
2449
2302
 
2450
- const el = els[0]
2303
+ const el = selectElement(els, locator, this)
2451
2304
 
2452
2305
  await highlightActiveElement.call(this, el)
2453
2306
 
@@ -2459,68 +2312,101 @@ class Playwright extends Helper {
2459
2312
  /**
2460
2313
  * {{> appendField }}
2461
2314
  */
2462
- async appendField(field, value) {
2463
- const els = await findFields.call(this, field)
2315
+ async appendField(field, value, context = null) {
2316
+ const els = await findFields.call(this, field, context)
2464
2317
  assertElementExists(els, field, 'Field')
2465
- await highlightActiveElement.call(this, els[0])
2466
- await els[0].press('End')
2467
- await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
2318
+ const el = selectElement(els, field, this)
2319
+ await highlightActiveElement.call(this, el)
2320
+ await el.press('End')
2321
+ await el.type(value.toString(), { delay: this.options.pressKeyDelay })
2468
2322
  return this._waitForAction()
2469
2323
  }
2470
2324
 
2471
2325
  /**
2472
2326
  * {{> seeInField }}
2473
2327
  */
2474
- async seeInField(field, value) {
2328
+ async seeInField(field, value, context = null) {
2475
2329
  const _value = typeof value === 'boolean' ? value : value.toString()
2476
- return proceedSeeInField.call(this, 'assert', field, _value)
2330
+ return proceedSeeInField.call(this, 'assert', field, _value, context)
2477
2331
  }
2478
2332
 
2479
2333
  /**
2480
2334
  * {{> dontSeeInField }}
2481
2335
  */
2482
- async dontSeeInField(field, value) {
2336
+ async dontSeeInField(field, value, context = null) {
2483
2337
  const _value = typeof value === 'boolean' ? value : value.toString()
2484
- return proceedSeeInField.call(this, 'negate', field, _value)
2338
+ return proceedSeeInField.call(this, 'negate', field, _value, context)
2485
2339
  }
2486
2340
 
2487
2341
  /**
2488
2342
  * {{> attachFile }}
2489
2343
  *
2490
2344
  */
2491
- async attachFile(locator, pathToFile) {
2345
+ async attachFile(locator, pathToFile, context = null) {
2492
2346
  const file = path.join(global.codecept_dir, pathToFile)
2493
2347
 
2494
2348
  if (!fileExists(file)) {
2495
2349
  throw new Error(`File at ${file} can not be found on local system`)
2496
2350
  }
2497
- const els = await findFields.call(this, locator)
2498
- assertElementExists(els, locator, 'Field')
2499
- await els[0].setInputFiles(file)
2351
+ const els = await findFields.call(this, locator, context)
2352
+ if (els.length) {
2353
+ const el = selectElement(els, locator, this)
2354
+ const tag = await el.evaluate(el => el.tagName)
2355
+ const type = await el.evaluate(el => el.type)
2356
+ if (tag === 'INPUT' && type === 'file') {
2357
+ await el.setInputFiles(file)
2358
+ return this._waitForAction()
2359
+ }
2360
+ }
2361
+
2362
+ const targetEls = els.length ? els : await this._locate(locator)
2363
+ assertElementExists(targetEls, locator, 'Element')
2364
+ const el = selectElement(targetEls, locator, this)
2365
+ const fileData = {
2366
+ base64Content: base64EncodeFile(file),
2367
+ fileName: path.basename(file),
2368
+ mimeType: getMimeType(path.basename(file)),
2369
+ }
2370
+ await el.evaluate(dropFile, fileData)
2500
2371
  return this._waitForAction()
2501
2372
  }
2502
2373
 
2503
2374
  /**
2504
2375
  * {{> selectOption }}
2505
2376
  */
2506
- async selectOption(select, option) {
2507
- const els = await findFields.call(this, select)
2508
- assertElementExists(els, select, 'Selectable field')
2509
- const el = els[0]
2377
+ async selectOption(select, option, context = null) {
2378
+ const pageContext = await this.context
2379
+ const matchedLocator = new Locator(select)
2510
2380
 
2511
- await highlightActiveElement.call(this, el)
2512
- let optionToSelect = ''
2381
+ let contextEl
2382
+ if (context) {
2383
+ const contextEls = await this._locate(context)
2384
+ assertElementExists(contextEls, context, 'Context element')
2385
+ contextEl = contextEls[0]
2386
+ }
2513
2387
 
2514
- try {
2515
- optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
2516
- } catch (e) {
2517
- optionToSelect = option
2388
+ // Strict locator
2389
+ if (!matchedLocator.isFuzzy()) {
2390
+ this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
2391
+ const els = contextEl ? await findElements.call(this, contextEl, matchedLocator) : await this._locate(matchedLocator)
2392
+ assertElementExists(els, select, 'Selectable element')
2393
+ return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2518
2394
  }
2519
2395
 
2520
- if (!Array.isArray(option)) option = [optionToSelect]
2396
+ // Fuzzy: try combobox
2397
+ this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
2398
+ const comboboxSearchCtx = contextEl || pageContext
2399
+ let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value })
2400
+ if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2521
2401
 
2522
- await el.selectOption(option)
2523
- return this._waitForAction()
2402
+ // Fuzzy: try listbox
2403
+ els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
2404
+ if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2405
+
2406
+ // Fuzzy: try native select
2407
+ els = await findFields.call(this, select, context)
2408
+ assertElementExists(els, select, 'Selectable element')
2409
+ return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2524
2410
  }
2525
2411
 
2526
2412
  /**
@@ -2561,6 +2447,26 @@ class Playwright extends Helper {
2561
2447
  urlEquals(this.options.url).negate(url, await this._getPageUrl())
2562
2448
  }
2563
2449
 
2450
+ /**
2451
+ * {{> seeCurrentPathEquals }}
2452
+ */
2453
+ async seeCurrentPathEquals(path) {
2454
+ const currentUrl = await this._getPageUrl()
2455
+ const baseUrl = this.options.url || 'http://localhost'
2456
+ const actualPath = new URL(currentUrl, baseUrl).pathname
2457
+ return equals('url path').assert(normalizePath(path), normalizePath(actualPath))
2458
+ }
2459
+
2460
+ /**
2461
+ * {{> dontSeeCurrentPathEquals }}
2462
+ */
2463
+ async dontSeeCurrentPathEquals(path) {
2464
+ const currentUrl = await this._getPageUrl()
2465
+ const baseUrl = this.options.url || 'http://localhost'
2466
+ const actualPath = new URL(currentUrl, baseUrl).pathname
2467
+ return equals('url path').negate(normalizePath(path), normalizePath(actualPath))
2468
+ }
2469
+
2564
2470
  /**
2565
2471
  * {{> see }}
2566
2472
  *
@@ -2778,23 +2684,12 @@ class Playwright extends Helper {
2778
2684
  _contextLocator(locator) {
2779
2685
  const locatorObj = new Locator(locator, 'css')
2780
2686
 
2781
- // Handle custom locators differently
2782
- if (locatorObj.isCustom()) {
2783
- return buildCustomLocatorString(locatorObj)
2784
- }
2785
-
2786
2687
  locator = buildLocatorString(locatorObj)
2787
2688
 
2788
2689
  if (this.contextLocator) {
2789
2690
  const contextLocatorObj = new Locator(this.contextLocator, 'css')
2790
- if (contextLocatorObj.isCustom()) {
2791
- // For custom context locators, we can't use the >> syntax
2792
- // Instead, we'll need to handle this differently in the calling methods
2793
- return locator
2794
- } else {
2795
- const contextLocator = buildLocatorString(contextLocatorObj)
2796
- locator = `${contextLocator} >> ${locator}`
2797
- }
2691
+ const contextLocator = buildLocatorString(contextLocatorObj)
2692
+ locator = `${contextLocator} >> ${locator}`
2798
2693
  }
2799
2694
 
2800
2695
  return locator
@@ -2805,43 +2700,28 @@ class Playwright extends Helper {
2805
2700
  *
2806
2701
  */
2807
2702
  async grabTextFrom(locator) {
2808
- // Handle role locators with text/exact options
2809
- if (isRoleLocatorObject(locator)) {
2810
- const elements = await handleRoleLocator(this.page, locator)
2811
- if (elements && elements.length > 0) {
2812
- const text = await elements[0].textContent()
2813
- assertElementExists(text, JSON.stringify(locator))
2814
- this.debugSection('Text', text)
2815
- return text
2816
- }
2703
+ const roleElements = await handleRoleLocator(this.page, locator)
2704
+ if (roleElements && roleElements.length > 0) {
2705
+ const text = await roleElements[0].textContent()
2706
+ assertElementExists(text, JSON.stringify(locator))
2707
+ this.debugSection('Text', text)
2708
+ return text
2817
2709
  }
2818
2710
 
2819
2711
  const locatorObj = new Locator(locator, 'css')
2820
2712
 
2821
- if (locatorObj.isCustom()) {
2822
- // For custom locators, find the element first
2823
- const elements = await findCustomElements.call(this, this.page, locatorObj)
2824
- if (elements.length === 0) {
2825
- throw new Error(`Element not found: ${locatorObj.toString()}`)
2826
- }
2827
- const text = await elements[0].textContent()
2828
- assertElementExists(text, locatorObj.toString())
2713
+ locator = this._contextLocator(locator)
2714
+ try {
2715
+ const text = await this.page.textContent(locator)
2716
+ assertElementExists(text, locator)
2829
2717
  this.debugSection('Text', text)
2830
2718
  return text
2831
- } else {
2832
- locator = this._contextLocator(locator)
2833
- try {
2834
- const text = await this.page.textContent(locator)
2835
- assertElementExists(text, locator)
2836
- this.debugSection('Text', text)
2837
- return text
2838
- } catch (error) {
2839
- // Convert Playwright timeout errors to ElementNotFound for consistency
2840
- if (error.message && error.message.includes('Timeout')) {
2841
- throw new ElementNotFound(locator, 'text')
2842
- }
2843
- throw error
2719
+ } catch (error) {
2720
+ // Convert Playwright timeout errors to ElementNotFound for consistency
2721
+ if (error.message && error.message.includes('Timeout')) {
2722
+ throw new ElementNotFound(locator, 'text')
2844
2723
  }
2724
+ throw error
2845
2725
  }
2846
2726
  }
2847
2727
 
@@ -3183,14 +3063,14 @@ class Playwright extends Helper {
3183
3063
  this.debugSection('Response', await response.text())
3184
3064
 
3185
3065
  // hook to allow JSON response handle this
3186
- if (this.config.onResponse) {
3066
+ if (this.options.onResponse) {
3187
3067
  const axiosResponse = {
3188
3068
  data: await response.json(),
3189
3069
  status: response.status(),
3190
3070
  statusText: response.statusText(),
3191
3071
  headers: response.headers(),
3192
3072
  }
3193
- this.config.onResponse(axiosResponse)
3073
+ this.options.onResponse(axiosResponse)
3194
3074
  }
3195
3075
 
3196
3076
  return response
@@ -3406,16 +3286,7 @@ class Playwright extends Helper {
3406
3286
 
3407
3287
  const context = await this._getContext()
3408
3288
  try {
3409
- if (locator.isCustom()) {
3410
- // For custom locators, we need to use our custom element finding logic
3411
- const elements = await findCustomElements.call(this, context, locator)
3412
- if (elements.length === 0) {
3413
- throw new Error(`Custom locator ${locator.type}=${locator.value} not found`)
3414
- }
3415
- await elements[0].waitFor({ timeout: waitTimeout, state: 'attached' })
3416
- } else {
3417
- await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' })
3418
- }
3289
+ await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' })
3419
3290
  } catch (e) {
3420
3291
  throw new Error(`element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${e.message}`)
3421
3292
  }
@@ -3433,26 +3304,6 @@ class Playwright extends Helper {
3433
3304
  const context = await this._getContext()
3434
3305
  let count = 0
3435
3306
 
3436
- // Handle custom locators
3437
- if (locator.isCustom()) {
3438
- let waiter
3439
- do {
3440
- const elements = await findCustomElements.call(this, context, locator)
3441
- if (elements.length > 0) {
3442
- waiter = await elements[0].isVisible()
3443
- } else {
3444
- waiter = false
3445
- }
3446
- if (!waiter) {
3447
- await this.wait(1)
3448
- count += 1000
3449
- }
3450
- } while (!waiter && count <= waitTimeout)
3451
-
3452
- if (!waiter) throw new Error(`element (${locator.toString()}) still not visible after ${waitTimeout / 1000} sec.`)
3453
- return
3454
- }
3455
-
3456
3307
  // we have this as https://github.com/microsoft/playwright/issues/26829 is not yet implemented
3457
3308
  let waiter
3458
3309
  if (this.frame) {
@@ -3570,6 +3421,7 @@ class Playwright extends Helper {
3570
3421
  */
3571
3422
  async waitInUrl(urlPart, sec = null) {
3572
3423
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3424
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
3573
3425
 
3574
3426
  return this.page
3575
3427
  .waitForFunction(
@@ -3577,13 +3429,13 @@ class Playwright extends Helper {
3577
3429
  const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
3578
3430
  return currUrl.indexOf(urlPart) > -1
3579
3431
  },
3580
- urlPart,
3432
+ expectedUrl,
3581
3433
  { timeout: waitTimeout },
3582
3434
  )
3583
3435
  .catch(async e => {
3584
- const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
3436
+ const currUrl = await this._getPageUrl()
3585
3437
  if (/Timeout/i.test(e.message)) {
3586
- throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
3438
+ throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`)
3587
3439
  } else {
3588
3440
  throw e
3589
3441
  }
@@ -3595,29 +3447,50 @@ class Playwright extends Helper {
3595
3447
  */
3596
3448
  async waitUrlEquals(urlPart, sec = null) {
3597
3449
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3450
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
3598
3451
 
3599
- const baseUrl = this.options.url
3600
- if (urlPart.indexOf('http') < 0) {
3601
- urlPart = baseUrl + urlPart
3452
+ try {
3453
+ await this.page.waitForURL(
3454
+ url => url.href === expectedUrl,
3455
+ { timeout: waitTimeout },
3456
+ )
3457
+ } catch (e) {
3458
+ const currUrl = await this._getPageUrl()
3459
+ if (/Timeout/i.test(e.message)) {
3460
+ throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`)
3461
+ } else {
3462
+ throw e
3463
+ }
3602
3464
  }
3465
+ }
3603
3466
 
3604
- return this.page
3605
- .waitForFunction(
3606
- urlPart => {
3607
- const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
3608
- return currUrl.indexOf(urlPart) > -1
3467
+ /**
3468
+ * {{> waitCurrentPathEquals }}
3469
+ */
3470
+ async waitCurrentPathEquals(path, sec = null) {
3471
+ const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3472
+ const normalizedPath = normalizePath(path)
3473
+
3474
+ try {
3475
+ await this.page.waitForFunction(
3476
+ expectedPath => {
3477
+ const actualPath = window.location.pathname
3478
+ const normalizePath = p => (p === '' || p === '/' ? '/' : p.replace(/\/+/g, '/').replace(/\/$/, '') || '/')
3479
+ return normalizePath(actualPath) === expectedPath
3609
3480
  },
3610
- urlPart,
3481
+ normalizedPath,
3611
3482
  { timeout: waitTimeout },
3612
3483
  )
3613
- .catch(async e => {
3614
- const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
3615
- if (/Timeout/i.test(e.message)) {
3616
- throw new Error(`expected url to be ${urlPart}, but found ${currUrl}`)
3617
- } else {
3618
- throw e
3619
- }
3620
- })
3484
+ } catch (e) {
3485
+ const currentUrl = await this._getPageUrl()
3486
+ const baseUrl = this.options.url || 'http://localhost'
3487
+ const actualPath = new URL(currentUrl, baseUrl).pathname
3488
+ if (/Timeout/i.test(e.message)) {
3489
+ throw new Error(`expected path to be ${normalizedPath}, but found ${normalizePath(actualPath)}`)
3490
+ } else {
3491
+ throw e
3492
+ }
3493
+ }
3621
3494
  }
3622
3495
 
3623
3496
  /**
@@ -3632,15 +3505,6 @@ class Playwright extends Helper {
3632
3505
  if (context) {
3633
3506
  const locator = new Locator(context, 'css')
3634
3507
  try {
3635
- if (locator.isCustom()) {
3636
- // For custom locators, find the elements first then check for text within them
3637
- const elements = await findCustomElements.call(this, contextObject, locator)
3638
- if (elements.length === 0) {
3639
- throw new Error(`Context element not found: ${locator.toString()}`)
3640
- }
3641
- return elements[0].locator(`text=${text}`).first().waitFor({ timeout: waitTimeout, state: 'visible' })
3642
- }
3643
-
3644
3508
  if (!locator.isXPath()) {
3645
3509
  return contextObject
3646
3510
  .locator(`${locator.simplify()} >> text=${text}`)
@@ -3883,7 +3747,7 @@ class Playwright extends Helper {
3883
3747
  if (!locator.isXPath()) {
3884
3748
  try {
3885
3749
  await context
3886
- .locator(`${locator.isCustom() ? `${locator.type}=${locator.value}` : locator.simplify()}`)
3750
+ .locator(locator.simplify())
3887
3751
  .first()
3888
3752
  .waitFor({ timeout: waitTimeout, state: 'detached' })
3889
3753
  } catch (e) {
@@ -4308,40 +4172,48 @@ class Playwright extends Helper {
4308
4172
 
4309
4173
  export default Playwright
4310
4174
 
4311
- function buildCustomLocatorString(locator) {
4312
- // Note: this.debug not available in standalone function, using console.log
4313
- console.log(`Building custom locator string: ${locator.type}=${locator.value}`)
4314
- return `${locator.type}=${locator.value}`
4315
- }
4316
-
4317
- function buildLocatorString(locator) {
4318
- if (locator.isCustom()) {
4319
- return buildCustomLocatorString(locator)
4320
- }
4175
+ export function buildLocatorString(locator) {
4321
4176
  if (locator.isXPath()) {
4322
- return `xpath=${locator.value}`
4177
+ // Make XPath relative so it works correctly within scoped contexts (e.g. within()).
4178
+ // Playwright's XPath engine auto-converts "//..." to ".//..." when the root is not a Document,
4179
+ // but only when the selector starts with "/". Locator methods like at() wrap XPath in
4180
+ // parentheses (e.g. "(//...)[position()=1]"), bypassing that auto-conversion.
4181
+ // We fix this by prepending "." before the first "//" that follows any leading parentheses.
4182
+ const value = locator.value.replace(/^(\(*)\/\//, '$1.//')
4183
+ return `xpath=${value}`
4184
+ }
4185
+ if (locator.isShadow()) {
4186
+ // Convert shadow locator to CSS with >> chaining operator
4187
+ // Playwright pierces shadow DOM by default, >> chains selectors
4188
+ // { shadow: ['my-app', 'my-form', 'button'] } => 'my-app >> my-form >> button'
4189
+ return locator.value.join(' >> ')
4323
4190
  }
4324
4191
  return locator.simplify()
4325
4192
  }
4326
4193
 
4327
- /**
4328
- * Checks if a locator is a role locator object (e.g., {role: 'button', text: 'Submit', exact: true})
4329
- */
4330
- function isRoleLocatorObject(locator) {
4331
- return locator && typeof locator === 'object' && locator.role && !locator.type
4332
- }
4333
-
4334
4194
  /**
4335
4195
  * Handles role locator objects by converting them to Playwright's getByRole() API
4196
+ * Accepts both raw objects ({role: 'button', text: 'Submit'}) and Locator-wrapped role objects.
4336
4197
  * Returns elements array if role locator, null otherwise
4337
4198
  */
4338
4199
  async function handleRoleLocator(context, locator) {
4339
- if (!isRoleLocatorObject(locator)) return null
4340
-
4200
+ const loc = new Locator(locator)
4201
+ if (!loc.isRole()) return null
4202
+
4203
+ const roleObj = loc.locator || {}
4204
+ const options = {}
4205
+ if (roleObj.text) options.name = roleObj.text
4206
+ if (roleObj.name) options.name = roleObj.name
4207
+ if (roleObj.exact !== undefined) options.exact = roleObj.exact
4208
+
4209
+ return context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined).all()
4210
+ }
4211
+
4212
+ async function findByRole(context, locator) {
4213
+ if (!locator || !locator.role) return null
4341
4214
  const options = {}
4342
- if (locator.text) options.name = locator.text
4215
+ if (locator.name) options.name = locator.name
4343
4216
  if (locator.exact !== undefined) options.exact = locator.exact
4344
-
4345
4217
  return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4346
4218
  }
4347
4219
 
@@ -4350,7 +4222,7 @@ async function findElements(matcher, locator) {
4350
4222
  const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react
4351
4223
  const isVueLocator = locator.type === 'vue' || (locator.locator && locator.locator.vue) || locator.vue
4352
4224
  const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
4353
-
4225
+
4354
4226
  if (isReactLocator) return findReact(matcher, locator)
4355
4227
  if (isVueLocator) return findVue(matcher, locator)
4356
4228
  if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
@@ -4361,119 +4233,11 @@ async function findElements(matcher, locator) {
4361
4233
 
4362
4234
  locator = new Locator(locator, 'css')
4363
4235
 
4364
- // Handle custom locators directly instead of relying on Playwright selector engines
4365
- if (locator.isCustom()) {
4366
- return findCustomElements.call(this, matcher, locator)
4367
- }
4368
-
4369
- // Check if we have a custom context locator and need to search within it
4370
- if (this.contextLocator) {
4371
- const contextLocatorObj = new Locator(this.contextLocator, 'css')
4372
- if (contextLocatorObj.isCustom()) {
4373
- // Find the context elements first
4374
- const contextElements = await findCustomElements.call(this, matcher, contextLocatorObj)
4375
- if (contextElements.length === 0) {
4376
- return []
4377
- }
4378
-
4379
- // Search within the first context element
4380
- const locatorString = buildLocatorString(locator)
4381
- return contextElements[0].locator(locatorString).all()
4382
- }
4383
- }
4384
-
4385
4236
  const locatorString = buildLocatorString(locator)
4386
4237
 
4387
4238
  return matcher.locator(locatorString).all()
4388
4239
  }
4389
4240
 
4390
- async function findCustomElements(matcher, locator) {
4391
- // Always prioritize this.customLocatorStrategies which is set in constructor from config
4392
- // and persists in every worker thread instance
4393
- let strategyFunction = null
4394
-
4395
- if (this.customLocatorStrategies && this.customLocatorStrategies[locator.type]) {
4396
- strategyFunction = this.customLocatorStrategies[locator.type]
4397
- } else if (globalCustomLocatorStrategies.has(locator.type)) {
4398
- // Fallback to global registry (populated in constructor and _init)
4399
- strategyFunction = globalCustomLocatorStrategies.get(locator.type)
4400
- }
4401
-
4402
- if (!strategyFunction) {
4403
- throw new Error(`Custom locator strategy "${locator.type}" is not defined. Please define "customLocatorStrategies" in your configuration.`)
4404
- }
4405
-
4406
- // Execute the custom locator function in the browser context using page.evaluate
4407
- const page = matcher.constructor.name === 'Page' ? matcher : await matcher.page()
4408
-
4409
- const elements = await page.evaluate(
4410
- ({ strategyCode, selector }) => {
4411
- const strategy = new Function('return ' + strategyCode)()
4412
- const result = strategy(selector, document)
4413
-
4414
- // Convert NodeList or single element to array
4415
- if (result && result.nodeType) {
4416
- return [result]
4417
- } else if (result && result.length !== undefined) {
4418
- return Array.from(result)
4419
- } else if (Array.isArray(result)) {
4420
- return result
4421
- }
4422
-
4423
- return []
4424
- },
4425
- {
4426
- strategyCode: strategyFunction.toString(),
4427
- selector: locator.value,
4428
- },
4429
- )
4430
-
4431
- // Convert the found elements back to Playwright locators
4432
- if (elements.length === 0) {
4433
- return []
4434
- }
4435
-
4436
- // Create CSS selectors for the found elements and return as locators
4437
- const locators = []
4438
- const timestamp = Date.now()
4439
-
4440
- for (let i = 0; i < elements.length; i++) {
4441
- // Use a unique attribute approach to target specific elements
4442
- const uniqueAttr = `data-codecept-custom-${timestamp}-${i}`
4443
-
4444
- await page.evaluate(
4445
- ({ index, uniqueAttr, strategyCode, selector }) => {
4446
- // Re-execute the strategy to find elements and mark the specific one
4447
- const strategy = new Function('return ' + strategyCode)()
4448
- const result = strategy(selector, document)
4449
-
4450
- let elementsArray = []
4451
- if (result && result.nodeType) {
4452
- elementsArray = [result]
4453
- } else if (result && result.length !== undefined) {
4454
- elementsArray = Array.from(result)
4455
- } else if (Array.isArray(result)) {
4456
- elementsArray = result
4457
- }
4458
-
4459
- if (elementsArray[index]) {
4460
- elementsArray[index].setAttribute(uniqueAttr, 'true')
4461
- }
4462
- },
4463
- {
4464
- index: i,
4465
- uniqueAttr,
4466
- strategyCode: strategyFunction.toString(),
4467
- selector: locator.value,
4468
- },
4469
- )
4470
-
4471
- locators.push(page.locator(`[${uniqueAttr}="true"]`))
4472
- }
4473
-
4474
- return locators
4475
- }
4476
-
4477
4241
  async function findElement(matcher, locator) {
4478
4242
  if (locator.react) return findReact(matcher, locator)
4479
4243
  if (locator.vue) return findVue(matcher, locator)
@@ -4511,16 +4275,22 @@ async function proceedClick(locator, context = null, options = {}) {
4511
4275
  assertElementExists(els, locator, 'Clickable element')
4512
4276
  }
4513
4277
 
4514
- await highlightActiveElement.call(this, els[0])
4515
- if (store.debugMode) this.debugSection('Clicked', await elToString(els[0], 1))
4278
+ const opts = store.currentStep?.opts
4279
+ let element
4280
+ if (opts?.elementIndex != null) {
4281
+ element = selectElement(els, locator, this)
4282
+ } else {
4283
+ const strict = (opts?.exact === false || opts?.strictMode === false) ? false : (this.options.strict || opts?.exact === true || opts?.strictMode === true)
4284
+ if (strict) assertOnlyOneElement(els, locator, this)
4285
+ element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0]
4286
+ }
4287
+
4288
+ await highlightActiveElement.call(this, element)
4289
+ if (store.debugMode) this.debugSection('Clicked', await elToString(element, 1))
4516
4290
 
4517
- /*
4518
- using the force true options itself but instead dispatching a click
4519
- */
4520
4291
  if (options.force) {
4521
- await els[0].dispatchEvent('click')
4292
+ await element.dispatchEvent('click')
4522
4293
  } else {
4523
- const element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0]
4524
4294
  await element.click(options)
4525
4295
  }
4526
4296
  const promises = []
@@ -4535,7 +4305,10 @@ async function proceedClick(locator, context = null, options = {}) {
4535
4305
  async function findClickable(matcher, locator) {
4536
4306
  const matchedLocator = new Locator(locator)
4537
4307
 
4538
- if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
4308
+ if (!matchedLocator.isFuzzy()) {
4309
+ const els = await findElements.call(this, matcher, matchedLocator)
4310
+ return els
4311
+ }
4539
4312
 
4540
4313
  let els
4541
4314
  const literal = xpathLocator.literal(matchedLocator.value)
@@ -4635,38 +4408,92 @@ async function proceedIsChecked(assertType, option) {
4635
4408
  return truth(`checkable ${option}`, 'to be checked')[assertType](selected)
4636
4409
  }
4637
4410
 
4638
- async function findFields(locator) {
4639
- // Handle role locators with text/exact options
4640
- if (isRoleLocatorObject(locator)) {
4641
- const page = await this.page
4642
- const roleElements = await handleRoleLocator(page, locator)
4643
- if (roleElements) return roleElements
4411
+ async function findFields(locator, context = null) {
4412
+ let contextEl
4413
+ if (context) {
4414
+ const contextEls = await this._locate(context)
4415
+ assertElementExists(contextEls, context, 'Context element')
4416
+ contextEl = contextEls[0]
4644
4417
  }
4645
4418
 
4419
+ const locateFn = contextEl
4420
+ ? loc => findElements.call(this, contextEl, loc)
4421
+ : loc => this._locate(loc)
4422
+
4423
+ const matcher = contextEl || (await this.page)
4424
+ const roleElements = await handleRoleLocator(matcher, locator)
4425
+ if (roleElements) return roleElements
4426
+
4646
4427
  const matchedLocator = new Locator(locator)
4647
4428
  if (!matchedLocator.isFuzzy()) {
4648
- return this._locate(matchedLocator)
4429
+ return locateFn(matchedLocator)
4649
4430
  }
4650
4431
  const literal = xpathLocator.literal(locator)
4651
4432
 
4652
- let els = await this._locate({ xpath: Locator.field.labelEquals(literal) })
4433
+ let els = await locateFn({ xpath: Locator.field.labelEquals(literal) })
4653
4434
  if (els.length) {
4654
4435
  return els
4655
4436
  }
4656
4437
 
4657
- els = await this._locate({ xpath: Locator.field.labelContains(literal) })
4438
+ els = await locateFn({ xpath: Locator.field.labelContains(literal) })
4658
4439
  if (els.length) {
4659
4440
  return els
4660
4441
  }
4661
- els = await this._locate({ xpath: Locator.field.byName(literal) })
4442
+ els = await locateFn({ xpath: Locator.field.byName(literal) })
4662
4443
  if (els.length) {
4663
4444
  return els
4664
4445
  }
4665
- return this._locate({ css: locator })
4446
+ return locateFn({ css: locator })
4447
+ }
4448
+
4449
+ async function proceedSelect(context, el, option) {
4450
+ const role = await el.getAttribute('role')
4451
+ const options = Array.isArray(option) ? option : [option]
4452
+
4453
+ if (role === 'combobox') {
4454
+ this.debugSection('SelectOption', 'Expanding combobox')
4455
+ await highlightActiveElement.call(this, el)
4456
+ const [ariaOwns, ariaControls] = await Promise.all([el.getAttribute('aria-owns'), el.getAttribute('aria-controls')])
4457
+ await el.click()
4458
+ await this._waitForAction()
4459
+
4460
+ const listboxId = ariaOwns || ariaControls
4461
+ let listbox = listboxId ? context.locator(`#${listboxId}`).first() : null
4462
+ if (!listbox || !(await listbox.count())) listbox = context.getByRole('listbox').first()
4463
+
4464
+ for (const opt of options) {
4465
+ const optEl = listbox.getByRole('option', { name: opt }).first()
4466
+ this.debugSection('SelectOption', `Clicking: "${opt}"`)
4467
+ await highlightActiveElement.call(this, optEl)
4468
+ await optEl.click()
4469
+ }
4470
+ return this._waitForAction()
4471
+ }
4472
+
4473
+ if (role === 'listbox') {
4474
+ for (const opt of options) {
4475
+ const optEl = el.getByRole('option', { name: opt }).first()
4476
+ this.debugSection('SelectOption', `Clicking: "${opt}"`)
4477
+ await highlightActiveElement.call(this, optEl)
4478
+ await optEl.click()
4479
+ }
4480
+ return this._waitForAction()
4481
+ }
4482
+
4483
+ await highlightActiveElement.call(this, el)
4484
+ let optionToSelect = option
4485
+ try {
4486
+ optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
4487
+ } catch (e) {
4488
+ optionToSelect = option
4489
+ }
4490
+ if (!Array.isArray(option)) option = [optionToSelect]
4491
+ await el.selectOption(option)
4492
+ return this._waitForAction()
4666
4493
  }
4667
4494
 
4668
- async function proceedSeeInField(assertType, field, value) {
4669
- const els = await findFields.call(this, field)
4495
+ async function proceedSeeInField(assertType, field, value, context) {
4496
+ const els = await findFields.call(this, field, context)
4670
4497
  assertElementExists(els, field, 'Field')
4671
4498
  const el = els[0]
4672
4499
  const tag = await el.evaluate(e => e.tagName)
@@ -4780,6 +4607,13 @@ function assertElementExists(res, locator, prefix, suffix) {
4780
4607
  }
4781
4608
  }
4782
4609
 
4610
+ function assertOnlyOneElement(elements, locator, helper) {
4611
+ if (elements.length > 1) {
4612
+ const webElements = elements.map(el => new WebElement(el, helper))
4613
+ throw new MultipleElementsFound(locator, webElements)
4614
+ }
4615
+ }
4616
+
4783
4617
  function $XPath(element, selector) {
4784
4618
  const found = document.evaluate(selector, element || document.body, null, 5, null)
4785
4619
  const res = []
@@ -4967,7 +4801,7 @@ async function refreshContextSession() {
4967
4801
  this.debugSection('Session', 'Skipping storage cleanup - no active page/context')
4968
4802
  return
4969
4803
  }
4970
-
4804
+
4971
4805
  const currentUrl = await this.grabCurrentUrl()
4972
4806
 
4973
4807
  if (currentUrl.startsWith('http')) {