codeceptjs 4.0.0-beta.7.esm-aria → 4.0.0-beta.8.esm-aria

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 (68) hide show
  1. package/README.md +46 -3
  2. package/bin/codecept.js +9 -0
  3. package/bin/test-server.js +64 -0
  4. package/docs/webapi/click.mustache +5 -1
  5. package/lib/ai.js +66 -102
  6. package/lib/codecept.js +99 -24
  7. package/lib/command/generate.js +33 -1
  8. package/lib/command/init.js +7 -3
  9. package/lib/command/run-workers.js +31 -2
  10. package/lib/command/run.js +15 -0
  11. package/lib/command/workers/runTests.js +331 -58
  12. package/lib/config.js +16 -5
  13. package/lib/container.js +15 -13
  14. package/lib/effects.js +1 -1
  15. package/lib/element/WebElement.js +327 -0
  16. package/lib/event.js +10 -1
  17. package/lib/helper/AI.js +11 -11
  18. package/lib/helper/ApiDataFactory.js +34 -6
  19. package/lib/helper/Appium.js +156 -42
  20. package/lib/helper/GraphQL.js +3 -3
  21. package/lib/helper/GraphQLDataFactory.js +4 -4
  22. package/lib/helper/JSONResponse.js +48 -40
  23. package/lib/helper/Mochawesome.js +24 -2
  24. package/lib/helper/Playwright.js +841 -153
  25. package/lib/helper/Puppeteer.js +263 -67
  26. package/lib/helper/REST.js +21 -0
  27. package/lib/helper/WebDriver.js +105 -16
  28. package/lib/helper/clientscripts/PollyWebDriverExt.js +1 -1
  29. package/lib/helper/extras/PlaywrightReactVueLocator.js +52 -0
  30. package/lib/helper/extras/PlaywrightRestartOpts.js +12 -1
  31. package/lib/helper/network/actions.js +8 -6
  32. package/lib/listener/config.js +11 -3
  33. package/lib/listener/enhancedGlobalRetry.js +110 -0
  34. package/lib/listener/globalTimeout.js +19 -4
  35. package/lib/listener/helpers.js +8 -2
  36. package/lib/listener/retryEnhancer.js +85 -0
  37. package/lib/listener/steps.js +12 -0
  38. package/lib/mocha/asyncWrapper.js +13 -3
  39. package/lib/mocha/cli.js +1 -1
  40. package/lib/mocha/factory.js +3 -0
  41. package/lib/mocha/gherkin.js +1 -1
  42. package/lib/mocha/test.js +6 -0
  43. package/lib/mocha/ui.js +13 -0
  44. package/lib/output.js +62 -18
  45. package/lib/plugin/coverage.js +16 -3
  46. package/lib/plugin/enhancedRetryFailedStep.js +99 -0
  47. package/lib/plugin/htmlReporter.js +3648 -0
  48. package/lib/plugin/retryFailedStep.js +1 -0
  49. package/lib/plugin/stepByStepReport.js +1 -1
  50. package/lib/recorder.js +28 -3
  51. package/lib/result.js +100 -23
  52. package/lib/retryCoordinator.js +207 -0
  53. package/lib/step/base.js +1 -1
  54. package/lib/step/comment.js +2 -2
  55. package/lib/step/meta.js +1 -1
  56. package/lib/template/heal.js +1 -1
  57. package/lib/template/prompts/generatePageObject.js +31 -0
  58. package/lib/template/prompts/healStep.js +13 -0
  59. package/lib/template/prompts/writeStep.js +9 -0
  60. package/lib/test-server.js +334 -0
  61. package/lib/utils/mask_data.js +47 -0
  62. package/lib/utils.js +87 -6
  63. package/lib/workerStorage.js +2 -1
  64. package/lib/workers.js +179 -23
  65. package/package.json +58 -47
  66. package/typings/index.d.ts +19 -7
  67. package/typings/promiseBasedTypes.d.ts +5525 -3759
  68. package/typings/types.d.ts +5791 -3781
@@ -30,17 +30,25 @@ import ElementNotFound from './errors/ElementNotFound.js'
30
30
  import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
31
31
  import Popup from './extras/Popup.js'
32
32
  import Console from './extras/Console.js'
33
- import { buildLocatorString, findElements, findElement, getVisibleElements, findReact, findVue, findByPlaywrightLocator, findByRole } from './extras/PlaywrightLocator.js'
33
+ import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
34
+ import WebElement from '../element/WebElement.js'
34
35
 
35
36
  let playwright
36
37
  let perfTiming
37
38
  let defaultSelectorEnginesInitialized = false
39
+ let registeredCustomLocatorStrategies = new Set()
40
+ let globalCustomLocatorStrategies = new Map()
41
+
42
+ // Use global object to track selector registration across workers
43
+ if (typeof global.__playwrightSelectorsRegistered === 'undefined') {
44
+ global.__playwrightSelectorsRegistered = false
45
+ }
38
46
 
39
47
  const popupStore = new Popup()
40
48
  const consoleLogStore = new Console()
41
49
  const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
42
50
 
43
- import { setRestartStrategy, restartsSession, restartsContext } from './extras/PlaywrightRestartOpts.js'
51
+ import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
44
52
  import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
45
53
  import { seeElementError, dontSeeElementError, dontSeeElementInDOMError, seeElementInDOMError } from './errors/ElementAssertion.js'
46
54
  import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
@@ -92,6 +100,13 @@ const pathSeparator = path.sep
92
100
  * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
93
101
  * @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).
94
102
  * @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
+ * @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object)
105
+ * passed directly to `browser.newContext`.
106
+ * If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`),
107
+ * those cookies are used instead and the configured `storageState` is ignored (no merge).
108
+ * May include session cookies, auth tokens, localStorage and (if captured with
109
+ * `grabStorageState({ indexedDB: true })`) IndexedDB data; treat as sensitive and do not commit.
95
110
  */
96
111
  const config = {}
97
112
 
@@ -340,6 +355,28 @@ class Playwright extends Helper {
340
355
  this.recordingWebSocketMessages = false
341
356
  this.recordedWebSocketMessagesAtLeastOnce = false
342
357
  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
+ }
343
380
 
344
381
  // Add test failure tracking to prevent false positives
345
382
  this.testFailures = []
@@ -347,6 +384,11 @@ class Playwright extends Helper {
347
384
 
348
385
  // override defaults with config
349
386
  this._setConfig(config)
387
+
388
+ // pass storageState directly (string path or object) and let Playwright handle errors/missing file
389
+ if (typeof config.storageState !== 'undefined') {
390
+ this.storageState = config.storageState
391
+ }
350
392
  }
351
393
 
352
394
  _validateConfig(config) {
@@ -373,6 +415,7 @@ class Playwright extends Helper {
373
415
  use: { actionTimeout: 0 },
374
416
  ignoreHTTPSErrors: false, // Adding it here o that context can be set up to ignore the SSL errors,
375
417
  highlightElement: false,
418
+ storageState: undefined,
376
419
  }
377
420
 
378
421
  process.env.testIdAttribute = 'data-testid'
@@ -479,19 +522,106 @@ class Playwright extends Helper {
479
522
  }
480
523
  }
481
524
 
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
+
482
535
  // register an internal selector engine for reading value property of elements in a selector
483
- if (defaultSelectorEnginesInitialized) return
484
- defaultSelectorEnginesInitialized = true
485
536
  try {
486
- await playwright.selectors.register('__value', createValueEngine)
487
- await playwright.selectors.register('__disabled', createDisabledEngine)
488
- if (process.env.testIdAttribute) await playwright.selectors.setTestIdAttribute(process.env.testIdAttribute)
537
+ // Always wrap in try-catch since selectors might be registered globally across workers
538
+ // Check global flag to avoid re-registration in worker processes
539
+ if (!global.__playwrightSelectorsRegistered) {
540
+ try {
541
+ await playwright.selectors.register('__value', createValueEngine)
542
+ await playwright.selectors.register('__disabled', createDisabledEngine)
543
+ global.__playwrightSelectorsRegistered = true
544
+ defaultSelectorEnginesInitialized = true
545
+ } catch (e) {
546
+ if (!e.message.includes('already registered')) {
547
+ throw e
548
+ }
549
+ // Selector already registered globally by another worker
550
+ global.__playwrightSelectorsRegistered = true
551
+ defaultSelectorEnginesInitialized = true
552
+ }
553
+ } else {
554
+ // Selectors already registered in a worker, skip
555
+ defaultSelectorEnginesInitialized = true
556
+ this.debugSection('Init', 'Default selector engines already registered globally, skipping')
557
+ }
558
+ if (process.env.testIdAttribute) {
559
+ try {
560
+ await playwright.selectors.setTestIdAttribute(process.env.testIdAttribute)
561
+ } catch (e) {
562
+ // Ignore if already set
563
+ }
564
+ }
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
+ }
489
613
  } catch (e) {
490
614
  console.warn(e)
491
615
  }
492
616
  }
493
617
 
494
618
  _beforeSuite() {
619
+ // Skip browser start in dry-run mode (used by check command)
620
+ if (store.dryRun) {
621
+ this.debugSection('Dry Run', 'Skipping browser start')
622
+ return
623
+ }
624
+
495
625
  // Start browser if not manually started and not already running
496
626
  // Browser should start in singleton mode (restart: false) or when restart strategy is enabled
497
627
  if (!this.options.manualStart && !this.isRunning) {
@@ -501,6 +631,12 @@ class Playwright extends Helper {
501
631
  }
502
632
 
503
633
  async _before(test) {
634
+ // Skip browser operations in dry-run mode (used by check command)
635
+ if (store.dryRun) {
636
+ this.currentRunningTest = test
637
+ return
638
+ }
639
+
504
640
  this.currentRunningTest = test
505
641
 
506
642
  // Reset failure tracking for each test to prevent false positives
@@ -526,7 +662,12 @@ class Playwright extends Helper {
526
662
  },
527
663
  })
528
664
 
665
+ // Start browser if needed (initial start or browser restart strategy)
529
666
  if (!this.isRunning && !this.options.manualStart) await this._startBrowser()
667
+ else if (restartsBrowser() && !this.options.manualStart) {
668
+ // Browser restart strategy: start browser for each test
669
+ await this._startBrowser()
670
+ }
530
671
 
531
672
  this.isAuthenticated = false
532
673
  if (this.isElectron) {
@@ -557,8 +698,7 @@ class Playwright extends Helper {
557
698
 
558
699
  // load pre-saved cookies
559
700
  if (test?.opts?.cookies) contextOptions.storageState = { cookies: test.opts.cookies }
560
-
561
- if (this.storageState) contextOptions.storageState = this.storageState
701
+ else if (this.storageState) contextOptions.storageState = this.storageState
562
702
  if (this.options.userAgent) contextOptions.userAgent = this.options.userAgent
563
703
  if (this.options.locale) contextOptions.locale = this.options.locale
564
704
  if (this.options.colorScheme) contextOptions.colorScheme = this.options.colorScheme
@@ -573,7 +713,20 @@ class Playwright extends Helper {
573
713
  }
574
714
  }
575
715
  this.debugSection('New Session', JSON.stringify(this.contextOptions))
576
- this.browserContext = await this.browser.newContext(this.contextOptions) // Adding the HTTPSError ignore in the context so that we can ignore those errors
716
+ try {
717
+ this.browserContext = await this.browser.newContext(this.contextOptions) // Adding the HTTPSError ignore in the context so that we can ignore those errors
718
+ } catch (err) {
719
+ // In worker mode with Playwright 1.x, there's a known issue where newContext() fails
720
+ // with "selector engine already registered" when selectors are registered globally
721
+ // across worker threads. This is safe to retry without ANY custom options.
722
+ if (err.message && err.message.includes('already registered')) {
723
+ this.debugSection('Worker Mode', 'Selector conflict detected, retrying context creation with no options')
724
+ // Create context with NO options to avoid selector conflicts
725
+ this.browserContext = await this.browser.newContext()
726
+ } else {
727
+ throw err
728
+ }
729
+ }
577
730
  }
578
731
  }
579
732
 
@@ -614,6 +767,9 @@ class Playwright extends Helper {
614
767
  async _after() {
615
768
  if (!this.isRunning) return
616
769
 
770
+ // Clear popup state to prevent leakage between tests
771
+ popupStore.clear()
772
+
617
773
  if (this.isElectron) {
618
774
  try {
619
775
  this.browser.close()
@@ -628,6 +784,31 @@ class Playwright extends Helper {
628
784
  return refreshContextSession.bind(this)()
629
785
  }
630
786
 
787
+ if (restartsBrowser()) {
788
+ // Close browser completely for restart strategy
789
+ if (this.isRunning) {
790
+ try {
791
+ // Close all pages first to release resources
792
+ if (this.browserContext) {
793
+ const pages = await this.browserContext.pages()
794
+ await Promise.allSettled(pages.map(p => p.close().catch(() => {})))
795
+ }
796
+ // 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
+ ])
801
+ } catch (e) {
802
+ console.warn('Warning during browser restart in _after:', e.message)
803
+ // Force cleanup even on timeout
804
+ this.browser = null
805
+ this.browserContext = null
806
+ this.isRunning = false
807
+ }
808
+ }
809
+ return
810
+ }
811
+
631
812
  // close other sessions with timeout protection, but only if restartsContext() is true
632
813
  if (restartsContext()) {
633
814
  try {
@@ -653,15 +834,25 @@ class Playwright extends Helper {
653
834
  }
654
835
 
655
836
  async _afterSuite() {
656
- // Only stop browser if restart strategy requires it
657
- if ((restartsSession() || restartsContext()) && this.isRunning) {
837
+ // Stop browser after suite completes
838
+ // For restart strategies: stop after each suite
839
+ // For session mode (restart:false): stop after the last suite
840
+ if (this.isRunning) {
658
841
  try {
659
- await this._stopBrowser()
842
+ // 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
+ ])
660
847
  } catch (e) {
661
848
  console.warn('Warning during suite cleanup:', e.message)
662
849
  // Track suite cleanup failures
663
850
  this.hasCleanupError = true
664
851
  this.testFailures.push(`Suite cleanup failed: ${e.message}`)
852
+ // Force cleanup on timeout
853
+ this.browser = null
854
+ this.browserContext = null
855
+ this.isRunning = false
665
856
  } finally {
666
857
  this.isRunning = false
667
858
  }
@@ -737,7 +928,7 @@ class Playwright extends Helper {
737
928
  }
738
929
 
739
930
  async _finishTest() {
740
- if ((restartsSession() || restartsContext()) && this.isRunning) {
931
+ if ((restartsSession() || restartsContext() || restartsBrowser()) && this.isRunning) {
741
932
  try {
742
933
  await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Test finish timeout')), 10000))])
743
934
  } catch (e) {
@@ -762,17 +953,32 @@ class Playwright extends Helper {
762
953
  // Final cleanup when test run completes
763
954
  if (this.isRunning) {
764
955
  try {
765
- await this._stopBrowser()
956
+ // 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
+ ])
766
961
  } catch (e) {
767
962
  console.warn('Warning during final cleanup:', e.message)
963
+ // Force cleanup on timeout
964
+ this.browser = null
965
+ this.browserContext = null
966
+ this.isRunning = false
768
967
  }
769
968
  } else {
770
969
  // Check if we still have a browser object despite isRunning being false
771
970
  if (this.browser) {
772
971
  try {
773
- await this._stopBrowser()
972
+ // 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
+ ])
774
977
  } catch (e) {
775
978
  console.warn('Warning during forced cleanup:', e.message)
979
+ // Force cleanup on timeout
980
+ this.browser = null
981
+ this.browserContext = null
776
982
  }
777
983
  }
778
984
  }
@@ -950,6 +1156,9 @@ class Playwright extends Helper {
950
1156
  try {
951
1157
  this.page.removeAllListeners('crash')
952
1158
  this.page.removeAllListeners('dialog')
1159
+ this.page.removeAllListeners('load')
1160
+ this.page.removeAllListeners('console')
1161
+ this.page.removeAllListeners('requestfinished')
953
1162
  } catch (e) {
954
1163
  console.warn('Warning cleaning previous page listeners:', e.message)
955
1164
  }
@@ -1038,6 +1247,12 @@ class Playwright extends Helper {
1038
1247
  }
1039
1248
 
1040
1249
  async _startBrowser() {
1250
+ // Ensure custom locator strategies are registered before browser launch
1251
+ // Only init once globally to avoid selector re-registration in workers
1252
+ if (!defaultSelectorEnginesInitialized) {
1253
+ await this._init()
1254
+ }
1255
+
1041
1256
  if (this.isElectron) {
1042
1257
  this.browser = await playwright._electron.launch(this.playwrightOptions)
1043
1258
  } else if (this.isRemoteBrowser && this.isCDPConnection) {
@@ -1073,6 +1288,30 @@ class Playwright extends Helper {
1073
1288
  return this.browser
1074
1289
  }
1075
1290
 
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
+
1076
1315
  /**
1077
1316
  * Create a new browser context with a page. \
1078
1317
  * Usually it should be run from a custom helper after call of `_startBrowser()`
@@ -1083,11 +1322,64 @@ class Playwright extends Helper {
1083
1322
  throw new Error('Browser not started. Call _startBrowser() first or disable manualStart option.')
1084
1323
  }
1085
1324
  this.browserContext = await this.browser.newContext(contextOptions)
1325
+
1326
+ // Register custom locator strategies for this context
1327
+ await this._registerCustomLocatorStrategies()
1328
+
1086
1329
  const page = await this.browserContext.newPage()
1087
1330
  targetCreatedHandler.call(this, page)
1088
1331
  await this._setPage(page)
1089
1332
  }
1090
1333
 
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
+
1091
1383
  _getType() {
1092
1384
  return this.browser._type
1093
1385
  }
@@ -1098,57 +1390,43 @@ class Playwright extends Helper {
1098
1390
  this.context = null
1099
1391
  this.frame = null
1100
1392
  popupStore.clear()
1101
-
1102
- // Clean up event listeners to prevent hanging
1103
- try {
1104
- if (this.browser) {
1105
- this.browser.removeAllListeners('targetchanged')
1106
- if (this.browserContext) {
1107
- // Clean up any page event listeners in the context
1108
- const pages = this.browserContext.pages()
1109
- for (const page of pages) {
1110
- try {
1111
- page.removeAllListeners('crash')
1112
- page.removeAllListeners('dialog')
1113
- } catch (e) {
1114
- console.warn('Warning cleaning page listeners:', e.message)
1115
- }
1116
- }
1117
- }
1118
- }
1119
- } catch (e) {
1120
- console.warn('Warning cleaning event listeners:', e.message)
1121
- }
1122
-
1123
- try {
1124
- if (this.browserContext) {
1125
- await Promise.race([this.browserContext.close(), new Promise((_, reject) => setTimeout(() => reject(new Error('Context close timeout')), 3000))])
1393
+
1394
+ // Remove all event listeners to prevent hanging
1395
+ if (this.browser) {
1396
+ try {
1397
+ this.browser.removeAllListeners()
1398
+ } catch (e) {
1399
+ // Ignore errors if browser is already closed
1126
1400
  }
1127
- } catch (error) {
1128
- console.warn('Failed to close browser context:', error.message)
1129
1401
  }
1130
-
1131
- try {
1132
- if (this.browser) {
1133
- await Promise.race([this.browser.close(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser close timeout')), 3000))])
1402
+
1403
+ if (this.options.recordHar && this.browserContext) {
1404
+ try {
1405
+ await this.browserContext.close()
1406
+ } catch (e) {
1407
+ // Ignore errors if context is already closed
1134
1408
  }
1135
- } catch (error) {
1136
- console.warn('Failed to close browser:', error.message)
1137
1409
  }
1138
-
1139
- // Always try to kill the browser process to ensure cleanup
1140
- try {
1141
- if (this.browser && this.browser.process && this.browser.process()) {
1142
- this.browser.process().kill('SIGKILL')
1410
+ this.browserContext = null
1411
+
1412
+ if (this.browser) {
1413
+ 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
+ ])
1421
+ } 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
1143
1427
  }
1144
- } catch (e) {
1145
- // Silently ignore process kill errors
1146
1428
  }
1147
-
1148
- // Ensure cleanup is complete
1149
1429
  this.browser = null
1150
- this.browserContext = null
1151
- this.page = null
1152
1430
  this.isRunning = false
1153
1431
  }
1154
1432
 
@@ -1225,8 +1503,9 @@ class Playwright extends Helper {
1225
1503
  throw new Error('Cannot open pages inside an Electron container')
1226
1504
  }
1227
1505
 
1228
- // Prevent navigation attempts when browser is being torn down
1229
- if (!this.isRunning && (!this.browser || !this.browserContext || !this.page)) {
1506
+ // Prevent navigation attempts only when manual start is enabled and browser is not running
1507
+ // Allow auto-initialization for normal operation (e.g., when using BROWSER_RESTART=browser)
1508
+ if (!this.isRunning && this.options.manualStart && (!this.browser || !this.browserContext || !this.page)) {
1230
1509
  throw new Error('Cannot navigate: browser is not running or has been closed')
1231
1510
  }
1232
1511
 
@@ -1260,7 +1539,21 @@ class Playwright extends Helper {
1260
1539
  acceptDownloads: true,
1261
1540
  ...this.options.emulate,
1262
1541
  }
1263
- this.browserContext = await this.browser.newContext(contextOptions)
1542
+
1543
+ try {
1544
+ this.browserContext = await this.browser.newContext(contextOptions)
1545
+ } catch (err) {
1546
+ // In worker mode with Playwright 1.x, there's a known issue where newContext() fails
1547
+ // with "selector engine already registered" when selectors are registered globally
1548
+ // across worker threads. This is safe to retry without ANY custom options.
1549
+ if (err.message && err.message.includes('already registered')) {
1550
+ this.debugSection('Worker Mode', 'Selector conflict in amOnPage, retrying with empty options')
1551
+ // Create context with NO options to avoid selector conflicts
1552
+ this.browserContext = await this.browser.newContext()
1553
+ } else {
1554
+ throw err
1555
+ }
1556
+ }
1264
1557
  }
1265
1558
 
1266
1559
  let pages
@@ -1450,20 +1743,21 @@ class Playwright extends Helper {
1450
1743
  async dragAndDrop(srcElement, destElement, options) {
1451
1744
  const src = new Locator(srcElement)
1452
1745
  const dst = new Locator(destElement)
1746
+ const context = await this._getContext()
1453
1747
 
1454
1748
  if (options) {
1455
- return this.page.dragAndDrop(buildLocatorString(src), buildLocatorString(dst), options)
1749
+ return context.dragAndDrop(buildLocatorString(src), buildLocatorString(dst), options)
1456
1750
  }
1457
1751
 
1458
1752
  const _smallWaitInMs = 600
1459
- await this.page.locator(buildLocatorString(src)).hover()
1753
+ await context.locator(buildLocatorString(src)).hover()
1460
1754
  await this.page.mouse.down()
1461
1755
  await this.page.waitForTimeout(_smallWaitInMs)
1462
1756
 
1463
- const destElBox = await this.page.locator(buildLocatorString(dst)).boundingBox()
1757
+ const destElBox = await context.locator(buildLocatorString(dst)).boundingBox()
1464
1758
 
1465
1759
  await this.page.mouse.move(destElBox.x + destElBox.width / 2, destElBox.y + destElBox.height / 2)
1466
- await this.page.locator(buildLocatorString(dst)).hover({ position: { x: 10, y: 10 } })
1760
+ await context.locator(buildLocatorString(dst)).hover({ position: { x: 10, y: 10 } })
1467
1761
  await this.page.waitForTimeout(_smallWaitInMs)
1468
1762
  await this.page.mouse.up()
1469
1763
  }
@@ -1603,9 +1897,9 @@ class Playwright extends Helper {
1603
1897
  async _locate(locator) {
1604
1898
  const context = await this._getContext()
1605
1899
 
1606
- if (this.frame) return findElements(this.frame, locator)
1900
+ if (this.frame) return findElements.call(this, this.frame, locator)
1607
1901
 
1608
- const els = await findElements(context, locator)
1902
+ const els = await findElements.call(this, context, locator)
1609
1903
 
1610
1904
  if (store.debugMode) {
1611
1905
  const previewElements = els.slice(0, 3)
@@ -1679,7 +1973,8 @@ class Playwright extends Helper {
1679
1973
  *
1680
1974
  */
1681
1975
  async grabWebElements(locator) {
1682
- return this._locate(locator)
1976
+ const elements = await this._locate(locator)
1977
+ return elements.map(element => new WebElement(element, this))
1683
1978
  }
1684
1979
 
1685
1980
  /**
@@ -1687,7 +1982,8 @@ class Playwright extends Helper {
1687
1982
  *
1688
1983
  */
1689
1984
  async grabWebElement(locator) {
1690
- return this._locateElement(locator)
1985
+ const element = await this._locateElement(locator)
1986
+ return new WebElement(element, this)
1691
1987
  }
1692
1988
 
1693
1989
  /**
@@ -1911,7 +2207,7 @@ class Playwright extends Helper {
1911
2207
  * ```
1912
2208
  *
1913
2209
  */
1914
- async click(locator, context = null, options = {}) {
2210
+ async click(locator = '//body', context = null, options = {}) {
1915
2211
  return proceedClick.call(this, locator, context, options)
1916
2212
  }
1917
2213
 
@@ -1945,6 +2241,49 @@ class Playwright extends Helper {
1945
2241
  return proceedClick.call(this, locator, context, { button: 'right' })
1946
2242
  }
1947
2243
 
2244
+ /**
2245
+ * Performs click at specific coordinates.
2246
+ * If locator is provided, the coordinates are relative to the element.
2247
+ * If locator is not provided, the coordinates are global page coordinates.
2248
+ *
2249
+ * ```js
2250
+ * // Click at global coordinates (100, 200)
2251
+ * I.clickXY(100, 200);
2252
+ *
2253
+ * // Click at coordinates (50, 30) relative to element
2254
+ * I.clickXY('#someElement', 50, 30);
2255
+ * ```
2256
+ *
2257
+ * @param {CodeceptJS.LocatorOrString|number} locator Element to click on or X coordinate if no element.
2258
+ * @param {number} [x] X coordinate relative to element, or Y coordinate if locator is a number.
2259
+ * @param {number} [y] Y coordinate relative to element.
2260
+ * @returns {Promise<void>}
2261
+ */
2262
+ async clickXY(locator, x, y) {
2263
+ // If locator is a number, treat it as global X coordinate
2264
+ if (typeof locator === 'number') {
2265
+ const globalX = locator
2266
+ const globalY = x
2267
+ await this.page.mouse.click(globalX, globalY)
2268
+ return this._waitForAction()
2269
+ }
2270
+
2271
+ // Locator is provided, click relative to element
2272
+ const el = await this._locateElement(locator)
2273
+ assertElementExists(el, locator, 'Element to click')
2274
+
2275
+ const box = await el.boundingBox()
2276
+ if (!box) {
2277
+ throw new Error(`Element ${locator} is not visible or has no bounding box`)
2278
+ }
2279
+
2280
+ const absoluteX = box.x + x
2281
+ const absoluteY = box.y + y
2282
+
2283
+ await this.page.mouse.click(absoluteX, absoluteY)
2284
+ return this._waitForAction()
2285
+ }
2286
+
1948
2287
  /**
1949
2288
  *
1950
2289
  * [Additional options](https://playwright.dev/docs/api/class-elementhandle#element-handle-check) for check available as 3rd argument.
@@ -2020,7 +2359,7 @@ class Playwright extends Helper {
2020
2359
 
2021
2360
  /**
2022
2361
  *
2023
- * _Note:_ Shortcuts like `'Meta'` + `'A'` do not work on macOS ([GoogleChrome/Puppeteer#1313](https://github.com/GoogleChrome/puppeteer/issues/1313)).
2362
+ * _Note:_ Shortcuts like `'Meta'` + `'A'` do not work on macOS ([puppeteer/puppeteer#1313](https://github.com/puppeteer/puppeteer/issues/1313)).
2024
2363
  *
2025
2364
  * {{> pressKeyWithKeyNormalization }}
2026
2365
  */
@@ -2053,11 +2392,15 @@ class Playwright extends Helper {
2053
2392
  * {{> type }}
2054
2393
  */
2055
2394
  async type(keys, delay = null) {
2395
+ // Always use page.keyboard.type for any string (including single character and national characters).
2056
2396
  if (!Array.isArray(keys)) {
2057
2397
  keys = keys.toString()
2058
- keys = keys.split('')
2398
+ const typeDelay = typeof delay === 'number' ? delay : this.options.pressKeyDelay
2399
+ await this.page.keyboard.type(keys, { delay: typeDelay })
2400
+ return
2059
2401
  }
2060
2402
 
2403
+ // For array input, treat each as a key press to keep working combinations such as ['Control', 'A'] or ['T', 'e', 's', 't'].
2061
2404
  for (const key of keys) {
2062
2405
  await this.page.keyboard.press(key)
2063
2406
  if (delay) await this.wait(delay / 1000)
@@ -2360,6 +2703,30 @@ class Playwright extends Helper {
2360
2703
  }
2361
2704
  }
2362
2705
 
2706
+ /**
2707
+ * Grab the current storage state (cookies, localStorage, etc.) via Playwright's `browserContext.storageState()`.
2708
+ * Returns the raw object that Playwright provides.
2709
+ *
2710
+ * Security: The returned object can contain authentication tokens, session cookies
2711
+ * and (when `indexedDB: true` is used) data that may include user PII. Treat it as a secret.
2712
+ * Avoid committing it to source control and prefer storing it in a protected secrets store / CI artifact vault.
2713
+ *
2714
+ * @param {object} [options]
2715
+ * @param {boolean} [options.indexedDB] set to true to include IndexedDB in snapshot (Playwright >=1.51)
2716
+ *
2717
+ * ```js
2718
+ * // basic usage
2719
+ * const state = await I.grabStorageState();
2720
+ * require('fs').writeFileSync('authState.json', JSON.stringify(state));
2721
+ *
2722
+ * // include IndexedDB when using Firebase Auth, etc.
2723
+ * const stateWithIDB = await I.grabStorageState({ indexedDB: true });
2724
+ * ```
2725
+ */
2726
+ async grabStorageState(options = {}) {
2727
+ return this.browserContext.storageState(options)
2728
+ }
2729
+
2363
2730
  /**
2364
2731
  * {{> clearCookie }}
2365
2732
  */
@@ -2409,11 +2776,25 @@ class Playwright extends Helper {
2409
2776
  * @param {*} locator
2410
2777
  */
2411
2778
  _contextLocator(locator) {
2412
- locator = buildLocatorString(new Locator(locator, 'css'))
2779
+ const locatorObj = new Locator(locator, 'css')
2780
+
2781
+ // Handle custom locators differently
2782
+ if (locatorObj.isCustom()) {
2783
+ return buildCustomLocatorString(locatorObj)
2784
+ }
2785
+
2786
+ locator = buildLocatorString(locatorObj)
2413
2787
 
2414
2788
  if (this.contextLocator) {
2415
- const contextLocator = buildLocatorString(new Locator(this.contextLocator, 'css'))
2416
- locator = `${contextLocator} >> ${locator}`
2789
+ 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
+ }
2417
2798
  }
2418
2799
 
2419
2800
  return locator
@@ -2424,30 +2805,44 @@ class Playwright extends Helper {
2424
2805
  *
2425
2806
  */
2426
2807
  async grabTextFrom(locator) {
2427
- const originalLocator = locator
2428
- const matchedLocator = new Locator(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
+ }
2817
+ }
2818
+
2819
+ const locatorObj = new Locator(locator, 'css')
2429
2820
 
2430
- if (!matchedLocator.isFuzzy()) {
2431
- const els = await this._locate(matchedLocator)
2432
- assertElementExists(els, locator)
2433
- const text = await els[0].innerText()
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())
2434
2829
  this.debugSection('Text', text)
2435
2830
  return text
2436
- }
2437
-
2438
- const contextAwareLocator = this._contextLocator(matchedLocator.value)
2439
- let text
2440
- try {
2441
- text = await this.page.textContent(contextAwareLocator)
2442
- } catch (err) {
2443
- if (err.message.includes('Timeout') || err.message.includes('exceeded')) {
2444
- throw new Error(`Element ${new Locator(originalLocator).toString()} was not found by text|CSS|XPath`)
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
2445
2844
  }
2446
- throw err
2447
2845
  }
2448
- assertElementExists(text, contextAwareLocator)
2449
- this.debugSection('Text', text)
2450
- return text
2451
2846
  }
2452
2847
 
2453
2848
  /**
@@ -2460,7 +2855,6 @@ class Playwright extends Helper {
2460
2855
  for (const el of els) {
2461
2856
  texts.push(await el.innerText())
2462
2857
  }
2463
- this.debug(`Matched ${els.length} elements`)
2464
2858
  return texts
2465
2859
  }
2466
2860
 
@@ -2479,7 +2873,6 @@ class Playwright extends Helper {
2479
2873
  */
2480
2874
  async grabValueFromAll(locator) {
2481
2875
  const els = await findFields.call(this, locator)
2482
- this.debug(`Matched ${els.length} elements`)
2483
2876
  return Promise.all(els.map(el => el.inputValue()))
2484
2877
  }
2485
2878
 
@@ -2498,7 +2891,6 @@ class Playwright extends Helper {
2498
2891
  */
2499
2892
  async grabHTMLFromAll(locator) {
2500
2893
  const els = await this._locate(locator)
2501
- this.debug(`Matched ${els.length} elements`)
2502
2894
  return Promise.all(els.map(el => el.innerHTML()))
2503
2895
  }
2504
2896
 
@@ -2519,7 +2911,6 @@ class Playwright extends Helper {
2519
2911
  */
2520
2912
  async grabCssPropertyFromAll(locator, cssProperty) {
2521
2913
  const els = await this._locate(locator)
2522
- this.debug(`Matched ${els.length} elements`)
2523
2914
  const cssValues = await Promise.all(els.map(el => el.evaluate((el, cssProperty) => getComputedStyle(el).getPropertyValue(cssProperty), cssProperty)))
2524
2915
 
2525
2916
  return cssValues
@@ -2630,7 +3021,6 @@ class Playwright extends Helper {
2630
3021
  */
2631
3022
  async grabAttributeFromAll(locator, attr) {
2632
3023
  const els = await this._locate(locator)
2633
- this.debug(`Matched ${els.length} elements`)
2634
3024
  const array = []
2635
3025
 
2636
3026
  for (let index = 0; index < els.length; index++) {
@@ -2640,6 +3030,33 @@ class Playwright extends Helper {
2640
3030
  return array
2641
3031
  }
2642
3032
 
3033
+ /**
3034
+ * Retrieves the ARIA snapshot for an element using Playwright's [`locator.ariaSnapshot`](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot).
3035
+ * This method returns a YAML representation of the accessibility tree that can be used for assertions.
3036
+ * If no locator is provided, it captures the snapshot of the entire page body.
3037
+ *
3038
+ * ```js
3039
+ * const snapshot = await I.grabAriaSnapshot();
3040
+ * expect(snapshot).toContain('heading "Sign up"');
3041
+ *
3042
+ * const formSnapshot = await I.grabAriaSnapshot('#login-form');
3043
+ * expect(formSnapshot).toContain('textbox "Email"');
3044
+ * ```
3045
+ *
3046
+ * [Learn more about ARIA snapshots](https://playwright.dev/docs/aria-snapshots)
3047
+ *
3048
+ * @param {string|object} [locator='//body'] element located by CSS|XPath|strict locator. Defaults to body element.
3049
+ * @return {Promise<string>} YAML representation of the accessibility tree
3050
+ */
3051
+ async grabAriaSnapshot(locator = '//body') {
3052
+ const matchedLocator = new Locator(locator)
3053
+ const els = await this._locate(matchedLocator)
3054
+ assertElementExists(els, locator)
3055
+ const snapshot = await els[0].ariaSnapshot()
3056
+ this.debugSection('Aria Snapshot', snapshot)
3057
+ return snapshot
3058
+ }
3059
+
2643
3060
  /**
2644
3061
  * {{> saveElementScreenshot }}
2645
3062
  *
@@ -2647,16 +3064,10 @@ class Playwright extends Helper {
2647
3064
  async saveElementScreenshot(locator, fileName) {
2648
3065
  const outputFile = screenshotOutputFolder(fileName)
2649
3066
 
2650
- try {
2651
- const res = await this._locateElement(locator)
2652
- assertElementExists(res, locator)
2653
- const elem = res
2654
- this.debug(`Screenshot of ${new Locator(locator)} element has been saved to ${outputFile}`)
2655
- return elem.screenshot({ path: outputFile, type: 'png' })
2656
- } catch (err) {
2657
- this.debug(`Failed to take element screenshot: ${err.message}`)
2658
- throw err
2659
- }
3067
+ const res = await this._locateElement(locator)
3068
+ assertElementExists(res, locator)
3069
+ const elem = res
3070
+ return elem.screenshot({ path: outputFile, type: 'png' })
2660
3071
  }
2661
3072
 
2662
3073
  /**
@@ -2795,15 +3206,19 @@ class Playwright extends Helper {
2795
3206
  if (this.options.recordVideo && this.page && this.page.video()) {
2796
3207
  test.artifacts.video = saveVideoForPage(this.page, `${test.title}.failed`)
2797
3208
  for (const sessionName in this.sessionPages) {
2798
- test.artifacts[`video_${sessionName}`] = saveVideoForPage(this.sessionPages[sessionName], `${test.title}_${sessionName}.failed`)
3209
+ if (sessionName === '') continue
3210
+ test.artifacts[`video_${sessionName}`] = saveVideoForPage(this.sessionPages[sessionName], `${sessionName}_${test.title}.failed`)
2799
3211
  }
2800
3212
  }
2801
3213
 
2802
3214
  if (this.options.trace) {
2803
3215
  test.artifacts.trace = await saveTraceForContext(this.browserContext, `${test.title}.failed`)
2804
3216
  for (const sessionName in this.sessionPages) {
2805
- if (!this.sessionPages[sessionName].context) continue
2806
- test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(this.sessionPages[sessionName].context, `${test.title}_${sessionName}.failed`)
3217
+ if (sessionName === '') continue
3218
+ const sessionPage = this.sessionPages[sessionName]
3219
+ const sessionContext = sessionPage.context()
3220
+ if (!sessionContext || !sessionContext.tracing) continue
3221
+ test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(sessionContext, `${sessionName}_${test.title}.failed`)
2807
3222
  }
2808
3223
  }
2809
3224
 
@@ -2817,7 +3232,8 @@ class Playwright extends Helper {
2817
3232
  if (this.options.keepVideoForPassedTests) {
2818
3233
  test.artifacts.video = saveVideoForPage(this.page, `${test.title}.passed`)
2819
3234
  for (const sessionName of Object.keys(this.sessionPages)) {
2820
- test.artifacts[`video_${sessionName}`] = saveVideoForPage(this.sessionPages[sessionName], `${test.title}_${sessionName}.passed`)
3235
+ if (sessionName === '') continue
3236
+ test.artifacts[`video_${sessionName}`] = saveVideoForPage(this.sessionPages[sessionName], `${sessionName}_${test.title}.passed`)
2821
3237
  }
2822
3238
  } else {
2823
3239
  this.page
@@ -2832,8 +3248,11 @@ class Playwright extends Helper {
2832
3248
  if (this.options.trace) {
2833
3249
  test.artifacts.trace = await saveTraceForContext(this.browserContext, `${test.title}.passed`)
2834
3250
  for (const sessionName in this.sessionPages) {
2835
- if (!this.sessionPages[sessionName].context) continue
2836
- test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(this.sessionPages[sessionName].context, `${test.title}_${sessionName}.passed`)
3251
+ if (sessionName === '') continue
3252
+ const sessionPage = this.sessionPages[sessionName]
3253
+ const sessionContext = sessionPage.context()
3254
+ if (!sessionContext || !sessionContext.tracing) continue
3255
+ test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(sessionContext, `${sessionName}_${test.title}.passed`)
2837
3256
  }
2838
3257
  }
2839
3258
  } else {
@@ -2987,7 +3406,16 @@ class Playwright extends Helper {
2987
3406
 
2988
3407
  const context = await this._getContext()
2989
3408
  try {
2990
- await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' })
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
+ }
2991
3419
  } catch (e) {
2992
3420
  throw new Error(`element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${e.message}`)
2993
3421
  }
@@ -3001,9 +3429,30 @@ class Playwright extends Helper {
3001
3429
  async waitForVisible(locator, sec) {
3002
3430
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3003
3431
  locator = new Locator(locator, 'css')
3432
+
3004
3433
  const context = await this._getContext()
3005
3434
  let count = 0
3006
3435
 
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
+
3007
3456
  // we have this as https://github.com/microsoft/playwright/issues/26829 is not yet implemented
3008
3457
  let waiter
3009
3458
  if (this.frame) {
@@ -3030,6 +3479,7 @@ class Playwright extends Helper {
3030
3479
  async waitForInvisible(locator, sec) {
3031
3480
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3032
3481
  locator = new Locator(locator, 'css')
3482
+
3033
3483
  const context = await this._getContext()
3034
3484
  let waiter
3035
3485
  let count = 0
@@ -3060,6 +3510,7 @@ class Playwright extends Helper {
3060
3510
  async waitToHide(locator, sec) {
3061
3511
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3062
3512
  locator = new Locator(locator, 'css')
3513
+
3063
3514
  const context = await this._getContext()
3064
3515
  let waiter
3065
3516
  let count = 0
@@ -3181,52 +3632,77 @@ class Playwright extends Helper {
3181
3632
  if (context) {
3182
3633
  const locator = new Locator(context, 'css')
3183
3634
  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
+
3184
3644
  if (!locator.isXPath()) {
3185
3645
  return contextObject
3186
- .locator(`${locator.isCustom() ? `${locator.type}=${locator.value}` : locator.simplify()} >> text=${text}`)
3646
+ .locator(`${locator.simplify()} >> text=${text}`)
3187
3647
  .first()
3188
3648
  .waitFor({ timeout: waitTimeout, state: 'visible' })
3649
+ .catch(e => {
3650
+ throw new Error(errorMessage)
3651
+ })
3189
3652
  }
3190
3653
 
3191
3654
  if (locator.isXPath()) {
3192
- return contextObject.waitForFunction(
3193
- ([locator, text, $XPath]) => {
3194
- eval($XPath)
3195
- const el = $XPath(null, locator)
3196
- if (!el.length) return false
3197
- return el[0].innerText.indexOf(text) > -1
3198
- },
3199
- [locator.value, text, $XPath.toString()],
3200
- { timeout: waitTimeout },
3201
- )
3655
+ return contextObject
3656
+ .waitForFunction(
3657
+ ([locator, text, $XPath]) => {
3658
+ eval($XPath)
3659
+ const el = $XPath(null, locator)
3660
+ if (!el.length) return false
3661
+ return el[0].innerText.indexOf(text) > -1
3662
+ },
3663
+ [locator.value, text, $XPath.toString()],
3664
+ { timeout: waitTimeout },
3665
+ )
3666
+ .catch(e => {
3667
+ throw new Error(errorMessage)
3668
+ })
3202
3669
  }
3203
3670
  } catch (e) {
3204
3671
  throw new Error(`${errorMessage}\n${e.message}`)
3205
3672
  }
3206
3673
  }
3207
3674
 
3675
+ // Based on original implementation but fixed to check title text and remove problematic promiseRetry
3676
+ // Original used timeoutGap for waitForFunction to give it slightly more time than the locator
3208
3677
  const timeoutGap = waitTimeout + 1000
3209
3678
 
3210
- // We add basic timeout to make sure we don't wait forever
3211
- // We apply 2 strategies here: wait for text as innert text on page (wide strategy) - older
3212
- // or we use native Playwright matcher to wait for text in element (narrow strategy) - newer
3213
- // If a user waits for text on a page they are mostly expect it to be there, so wide strategy can be helpful even PW strategy is available
3214
3679
  return Promise.race([
3215
- new Promise((_, reject) => {
3216
- setTimeout(() => reject(errorMessage), waitTimeout)
3217
- }),
3218
- this.page.waitForFunction(text => document.body && document.body.innerText.indexOf(text) > -1, text, { timeout: timeoutGap }),
3219
- promiseRetry(
3220
- async retry => {
3221
- const textPresent = await contextObject
3222
- .locator(`:has-text(${JSON.stringify(text)})`)
3223
- .first()
3224
- .isVisible()
3225
- if (!textPresent) retry(errorMessage)
3680
+ // Strategy 1: waitForFunction that checks both body AND title text
3681
+ // Use this.page instead of contextObject because FrameLocator doesn't have waitForFunction
3682
+ // Original only checked document.body.innerText, missing title text like "TestEd"
3683
+ this.page.waitForFunction(
3684
+ function (text) {
3685
+ // Check body text (original behavior)
3686
+ if (document.body && document.body.innerText && document.body.innerText.indexOf(text) > -1) {
3687
+ return true
3688
+ }
3689
+ // Check document title (fixes the TestEd in title issue)
3690
+ if (document.title && document.title.indexOf(text) > -1) {
3691
+ return true
3692
+ }
3693
+ return false
3226
3694
  },
3227
- { retries: 1000, minTimeout: 500, maxTimeout: 500, factor: 1 },
3695
+ text,
3696
+ { timeout: timeoutGap },
3228
3697
  ),
3229
- ])
3698
+ // Strategy 2: Native Playwright text locator (replaces problematic promiseRetry)
3699
+ contextObject
3700
+ .locator(`:has-text(${JSON.stringify(text)})`)
3701
+ .first()
3702
+ .waitFor({ timeout: waitTimeout }),
3703
+ ]).catch(err => {
3704
+ throw new Error(errorMessage)
3705
+ })
3230
3706
  }
3231
3707
 
3232
3708
  /**
@@ -3356,7 +3832,7 @@ class Playwright extends Helper {
3356
3832
  /**
3357
3833
  * Waits for navigation to finish. By default, it takes configured `waitForNavigation` option.
3358
3834
  *
3359
- * See [Playwright's reference](https://playwright.dev/docs/api/class-page?_highlight=waitfornavi#pagewaitfornavigationoptions)
3835
+ * See [Playwright's reference](https://playwright.dev/docs/api/class-page#page-wait-for-navigation)
3360
3836
  *
3361
3837
  * @param {*} options
3362
3838
  */
@@ -3832,6 +4308,195 @@ class Playwright extends Helper {
3832
4308
 
3833
4309
  export default Playwright
3834
4310
 
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
+ }
4321
+ if (locator.isXPath()) {
4322
+ return `xpath=${locator.value}`
4323
+ }
4324
+ return locator.simplify()
4325
+ }
4326
+
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
+ /**
4335
+ * Handles role locator objects by converting them to Playwright's getByRole() API
4336
+ * Returns elements array if role locator, null otherwise
4337
+ */
4338
+ async function handleRoleLocator(context, locator) {
4339
+ if (!isRoleLocatorObject(locator)) return null
4340
+
4341
+ const options = {}
4342
+ if (locator.text) options.name = locator.text
4343
+ if (locator.exact !== undefined) options.exact = locator.exact
4344
+
4345
+ return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4346
+ }
4347
+
4348
+ async function findElements(matcher, locator) {
4349
+ // Check if locator is a Locator object with react/vue type, or a raw object with react/vue property
4350
+ const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react
4351
+ const isVueLocator = locator.type === 'vue' || (locator.locator && locator.locator.vue) || locator.vue
4352
+ const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
4353
+
4354
+ if (isReactLocator) return findReact(matcher, locator)
4355
+ if (isVueLocator) return findVue(matcher, locator)
4356
+ if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
4357
+
4358
+ // Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
4359
+ const roleElements = await handleRoleLocator(matcher, locator)
4360
+ if (roleElements) return roleElements
4361
+
4362
+ locator = new Locator(locator, 'css')
4363
+
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
+ const locatorString = buildLocatorString(locator)
4386
+
4387
+ return matcher.locator(locatorString).all()
4388
+ }
4389
+
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
+ async function findElement(matcher, locator) {
4478
+ if (locator.react) return findReact(matcher, locator)
4479
+ if (locator.vue) return findVue(matcher, locator)
4480
+ if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4481
+
4482
+ locator = new Locator(locator, 'css')
4483
+
4484
+ return matcher.locator(buildLocatorString(locator)).first()
4485
+ }
4486
+
4487
+ async function getVisibleElements(elements) {
4488
+ const visibleElements = []
4489
+ for (const element of elements) {
4490
+ if (await element.isVisible()) {
4491
+ visibleElements.push(element)
4492
+ }
4493
+ }
4494
+ if (visibleElements.length === 0) {
4495
+ return elements
4496
+ }
4497
+ return visibleElements
4498
+ }
4499
+
3835
4500
  async function proceedClick(locator, context = null, options = {}) {
3836
4501
  let matcher = await this._getContext()
3837
4502
  if (context) {
@@ -3941,6 +4606,10 @@ async function findCheckable(locator, context) {
3941
4606
  contextEl = contextEl[0]
3942
4607
  }
3943
4608
 
4609
+ // Handle role locators with text/exact options
4610
+ const roleElements = await handleRoleLocator(contextEl, locator)
4611
+ if (roleElements) return roleElements
4612
+
3944
4613
  const matchedLocator = new Locator(locator)
3945
4614
  if (!matchedLocator.isFuzzy()) {
3946
4615
  return findElements.call(this, contextEl, matchedLocator)
@@ -3967,6 +4636,13 @@ async function proceedIsChecked(assertType, option) {
3967
4636
  }
3968
4637
 
3969
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
4644
+ }
4645
+
3970
4646
  const matchedLocator = new Locator(locator)
3971
4647
  if (!matchedLocator.isFuzzy()) {
3972
4648
  return this._locate(matchedLocator)
@@ -4145,9 +4821,7 @@ async function targetCreatedHandler(page) {
4145
4821
  if (this.options.windowSize && this.options.windowSize.indexOf('x') > 0 && this._getType() === 'Browser') {
4146
4822
  try {
4147
4823
  await page.setViewportSize(parseWindowSize(this.options.windowSize))
4148
- } catch (err) {
4149
- this.debug('Target can be already closed, ignoring...')
4150
- }
4824
+ } catch (err) {}
4151
4825
  }
4152
4826
  }
4153
4827
 
@@ -4289,6 +4963,11 @@ async function refreshContextSession() {
4289
4963
  }
4290
4964
 
4291
4965
  try {
4966
+ if (!this.page || !this.browserContext) {
4967
+ this.debugSection('Session', 'Skipping storage cleanup - no active page/context')
4968
+ return
4969
+ }
4970
+
4292
4971
  const currentUrl = await this.grabCurrentUrl()
4293
4972
 
4294
4973
  if (currentUrl.startsWith('http')) {
@@ -4322,9 +5001,18 @@ function saveVideoForPage(page, name) {
4322
5001
  async function saveTraceForContext(context, name) {
4323
5002
  if (!context) return
4324
5003
  if (!context.tracing) return
4325
- const fileName = `${`${global.output_dir}${pathSeparator}trace${pathSeparator}${uuidv4()}_${clearString(name)}`.slice(0, 245)}.zip`
4326
- await context.tracing.stop({ path: fileName })
4327
- return fileName
5004
+ try {
5005
+ const fileName = `${`${global.output_dir}${pathSeparator}trace${pathSeparator}${uuidv4()}_${clearString(name)}`.slice(0, 245)}.zip`
5006
+ await context.tracing.stop({ path: fileName })
5007
+ return fileName
5008
+ } catch (err) {
5009
+ // Handle the case where tracing was not started or context is invalid
5010
+ if (err.message && err.message.includes('Must start tracing before stopping')) {
5011
+ // Tracing was never started on this context, silently skip
5012
+ return null
5013
+ }
5014
+ throw err
5015
+ }
4328
5016
  }
4329
5017
 
4330
5018
  async function highlightActiveElement(element) {