codeceptjs 4.0.0-beta.4 → 4.0.0-beta.6.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 (188) hide show
  1. package/README.md +89 -119
  2. package/bin/codecept.js +53 -54
  3. package/docs/webapi/clearCookie.mustache +1 -1
  4. package/lib/actor.js +70 -102
  5. package/lib/ai.js +131 -121
  6. package/lib/assert/empty.js +11 -12
  7. package/lib/assert/equal.js +16 -21
  8. package/lib/assert/error.js +2 -2
  9. package/lib/assert/include.js +11 -15
  10. package/lib/assert/throws.js +3 -5
  11. package/lib/assert/truth.js +10 -7
  12. package/lib/assert.js +18 -18
  13. package/lib/codecept.js +112 -101
  14. package/lib/colorUtils.js +48 -50
  15. package/lib/command/check.js +206 -0
  16. package/lib/command/configMigrate.js +13 -14
  17. package/lib/command/definitions.js +24 -36
  18. package/lib/command/dryRun.js +16 -16
  19. package/lib/command/generate.js +38 -39
  20. package/lib/command/gherkin/init.js +36 -38
  21. package/lib/command/gherkin/snippets.js +76 -74
  22. package/lib/command/gherkin/steps.js +21 -18
  23. package/lib/command/info.js +49 -15
  24. package/lib/command/init.js +41 -37
  25. package/lib/command/interactive.js +22 -13
  26. package/lib/command/list.js +11 -10
  27. package/lib/command/run-multiple/chunk.js +50 -47
  28. package/lib/command/run-multiple/collection.js +5 -5
  29. package/lib/command/run-multiple/run.js +3 -3
  30. package/lib/command/run-multiple.js +27 -47
  31. package/lib/command/run-rerun.js +6 -7
  32. package/lib/command/run-workers.js +15 -66
  33. package/lib/command/run.js +8 -8
  34. package/lib/command/utils.js +22 -21
  35. package/lib/command/workers/runTests.js +131 -241
  36. package/lib/config.js +111 -49
  37. package/lib/container.js +589 -244
  38. package/lib/data/context.js +16 -18
  39. package/lib/data/dataScenarioConfig.js +9 -9
  40. package/lib/data/dataTableArgument.js +7 -7
  41. package/lib/data/table.js +6 -12
  42. package/lib/effects.js +307 -0
  43. package/lib/els.js +160 -0
  44. package/lib/event.js +24 -19
  45. package/lib/globals.js +141 -0
  46. package/lib/heal.js +89 -81
  47. package/lib/helper/AI.js +3 -2
  48. package/lib/helper/ApiDataFactory.js +19 -19
  49. package/lib/helper/Appium.js +47 -51
  50. package/lib/helper/FileSystem.js +35 -15
  51. package/lib/helper/GraphQL.js +1 -1
  52. package/lib/helper/GraphQLDataFactory.js +4 -4
  53. package/lib/helper/JSONResponse.js +72 -45
  54. package/lib/helper/Mochawesome.js +14 -11
  55. package/lib/helper/Playwright.js +832 -434
  56. package/lib/helper/Puppeteer.js +393 -292
  57. package/lib/helper/REST.js +32 -27
  58. package/lib/helper/WebDriver.js +320 -219
  59. package/lib/helper/errors/ConnectionRefused.js +6 -6
  60. package/lib/helper/errors/ElementAssertion.js +11 -16
  61. package/lib/helper/errors/ElementNotFound.js +5 -9
  62. package/lib/helper/errors/RemoteBrowserConnectionRefused.js +5 -5
  63. package/lib/helper/extras/Console.js +11 -11
  64. package/lib/helper/extras/PlaywrightLocator.js +110 -0
  65. package/lib/helper/extras/PlaywrightPropEngine.js +18 -18
  66. package/lib/helper/extras/PlaywrightRestartOpts.js +23 -23
  67. package/lib/helper/extras/Popup.js +22 -22
  68. package/lib/helper/extras/React.js +29 -30
  69. package/lib/helper/network/actions.js +33 -48
  70. package/lib/helper/network/utils.js +76 -83
  71. package/lib/helper/scripts/blurElement.js +6 -6
  72. package/lib/helper/scripts/focusElement.js +6 -6
  73. package/lib/helper/scripts/highlightElement.js +9 -9
  74. package/lib/helper/scripts/isElementClickable.js +34 -34
  75. package/lib/helper.js +2 -1
  76. package/lib/history.js +23 -20
  77. package/lib/hooks.js +10 -10
  78. package/lib/html.js +90 -100
  79. package/lib/index.js +48 -21
  80. package/lib/listener/config.js +8 -9
  81. package/lib/listener/emptyRun.js +54 -0
  82. package/lib/listener/exit.js +10 -12
  83. package/lib/listener/{retry.js → globalRetry.js} +10 -10
  84. package/lib/listener/globalTimeout.js +166 -0
  85. package/lib/listener/helpers.js +43 -24
  86. package/lib/listener/mocha.js +4 -5
  87. package/lib/listener/result.js +11 -0
  88. package/lib/listener/steps.js +26 -23
  89. package/lib/listener/store.js +20 -0
  90. package/lib/locator.js +213 -192
  91. package/lib/mocha/asyncWrapper.js +264 -0
  92. package/lib/mocha/bdd.js +167 -0
  93. package/lib/mocha/cli.js +341 -0
  94. package/lib/mocha/factory.js +160 -0
  95. package/lib/{interfaces → mocha}/featureConfig.js +33 -13
  96. package/lib/{interfaces → mocha}/gherkin.js +75 -45
  97. package/lib/mocha/hooks.js +121 -0
  98. package/lib/mocha/index.js +21 -0
  99. package/lib/mocha/inject.js +46 -0
  100. package/lib/{interfaces → mocha}/scenarioConfig.js +32 -8
  101. package/lib/mocha/suite.js +89 -0
  102. package/lib/mocha/test.js +178 -0
  103. package/lib/mocha/types.d.ts +42 -0
  104. package/lib/mocha/ui.js +229 -0
  105. package/lib/output.js +86 -64
  106. package/lib/parser.js +44 -44
  107. package/lib/pause.js +160 -139
  108. package/lib/plugin/analyze.js +403 -0
  109. package/lib/plugin/{autoLogin.js → auth.js} +137 -43
  110. package/lib/plugin/autoDelay.js +19 -15
  111. package/lib/plugin/coverage.js +22 -27
  112. package/lib/plugin/customLocator.js +5 -5
  113. package/lib/plugin/customReporter.js +53 -0
  114. package/lib/plugin/heal.js +49 -17
  115. package/lib/plugin/pageInfo.js +140 -0
  116. package/lib/plugin/pauseOnFail.js +4 -3
  117. package/lib/plugin/retryFailedStep.js +60 -19
  118. package/lib/plugin/screenshotOnFail.js +80 -83
  119. package/lib/plugin/stepByStepReport.js +70 -31
  120. package/lib/plugin/stepTimeout.js +7 -13
  121. package/lib/plugin/subtitles.js +10 -9
  122. package/lib/recorder.js +167 -126
  123. package/lib/rerun.js +94 -50
  124. package/lib/result.js +161 -0
  125. package/lib/secret.js +18 -17
  126. package/lib/session.js +95 -89
  127. package/lib/step/base.js +239 -0
  128. package/lib/step/comment.js +10 -0
  129. package/lib/step/config.js +50 -0
  130. package/lib/step/func.js +46 -0
  131. package/lib/step/helper.js +50 -0
  132. package/lib/step/meta.js +99 -0
  133. package/lib/step/record.js +74 -0
  134. package/lib/step/retry.js +11 -0
  135. package/lib/step/section.js +55 -0
  136. package/lib/step.js +18 -332
  137. package/lib/steps.js +54 -0
  138. package/lib/store.js +37 -5
  139. package/lib/template/heal.js +2 -11
  140. package/lib/timeout.js +60 -0
  141. package/lib/transform.js +8 -8
  142. package/lib/translation.js +32 -18
  143. package/lib/utils.js +354 -250
  144. package/lib/workerStorage.js +16 -16
  145. package/lib/workers.js +366 -282
  146. package/package.json +107 -95
  147. package/translations/de-DE.js +5 -4
  148. package/translations/fr-FR.js +5 -4
  149. package/translations/index.js +23 -9
  150. package/translations/it-IT.js +5 -4
  151. package/translations/ja-JP.js +5 -4
  152. package/translations/nl-NL.js +76 -0
  153. package/translations/pl-PL.js +5 -4
  154. package/translations/pt-BR.js +5 -4
  155. package/translations/ru-RU.js +5 -4
  156. package/translations/utils.js +18 -0
  157. package/translations/zh-CN.js +5 -4
  158. package/translations/zh-TW.js +5 -4
  159. package/typings/index.d.ts +177 -186
  160. package/typings/promiseBasedTypes.d.ts +3573 -5941
  161. package/typings/types.d.ts +4042 -6370
  162. package/lib/cli.js +0 -256
  163. package/lib/helper/ExpectHelper.js +0 -391
  164. package/lib/helper/Nightmare.js +0 -1504
  165. package/lib/helper/Protractor.js +0 -1863
  166. package/lib/helper/SoftExpectHelper.js +0 -381
  167. package/lib/helper/TestCafe.js +0 -1414
  168. package/lib/helper/clientscripts/nightmare.js +0 -213
  169. package/lib/helper/extras/PlaywrightReactVueLocator.js +0 -43
  170. package/lib/helper/testcafe/testControllerHolder.js +0 -42
  171. package/lib/helper/testcafe/testcafe-utils.js +0 -62
  172. package/lib/interfaces/bdd.js +0 -81
  173. package/lib/listener/artifacts.js +0 -19
  174. package/lib/listener/timeout.js +0 -109
  175. package/lib/mochaFactory.js +0 -113
  176. package/lib/plugin/allure.js +0 -15
  177. package/lib/plugin/commentStep.js +0 -136
  178. package/lib/plugin/debugErrors.js +0 -67
  179. package/lib/plugin/eachElement.js +0 -127
  180. package/lib/plugin/fakerTransform.js +0 -49
  181. package/lib/plugin/retryTo.js +0 -127
  182. package/lib/plugin/selenoid.js +0 -384
  183. package/lib/plugin/standardActingHelpers.js +0 -3
  184. package/lib/plugin/tryTo.js +0 -115
  185. package/lib/plugin/wdio.js +0 -249
  186. package/lib/scenario.js +0 -224
  187. package/lib/ui.js +0 -236
  188. package/lib/within.js +0 -70
@@ -1,18 +1,17 @@
1
- const path = require('path')
2
- const fs = require('fs')
3
-
4
- const Helper = require('@codeceptjs/helper')
5
- const { v4: uuidv4 } = require('uuid')
6
- const assert = require('assert')
7
- const promiseRetry = require('promise-retry')
8
- const Locator = require('../locator')
9
- const recorder = require('../recorder')
10
- const stringIncludes = require('../assert/include').includes
11
- const { urlEquals } = require('../assert/equal')
12
- const { equals } = require('../assert/equal')
13
- const { empty } = require('../assert/empty')
14
- const { truth } = require('../assert/truth')
15
- const {
1
+ import path from 'path'
2
+ import fs from 'fs'
3
+ import Helper from '@codeceptjs/helper'
4
+ import { v4 as uuidv4 } from 'uuid'
5
+ import assert from 'assert'
6
+ import promiseRetry from 'promise-retry'
7
+ import Locator from '../locator.js'
8
+ import recorder from '../recorder.js'
9
+ import store from '../store.js'
10
+ import { includes as stringIncludes } from '../assert/include.js'
11
+ import { urlEquals, equals } from '../assert/equal.js'
12
+ import { empty } from '../assert/empty.js'
13
+ import { truth } from '../assert/truth.js'
14
+ import {
16
15
  xpathLocator,
17
16
  ucfirst,
18
17
  fileExists,
@@ -24,13 +23,14 @@ const {
24
23
  clearString,
25
24
  requireWithFallback,
26
25
  normalizeSpacesInString,
27
- } = require('../utils')
28
- const { isColorProperty, convertColorToRGBA } = require('../colorUtils')
29
- const ElementNotFound = require('./errors/ElementNotFound')
30
- const RemoteBrowserConnectionRefused = require('./errors/RemoteBrowserConnectionRefused')
31
- const Popup = require('./extras/Popup')
32
- const Console = require('./extras/Console')
33
- const { findReact, findVue, findByPlaywrightLocator } = require('./extras/PlaywrightReactVueLocator')
26
+ relativeDir,
27
+ } from '../utils.js'
28
+ import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
29
+ import ElementNotFound from './errors/ElementNotFound.js'
30
+ import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
31
+ import Popup from './extras/Popup.js'
32
+ import Console from './extras/Console.js'
33
+ import { buildLocatorString, findElements, findElement, getVisibleElements, findReact, findVue, findByPlaywrightLocator, findByRole } from './extras/PlaywrightLocator.js'
34
34
 
35
35
  let playwright
36
36
  let perfTiming
@@ -40,26 +40,10 @@ const popupStore = new Popup()
40
40
  const consoleLogStore = new Console()
41
41
  const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
42
42
 
43
- const {
44
- setRestartStrategy,
45
- restartsSession,
46
- restartsContext,
47
- restartsBrowser,
48
- } = require('./extras/PlaywrightRestartOpts')
49
- const { createValueEngine, createDisabledEngine } = require('./extras/PlaywrightPropEngine')
50
- const {
51
- seeElementError,
52
- dontSeeElementError,
53
- dontSeeElementInDOMError,
54
- seeElementInDOMError,
55
- } = require('./errors/ElementAssertion')
56
- const {
57
- dontSeeTraffic,
58
- seeTraffic,
59
- grabRecordedNetworkTraffics,
60
- stopRecordingTraffic,
61
- flushNetworkTraffics,
62
- } = require('./network/actions')
43
+ import { setRestartStrategy, restartsSession, restartsContext } from './extras/PlaywrightRestartOpts.js'
44
+ import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
45
+ import { seeElementError, dontSeeElementError, dontSeeElementInDOMError, seeElementInDOMError } from './errors/ElementAssertion.js'
46
+ import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
63
47
 
64
48
  const pathSeparator = path.sep
65
49
 
@@ -75,7 +59,6 @@ const pathSeparator = path.sep
75
59
  * @prop {boolean} [show=true] - show browser window.
76
60
  * @prop {string|boolean} [restart=false] - restart strategy between tests. Possible values:
77
61
  * * 'context' or **false** - restarts [browser context](https://playwright.dev/docs/api/class-browsercontext) but keeps running browser. Recommended by Playwright team to keep tests isolated.
78
- * * 'browser' or **true** - closes browser and opens it again between tests.
79
62
  * * 'session' or 'keep' - keeps browser context and session, but cleans up cookies and localStorage between tests. The fastest option when running tests in windowed mode. Works with `keepCookies` and `keepBrowserState` options. This behavior was default before CodeceptJS 3.1
80
63
  * @prop {number} [timeout=1000] - - [timeout](https://playwright.dev/docs/api/class-page#page-set-default-timeout) in ms of all Playwright actions .
81
64
  * @prop {boolean} [disableScreenshots=false] - don't save screenshot on failure.
@@ -334,7 +317,7 @@ class Playwright extends Helper {
334
317
  constructor(config) {
335
318
  super(config)
336
319
 
337
- playwright = requireWithFallback('playwright', 'playwright-core')
320
+ // playwright will be loaded dynamically in _init method
338
321
 
339
322
  // set defaults
340
323
  this.isRemoteBrowser = false
@@ -358,6 +341,10 @@ class Playwright extends Helper {
358
341
  this.recordedWebSocketMessagesAtLeastOnce = false
359
342
  this.cdpSession = null
360
343
 
344
+ // Add test failure tracking to prevent false positives
345
+ this.testFailures = []
346
+ this.hasCleanupError = false
347
+
361
348
  // override defaults with config
362
349
  this._setConfig(config)
363
350
  }
@@ -392,9 +379,7 @@ class Playwright extends Helper {
392
379
  config = Object.assign(defaults, config)
393
380
 
394
381
  if (availableBrowsers.indexOf(config.browser) < 0) {
395
- throw new Error(
396
- `Invalid config. Can't use browser "${config.browser}". Accepted values: ${availableBrowsers.join(', ')}`,
397
- )
382
+ throw new Error(`Invalid config. Can't use browser "${config.browser}". Accepted values: ${availableBrowsers.join(', ')}`)
398
383
  }
399
384
 
400
385
  return config
@@ -440,9 +425,7 @@ class Playwright extends Helper {
440
425
  }
441
426
  this.isRemoteBrowser = !!this.playwrightOptions.browserWSEndpoint
442
427
  this.isElectron = this.options.browser === 'electron'
443
- this.userDataDir = this.playwrightOptions.userDataDir
444
- ? `${this.playwrightOptions.userDataDir}_${Date.now().toString()}`
445
- : undefined
428
+ this.userDataDir = this.playwrightOptions.userDataDir ? `${this.playwrightOptions.userDataDir}_${Date.now().toString()}` : undefined
446
429
  this.isCDPConnection = this.playwrightOptions.cdpConnection
447
430
  popupStore.defaultAction = this.options.defaultPopupAction
448
431
  }
@@ -458,27 +441,44 @@ class Playwright extends Helper {
458
441
  name: 'url',
459
442
  message: 'Base url of site to be tested',
460
443
  default: 'http://localhost',
461
- when: (answers) => answers.Playwright_browser !== 'electron',
444
+ when: answers => answers.Playwright_browser !== 'electron',
462
445
  },
463
446
  {
464
447
  name: 'show',
465
448
  message: 'Show browser window',
466
449
  default: true,
467
450
  type: 'confirm',
468
- when: (answers) => answers.Playwright_browser !== 'electron',
451
+ when: answers => answers.Playwright_browser !== 'electron',
469
452
  },
470
453
  ]
471
454
  }
472
455
 
473
456
  static _checkRequirements() {
474
457
  try {
475
- requireWithFallback('playwright', 'playwright-core')
458
+ // In ESM, playwright will be checked via dynamic import in constructor
459
+ // The import will fail at module load time if playwright is missing
460
+ return null
476
461
  } catch (e) {
477
462
  return ['playwright@^1.18']
478
463
  }
479
464
  }
480
465
 
481
466
  async _init() {
467
+ // Load playwright dynamically with fallback
468
+ if (!playwright) {
469
+ try {
470
+ playwright = await import('playwright')
471
+ playwright = playwright.default || playwright
472
+ } catch (e) {
473
+ try {
474
+ playwright = await import('playwright-core')
475
+ playwright = playwright.default || playwright
476
+ } catch (e2) {
477
+ throw new Error('Neither playwright nor playwright-core could be loaded. Please install one of them.')
478
+ }
479
+ }
480
+ }
481
+
482
482
  // register an internal selector engine for reading value property of elements in a selector
483
483
  if (defaultSelectorEnginesInitialized) return
484
484
  defaultSelectorEnginesInitialized = true
@@ -492,7 +492,9 @@ class Playwright extends Helper {
492
492
  }
493
493
 
494
494
  _beforeSuite() {
495
- if ((restartsSession() || restartsContext()) && !this.options.manualStart && !this.isRunning) {
495
+ // Start browser if not manually started and not already running
496
+ // Browser should start in singleton mode (restart: false) or when restart strategy is enabled
497
+ if (!this.options.manualStart && !this.isRunning) {
496
498
  this.debugSection('Session', 'Starting singleton browser session')
497
499
  return this._startBrowser()
498
500
  }
@@ -500,9 +502,22 @@ class Playwright extends Helper {
500
502
 
501
503
  async _before(test) {
502
504
  this.currentRunningTest = test
505
+
506
+ // Reset failure tracking for each test to prevent false positives
507
+ this.hasCleanupError = false
508
+ this.testFailures = []
509
+
510
+ // Reset frame context to ensure clean state for each test
511
+ this.context = this.page
512
+ this.frame = null
513
+ this.contextLocator = null
514
+
515
+ // Clear popup state to ensure clean state for each test
516
+ popupStore.clear()
517
+
503
518
  recorder.retry({
504
- retries: process.env.FAILED_STEP_RETRIES || 3,
505
- when: (err) => {
519
+ retries: test?.opts?.conditionalRetries || 3,
520
+ when: err => {
506
521
  if (!err || typeof err.message !== 'string') {
507
522
  return false
508
523
  }
@@ -511,7 +526,6 @@ class Playwright extends Helper {
511
526
  },
512
527
  })
513
528
 
514
- if (restartsBrowser() && !this.options.manualStart) await this._startBrowser()
515
529
  if (!this.isRunning && !this.options.manualStart) await this._startBrowser()
516
530
 
517
531
  this.isAuthenticated = false
@@ -540,12 +554,25 @@ class Playwright extends Helper {
540
554
  this.currentRunningTest.artifacts.har = fileName
541
555
  contextOptions.recordHar = this.options.recordHar
542
556
  }
557
+
558
+ // load pre-saved cookies
559
+ if (test?.opts?.cookies) contextOptions.storageState = { cookies: test.opts.cookies }
560
+
543
561
  if (this.storageState) contextOptions.storageState = this.storageState
544
562
  if (this.options.userAgent) contextOptions.userAgent = this.options.userAgent
545
563
  if (this.options.locale) contextOptions.locale = this.options.locale
546
564
  if (this.options.colorScheme) contextOptions.colorScheme = this.options.colorScheme
547
565
  this.contextOptions = contextOptions
548
566
  if (!this.browserContext || !restartsSession()) {
567
+ if (!this.browser) {
568
+ if (this.options.manualStart) {
569
+ this.debugSection('Manual Start', 'Browser not started - skipping context creation')
570
+ return // Skip context creation when manualStart is true
571
+ } else {
572
+ throw new Error('Browser not started. This should not happen.')
573
+ }
574
+ }
575
+ this.debugSection('New Session', JSON.stringify(this.contextOptions))
549
576
  this.browserContext = await this.browser.newContext(this.contextOptions) // Adding the HTTPSError ignore in the context so that we can ignore those errors
550
577
  }
551
578
  }
@@ -559,10 +586,7 @@ class Playwright extends Helper {
559
586
  mainPage = existingPages[0] || (await this.browserContext.newPage())
560
587
  } catch (e) {
561
588
  if (this.playwrightOptions.userDataDir) {
562
- this.browser = await playwright[this.options.browser].launchPersistentContext(
563
- this.userDataDir,
564
- this.playwrightOptions,
565
- )
589
+ this.browser = await playwright[this.options.browser].launchPersistentContext(this.userDataDir, this.playwrightOptions)
566
590
  this.browserContext = this.browser
567
591
  const existingPages = await this.browserContext.pages()
568
592
  mainPage = existingPages[0]
@@ -573,6 +597,15 @@ class Playwright extends Helper {
573
597
 
574
598
  await this._setPage(mainPage)
575
599
 
600
+ try {
601
+ // set metadata for reporting
602
+ test.meta.browser = this.browser.browserType().name()
603
+ test.meta.browserVersion = this.browser.version()
604
+ test.meta.windowSize = `${this.page.viewportSize().width}x${this.page.viewportSize().height}`
605
+ } catch (e) {
606
+ this.debug('Failed to set metadata for reporting')
607
+ }
608
+
576
609
  if (this.options.trace) await this.browserContext.tracing.start({ screenshots: true, snapshots: true })
577
610
 
578
611
  return this.browser
@@ -582,8 +615,12 @@ class Playwright extends Helper {
582
615
  if (!this.isRunning) return
583
616
 
584
617
  if (this.isElectron) {
585
- this.browser.close()
586
- this.electronSessions.forEach((session) => session.close())
618
+ try {
619
+ this.browser.close()
620
+ this.electronSessions.forEach(session => session.close())
621
+ } catch (e) {
622
+ console.warn('Warning during electron cleanup:', e.message)
623
+ }
587
624
  return
588
625
  }
589
626
 
@@ -591,34 +628,154 @@ class Playwright extends Helper {
591
628
  return refreshContextSession.bind(this)()
592
629
  }
593
630
 
594
- if (restartsBrowser()) {
595
- this.isRunning = false
596
- return this._stopBrowser()
631
+ // close other sessions with timeout protection, but only if restartsContext() is true
632
+ if (restartsContext()) {
633
+ try {
634
+ if ((await this.browser)?._type === 'Browser') {
635
+ const contexts = await Promise.race([this.browser.contexts(), new Promise((_, reject) => setTimeout(() => reject(new Error('Get contexts timeout')), 3000))])
636
+ const currentContext = contexts[0]
637
+ if (currentContext && (this.options.keepCookies || this.options.keepBrowserState)) {
638
+ try {
639
+ this.storageState = await currentContext.storageState()
640
+ } catch (e) {
641
+ console.warn('Warning during storage state save:', e.message)
642
+ }
643
+ }
644
+
645
+ await Promise.race([Promise.all(contexts.map(c => c.close())), new Promise((_, reject) => setTimeout(() => reject(new Error('Close contexts timeout')), 5000))])
646
+ }
647
+ } catch (e) {
648
+ console.warn('Warning during context cleanup in _after:', e.message)
649
+ }
650
+ }
651
+
652
+ return this.browser
653
+ }
654
+
655
+ async _afterSuite() {
656
+ // Only stop browser if restart strategy requires it
657
+ if ((restartsSession() || restartsContext()) && this.isRunning) {
658
+ try {
659
+ await this._stopBrowser()
660
+ } catch (e) {
661
+ console.warn('Warning during suite cleanup:', e.message)
662
+ // Track suite cleanup failures
663
+ this.hasCleanupError = true
664
+ this.testFailures.push(`Suite cleanup failed: ${e.message}`)
665
+ } finally {
666
+ this.isRunning = false
667
+ }
668
+ }
669
+
670
+ // Force cleanup of any remaining browser processes
671
+ try {
672
+ if (this.browser && (!this.browser.isConnected || this.browser)) {
673
+ await Promise.race([Promise.resolve(), new Promise(resolve => setTimeout(resolve, 1000))])
674
+ }
675
+ } catch (e) {
676
+ console.warn('Final cleanup warning:', e.message)
677
+ this.hasCleanupError = true
678
+ this.testFailures.push(`Final cleanup failed: ${e.message}`)
597
679
  }
598
680
 
599
- // close other sessions
681
+ // Clean up session pages explicitly to prevent hanging references
600
682
  try {
601
- if ((await this.browser)._type === 'Browser') {
602
- const contexts = await this.browser.contexts()
603
- const currentContext = contexts[0]
604
- if (currentContext && (this.options.keepCookies || this.options.keepBrowserState)) {
605
- this.storageState = await currentContext.storageState()
683
+ if (this.sessionPages && Object.keys(this.sessionPages).length > 0) {
684
+ for (const sessionName in this.sessionPages) {
685
+ const sessionPage = this.sessionPages[sessionName]
686
+ if (sessionPage && !sessionPage.isClosed()) {
687
+ try {
688
+ // Remove any remaining event listeners from session pages
689
+ sessionPage.removeAllListeners('dialog')
690
+ sessionPage.removeAllListeners('crash')
691
+ sessionPage.removeAllListeners('close')
692
+ sessionPage.removeAllListeners('error')
693
+ await sessionPage.close()
694
+ } catch (e) {
695
+ console.warn(`Warning closing session page ${sessionName}:`, e.message)
696
+ }
697
+ }
606
698
  }
699
+ this.sessionPages = {} // Clear the session pages object
700
+ this.activeSessionName = '' // Reset active session name
701
+ }
702
+ } catch (e) {
703
+ console.warn('Session pages cleanup warning:', e.message)
704
+ this.hasCleanupError = true
705
+ this.testFailures.push(`Session cleanup failed: ${e.message}`)
706
+ }
607
707
 
608
- await Promise.all(contexts.map((c) => c.close()))
708
+ // Clear any lingering DOM timeouts by executing cleanup in browser context
709
+ try {
710
+ if (this.page && !this.page.isClosed()) {
711
+ await this.page
712
+ .evaluate(() => {
713
+ // Clear any running highlight timeouts by clearing a range of timeout IDs
714
+ for (let i = 1; i <= 1000; i++) {
715
+ clearTimeout(i)
716
+ }
717
+ })
718
+ .catch(() => {
719
+ // Ignore errors if execution context is destroyed (e.g., due to navigation)
720
+ })
609
721
  }
610
722
  } catch (e) {
611
- console.log(e)
723
+ // Only log if it's not an execution context error
724
+ if (!e.message.includes('Execution context was destroyed')) {
725
+ console.warn('DOM timeout cleanup warning:', e.message)
726
+ this.hasCleanupError = true
727
+ this.testFailures.push(`DOM cleanup failed: ${e.message}`)
728
+ }
612
729
  }
613
730
 
614
- // await this.closeOtherTabs();
615
- return this.browser
731
+ // If we have cleanup errors, throw to fail the test suite
732
+ if (this.hasCleanupError && this.testFailures.length > 0) {
733
+ const errorMessage = `Test suite cleanup failed: ${this.testFailures.join('; ')}`
734
+ console.error(errorMessage)
735
+ throw new Error(errorMessage)
736
+ }
616
737
  }
617
738
 
618
- _afterSuite() {}
619
-
620
739
  async _finishTest() {
621
- if ((restartsSession() || restartsContext()) && this.isRunning) return this._stopBrowser()
740
+ if ((restartsSession() || restartsContext()) && this.isRunning) {
741
+ try {
742
+ await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Test finish timeout')), 10000))])
743
+ } catch (e) {
744
+ console.warn('Warning during test finish cleanup:', e.message)
745
+ // Track cleanup failures to prevent false positives
746
+ this.hasCleanupError = true
747
+ this.testFailures.push(`Test finish cleanup failed: ${e.message}`)
748
+
749
+ this.isRunning = false
750
+ // Set flags to prevent further operations after cleanup failure
751
+ this.page = null
752
+ this.browserContext = null
753
+ this.browser = null
754
+
755
+ // Propagate the error to fail the test properly
756
+ throw new Error(`Test cleanup failed: ${e.message}`)
757
+ }
758
+ }
759
+ }
760
+
761
+ async _cleanup() {
762
+ // Final cleanup when test run completes
763
+ if (this.isRunning) {
764
+ try {
765
+ await this._stopBrowser()
766
+ } catch (e) {
767
+ console.warn('Warning during final cleanup:', e.message)
768
+ }
769
+ } else {
770
+ // Check if we still have a browser object despite isRunning being false
771
+ if (this.browser) {
772
+ try {
773
+ await this._stopBrowser()
774
+ } catch (e) {
775
+ console.warn('Warning during forced cleanup:', e.message)
776
+ }
777
+ }
778
+ }
622
779
  }
623
780
 
624
781
  _session() {
@@ -637,16 +794,20 @@ class Playwright extends Helper {
637
794
  page = await browser.firstWindow()
638
795
  } else {
639
796
  try {
640
- browserContext = await this.browser.newContext(Object.assign(this.contextOptions, config))
641
- page = await browserContext.newPage()
797
+ // Check if browser is still available before creating context
798
+ if (!this.browser) {
799
+ throw new Error('Browser is not available for session context creation')
800
+ }
801
+ browserContext = await Promise.race([this.browser.newContext(Object.assign(this.contextOptions, config)), new Promise((_, reject) => setTimeout(() => reject(new Error('New context timeout')), 10000))])
802
+ page = await Promise.race([browserContext.newPage(), new Promise((_, reject) => setTimeout(() => reject(new Error('New page timeout')), 5000))])
642
803
  } catch (e) {
804
+ console.warn('Warning during context creation:', e.message)
643
805
  if (this.playwrightOptions.userDataDir) {
644
- browserContext = await playwright[this.options.browser].launchPersistentContext(
645
- `${this.userDataDir}_${this.activeSessionName}`,
646
- this.playwrightOptions,
647
- )
806
+ browserContext = await playwright[this.options.browser].launchPersistentContext(`${this.userDataDir}_${this.activeSessionName}`, this.playwrightOptions)
648
807
  this.browser = browserContext
649
808
  page = await browserContext.pages()[0]
809
+ } else {
810
+ throw e
650
811
  }
651
812
  }
652
813
  }
@@ -660,7 +821,7 @@ class Playwright extends Helper {
660
821
  stop: async () => {
661
822
  // is closed by _after
662
823
  },
663
- loadVars: async (context) => {
824
+ loadVars: async context => {
664
825
  if (context) {
665
826
  this.browserContext = context
666
827
  const existingPages = await context.pages()
@@ -668,7 +829,7 @@ class Playwright extends Helper {
668
829
  return this._setPage(this.sessionPages[this.activeSessionName])
669
830
  }
670
831
  },
671
- restoreVars: async (session) => {
832
+ restoreVars: async session => {
672
833
  this.withinLocator = null
673
834
  this.browserContext = defaultContext
674
835
 
@@ -677,8 +838,28 @@ class Playwright extends Helper {
677
838
  } else {
678
839
  this.activeSessionName = session
679
840
  }
680
- const existingPages = await this.browserContext.pages()
681
- await this._setPage(existingPages[0])
841
+
842
+ // Safety check: ensure browserContext exists before calling pages()
843
+ if (!this.browserContext) {
844
+ this.debug('Cannot restore session vars: browserContext is undefined')
845
+ return
846
+ }
847
+
848
+ try {
849
+ const existingPages = await this.browserContext.pages()
850
+ if (existingPages && existingPages.length > 0) {
851
+ await this._setPage(existingPages[0])
852
+ // Reset context-related variables to ensure clean state after session
853
+ this.context = await this.page
854
+ this.contextLocator = null
855
+ this.frame = null
856
+ } else {
857
+ this.debug('Cannot restore session vars: no pages available')
858
+ }
859
+ } catch (err) {
860
+ this.debug(`Failed to restore session vars: ${err.message}`)
861
+ return
862
+ }
682
863
 
683
864
  return this._waitForAction()
684
865
  },
@@ -764,21 +945,43 @@ class Playwright extends Helper {
764
945
  * @param {object} page page to set
765
946
  */
766
947
  async _setPage(page) {
948
+ // Clean up previous page event listeners
949
+ if (this.page && this.page !== page) {
950
+ try {
951
+ this.page.removeAllListeners('crash')
952
+ this.page.removeAllListeners('dialog')
953
+ } catch (e) {
954
+ console.warn('Warning cleaning previous page listeners:', e.message)
955
+ }
956
+ }
957
+
767
958
  page = await page
768
959
  this._addPopupListener(page)
769
960
  this.page = page
770
961
  if (!page) return
771
- this.browserContext.setDefaultTimeout(0)
772
- page.setDefaultNavigationTimeout(this.options.getPageTimeout)
773
- page.setDefaultTimeout(this.options.timeout)
774
962
 
775
- page.on('crash', async () => {
776
- console.log('ERROR: Page has crashed, closing page!')
777
- await page.close()
778
- })
779
- this.context = await this.page
780
- this.contextLocator = null
781
- await page.bringToFront()
963
+ try {
964
+ this.browserContext.setDefaultTimeout(0)
965
+ page.setDefaultNavigationTimeout(this.options.getPageTimeout)
966
+ page.setDefaultTimeout(this.options.timeout)
967
+
968
+ page.on('crash', async () => {
969
+ console.log('ERROR: Page has crashed, closing page!')
970
+ try {
971
+ await page.close()
972
+ } catch (e) {
973
+ console.warn('Warning during crashed page cleanup:', e.message)
974
+ }
975
+ })
976
+
977
+ this.context = await this.page
978
+ this.contextLocator = null
979
+ await page.bringToFront()
980
+ } catch (e) {
981
+ console.warn('Warning during page setup:', e.message)
982
+ this.context = await this.page
983
+ this.contextLocator = null
984
+ }
782
985
  }
783
986
 
784
987
  /**
@@ -793,7 +996,7 @@ class Playwright extends Helper {
793
996
  return
794
997
  }
795
998
  page.removeAllListeners('dialog')
796
- page.on('dialog', async (dialog) => {
999
+ page.on('dialog', async dialog => {
797
1000
  popupStore.popup = dialog
798
1001
  const action = popupStore.actionType || this.options.defaultPopupAction
799
1002
  await this._waitForAction()
@@ -856,16 +1059,13 @@ class Playwright extends Helper {
856
1059
  throw err
857
1060
  }
858
1061
  } else if (this.playwrightOptions.userDataDir) {
859
- this.browser = await playwright[this.options.browser].launchPersistentContext(
860
- this.userDataDir,
861
- this.playwrightOptions,
862
- )
1062
+ this.browser = await playwright[this.options.browser].launchPersistentContext(this.userDataDir, this.playwrightOptions)
863
1063
  } else {
864
1064
  this.browser = await playwright[this.options.browser].launch(this.playwrightOptions)
865
1065
  }
866
1066
 
867
1067
  // works only for Chromium
868
- this.browser.on('targetchanged', (target) => {
1068
+ this.browser.on('targetchanged', target => {
869
1069
  this.debugSection('Url', target.url())
870
1070
  })
871
1071
 
@@ -879,6 +1079,9 @@ class Playwright extends Helper {
879
1079
  * @param {object} [contextOptions] See https://playwright.dev/docs/api/class-browser#browser-new-context
880
1080
  */
881
1081
  async _createContextPage(contextOptions) {
1082
+ if (!this.browser) {
1083
+ throw new Error('Browser not started. Call _startBrowser() first or disable manualStart option.')
1084
+ }
882
1085
  this.browserContext = await this.browser.newContext(contextOptions)
883
1086
  const page = await this.browserContext.newPage()
884
1087
  targetCreatedHandler.call(this, page)
@@ -895,8 +1098,58 @@ class Playwright extends Helper {
895
1098
  this.context = null
896
1099
  this.frame = null
897
1100
  popupStore.clear()
898
- if (this.options.recordHar) await this.browserContext.close()
899
- await this.browser.close()
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))])
1126
+ }
1127
+ } catch (error) {
1128
+ console.warn('Failed to close browser context:', error.message)
1129
+ }
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))])
1134
+ }
1135
+ } catch (error) {
1136
+ console.warn('Failed to close browser:', error.message)
1137
+ }
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')
1143
+ }
1144
+ } catch (e) {
1145
+ // Silently ignore process kill errors
1146
+ }
1147
+
1148
+ // Ensure cleanup is complete
1149
+ this.browser = null
1150
+ this.browserContext = null
1151
+ this.page = null
1152
+ this.isRunning = false
900
1153
  }
901
1154
 
902
1155
  async _evaluateHandeInContext(...args) {
@@ -913,8 +1166,21 @@ class Playwright extends Helper {
913
1166
 
914
1167
  if (frame) {
915
1168
  if (Array.isArray(frame)) {
1169
+ // For nested frames, build the complete frame path
916
1170
  await this.switchTo(null)
917
- return frame.reduce((p, frameLocator) => p.then(() => this.switchTo(frameLocator)), Promise.resolve())
1171
+
1172
+ // Build nested frame locator from page
1173
+ let frameLocatorObj = this.page
1174
+ for (const frameSelector of frame) {
1175
+ const selector = buildLocatorString(new Locator(frameSelector, 'css'))
1176
+ frameLocatorObj = frameLocatorObj.frameLocator(selector)
1177
+ }
1178
+
1179
+ this.frame = frameLocatorObj
1180
+ this.context = frameLocatorObj
1181
+ this.contextLocator = null
1182
+ this.withinLocator = new Locator(frame)
1183
+ return
918
1184
  }
919
1185
  await this.switchTo(frame)
920
1186
  this.withinLocator = new Locator(frame)
@@ -931,7 +1197,11 @@ class Playwright extends Helper {
931
1197
 
932
1198
  async _withinEnd() {
933
1199
  this.withinLocator = null
934
- this.context = await this.page
1200
+ if (this.page) {
1201
+ this.context = await this.page
1202
+ } else {
1203
+ this.context = null
1204
+ }
935
1205
  this.contextLocator = null
936
1206
  this.frame = null
937
1207
  }
@@ -940,7 +1210,7 @@ class Playwright extends Helper {
940
1210
  const navigationStart = timing.navigationStart
941
1211
 
942
1212
  const extractedData = {}
943
- dataNames.forEach((name) => {
1213
+ dataNames.forEach(name => {
944
1214
  extractedData[name] = timing[name] - navigationStart
945
1215
  })
946
1216
 
@@ -954,8 +1224,15 @@ class Playwright extends Helper {
954
1224
  if (this.isElectron) {
955
1225
  throw new Error('Cannot open pages inside an Electron container')
956
1226
  }
1227
+
1228
+ // Prevent navigation attempts when browser is being torn down
1229
+ if (!this.isRunning && (!this.browser || !this.browserContext || !this.page)) {
1230
+ throw new Error('Cannot navigate: browser is not running or has been closed')
1231
+ }
1232
+
957
1233
  if (!/^\w+\:(\/\/|.+)/.test(url)) {
958
- url = this.options.url + (url.startsWith('/') ? url : `/${url}`)
1234
+ url = this.options.url + (!this.options.url.endsWith('/') && url.startsWith('/') ? url : `/${url}`)
1235
+ this.debug(`Changed URL to base url + relative path: ${url}`)
959
1236
  }
960
1237
 
961
1238
  if (this.options.basicAuth && this.isAuthenticated !== true) {
@@ -965,17 +1242,81 @@ class Playwright extends Helper {
965
1242
  }
966
1243
  }
967
1244
 
968
- await this.page.goto(url, { waitUntil: this.options.waitForNavigation })
1245
+ // Ensure browser is initialized before page operations
1246
+ if (!this.page) {
1247
+ this.debugSection('Auto-initializing', `Browser not started properly. page=${!!this.page}, isRunning=${this.isRunning}, browser=${!!this.browser}, browserContext=${!!this.browserContext}`)
1248
+
1249
+ if (!this.browser) {
1250
+ await this._startBrowser()
1251
+ }
1252
+
1253
+ // Create browser context and page (simplified version of _before logic)
1254
+ if (!this.browserContext) {
1255
+ if (!this.browser) {
1256
+ throw new Error('Browser is not available for context creation. Browser may have been closed.')
1257
+ }
1258
+ const contextOptions = {
1259
+ ignoreHTTPSErrors: this.options.ignoreHTTPSErrors,
1260
+ acceptDownloads: true,
1261
+ ...this.options.emulate,
1262
+ }
1263
+ this.browserContext = await this.browser.newContext(contextOptions)
1264
+ }
1265
+
1266
+ let pages
1267
+ let mainPage
1268
+ try {
1269
+ pages = await this.browserContext.pages()
1270
+ mainPage = pages[0] || (await this.browserContext.newPage())
1271
+ } catch (e) {
1272
+ if (e.message.includes('Target page, context or browser has been closed') || e.message.includes('Browser has been closed')) {
1273
+ throw new Error('Cannot create page: browser context has been closed')
1274
+ }
1275
+ throw e
1276
+ }
1277
+ await this._setPage(mainPage)
1278
+
1279
+ this.debugSection('Auto-initializing', `Completed. page=${!!this.page}, browserContext=${!!this.browserContext}`)
1280
+ }
1281
+
1282
+ // Additional safety check
1283
+ if (!this.page) {
1284
+ throw new Error(`Page is not initialized after auto-initialization. this.page=${this.page}, this.isRunning=${this.isRunning}, this.browser=${!!this.browser}, this.browserContext=${!!this.browserContext}`)
1285
+ }
1286
+
1287
+ try {
1288
+ // Additional validation before navigation
1289
+ if (this.page && this.page.isClosed && this.page.isClosed()) {
1290
+ throw new Error('Cannot navigate: page has been closed')
1291
+ }
1292
+
1293
+ if (this.browserContext) {
1294
+ // Try to check if context is still valid
1295
+ try {
1296
+ await Promise.race([this.browserContext.pages(), new Promise((_, reject) => setTimeout(() => reject(new Error('Context check timeout')), 1000))])
1297
+ } catch (contextError) {
1298
+ throw new Error('Cannot navigate: browser context is invalid or closed')
1299
+ }
1300
+ }
1301
+
1302
+ await this.page.goto(url, { waitUntil: this.options.waitForNavigation })
1303
+ } catch (err) {
1304
+ // Handle terminal navigation errors that shouldn't be retried
1305
+ if (
1306
+ err.message &&
1307
+ (err.message.includes('ERR_ABORTED') || err.message.includes('frame was detached') || err.message.includes('Target page, context or browser has been closed') || err.message.includes('Cannot navigate'))
1308
+ ) {
1309
+ // Mark this as a terminal error to prevent retries
1310
+ const terminalError = new Error(err.message)
1311
+ terminalError.isTerminal = true
1312
+ throw terminalError
1313
+ }
1314
+ throw err
1315
+ }
969
1316
 
970
1317
  const performanceTiming = JSON.parse(await this.page.evaluate(() => JSON.stringify(window.performance.timing)))
971
1318
 
972
- perfTiming = this._extractDataFromPerformanceTiming(
973
- performanceTiming,
974
- 'responseEnd',
975
- 'domInteractive',
976
- 'domContentLoadedEventEnd',
977
- 'loadEventEnd',
978
- )
1319
+ perfTiming = this._extractDataFromPerformanceTiming(performanceTiming, 'responseEnd', 'domInteractive', 'domContentLoadedEventEnd', 'loadEventEnd')
979
1320
 
980
1321
  return this._waitForAction()
981
1322
  }
@@ -1127,26 +1468,6 @@ class Playwright extends Helper {
1127
1468
  await this.page.mouse.up()
1128
1469
  }
1129
1470
 
1130
- /**
1131
- * Restart browser with a new context and a new page
1132
- *
1133
- * ```js
1134
- * // Restart browser and use a new timezone
1135
- * I.restartBrowser({ timezoneId: 'America/Phoenix' });
1136
- * // Open URL in a new page in changed timezone
1137
- * I.amOnPage('/');
1138
- * // Restart browser, allow reading/copying of text from/into clipboard in Chrome
1139
- * I.restartBrowser({ permissions: ['clipboard-read', 'clipboard-write'] });
1140
- * ```
1141
- *
1142
- * @param {object} [contextOptions] [Options for browser context](https://playwright.dev/docs/api/class-browser#browser-new-context) when starting new browser
1143
- */
1144
- async restartBrowser(contextOptions) {
1145
- await this._stopBrowser()
1146
- await this._startBrowser()
1147
- await this._createContextPage(contextOptions)
1148
- }
1149
-
1150
1471
  /**
1151
1472
  * {{> refreshPage }}
1152
1473
  */
@@ -1197,10 +1518,7 @@ class Playwright extends Helper {
1197
1518
  return this.executeScript(() => {
1198
1519
  const body = document.body
1199
1520
  const html = document.documentElement
1200
- window.scrollTo(
1201
- 0,
1202
- Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight),
1203
- )
1521
+ window.scrollTo(0, Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight))
1204
1522
  })
1205
1523
  }
1206
1524
 
@@ -1287,7 +1605,22 @@ class Playwright extends Helper {
1287
1605
 
1288
1606
  if (this.frame) return findElements(this.frame, locator)
1289
1607
 
1290
- return findElements(context, locator)
1608
+ const els = await findElements(context, locator)
1609
+
1610
+ if (store.debugMode) {
1611
+ const previewElements = els.slice(0, 3)
1612
+ let htmls = await Promise.all(previewElements.map(el => elToString(el, previewElements.length)))
1613
+ if (els.length > 3) htmls.push('...')
1614
+ if (els.length > 1) {
1615
+ this.debugSection(`Elements (${els.length})`, htmls.join('|').trim())
1616
+ } else if (els.length === 1) {
1617
+ this.debugSection('Element', htmls.join('|').trim())
1618
+ } else {
1619
+ this.debug(`No elements found by ${JSON.stringify(locator).slice(0, 50)}....`)
1620
+ }
1621
+ }
1622
+
1623
+ return els
1291
1624
  }
1292
1625
 
1293
1626
  /**
@@ -1437,10 +1770,10 @@ class Playwright extends Helper {
1437
1770
  */
1438
1771
  async closeOtherTabs() {
1439
1772
  const pages = await this.browserContext.pages()
1440
- const otherPages = pages.filter((page) => page !== this.page)
1773
+ const otherPages = pages.filter(page => page !== this.page)
1441
1774
  if (otherPages.length) {
1442
1775
  this.debug(`Closing ${otherPages.length} tabs`)
1443
- return Promise.all(otherPages.map((p) => p.close()))
1776
+ return Promise.all(otherPages.map(p => p.close()))
1444
1777
  }
1445
1778
  return Promise.resolve()
1446
1779
  }
@@ -1483,9 +1816,9 @@ class Playwright extends Helper {
1483
1816
  */
1484
1817
  async seeElement(locator) {
1485
1818
  let els = await this._locate(locator)
1486
- els = await Promise.all(els.map((el) => el.isVisible()))
1819
+ els = await Promise.all(els.map(el => el.isVisible()))
1487
1820
  try {
1488
- return empty('visible elements').negate(els.filter((v) => v).fill('ELEMENT'))
1821
+ return empty('visible elements').negate(els.filter(v => v).fill('ELEMENT'))
1489
1822
  } catch (e) {
1490
1823
  dontSeeElementError(locator)
1491
1824
  }
@@ -1497,9 +1830,9 @@ class Playwright extends Helper {
1497
1830
  */
1498
1831
  async dontSeeElement(locator) {
1499
1832
  let els = await this._locate(locator)
1500
- els = await Promise.all(els.map((el) => el.isVisible()))
1833
+ els = await Promise.all(els.map(el => el.isVisible()))
1501
1834
  try {
1502
- return empty('visible elements').assert(els.filter((v) => v).fill('ELEMENT'))
1835
+ return empty('visible elements').assert(els.filter(v => v).fill('ELEMENT'))
1503
1836
  } catch (e) {
1504
1837
  seeElementError(locator)
1505
1838
  }
@@ -1511,7 +1844,7 @@ class Playwright extends Helper {
1511
1844
  async seeElementInDOM(locator) {
1512
1845
  const els = await this._locate(locator)
1513
1846
  try {
1514
- return empty('elements on page').negate(els.filter((v) => v).fill('ELEMENT'))
1847
+ return empty('elements on page').negate(els.filter(v => v).fill('ELEMENT'))
1515
1848
  } catch (e) {
1516
1849
  dontSeeElementInDOMError(locator)
1517
1850
  }
@@ -1523,7 +1856,7 @@ class Playwright extends Helper {
1523
1856
  async dontSeeElementInDOM(locator) {
1524
1857
  const els = await this._locate(locator)
1525
1858
  try {
1526
- return empty('elements on a page').assert(els.filter((v) => v).fill('ELEMENT'))
1859
+ return empty('elements on a page').assert(els.filter(v => v).fill('ELEMENT'))
1527
1860
  } catch (e) {
1528
1861
  seeElementInDOMError(locator)
1529
1862
  }
@@ -1547,7 +1880,7 @@ class Playwright extends Helper {
1547
1880
  * @return {Promise<void>}
1548
1881
  */
1549
1882
  async handleDownloads(fileName) {
1550
- this.page.waitForEvent('download').then(async (download) => {
1883
+ this.page.waitForEvent('download').then(async download => {
1551
1884
  const filePath = await download.path()
1552
1885
  fileName = fileName || `downloads/${path.basename(filePath)}`
1553
1886
 
@@ -1741,6 +2074,7 @@ class Playwright extends Helper {
1741
2074
  const el = els[0]
1742
2075
 
1743
2076
  await el.clear()
2077
+ if (store.debugMode) this.debugSection('Focused', await elToString(el, 1))
1744
2078
 
1745
2079
  await highlightActiveElement.call(this, el)
1746
2080
 
@@ -1852,8 +2186,8 @@ class Playwright extends Helper {
1852
2186
  */
1853
2187
  async grabNumberOfVisibleElements(locator) {
1854
2188
  let els = await this._locate(locator)
1855
- els = await Promise.all(els.map((el) => el.isVisible()))
1856
- return els.filter((v) => v).length
2189
+ els = await Promise.all(els.map(el => el.isVisible()))
2190
+ return els.filter(v => v).length
1857
2191
  }
1858
2192
 
1859
2193
  /**
@@ -1963,9 +2297,7 @@ class Playwright extends Helper {
1963
2297
  */
1964
2298
  async seeNumberOfElements(locator, num) {
1965
2299
  const elements = await this._locate(locator)
1966
- return equals(
1967
- `expected number of elements (${new Locator(locator)}) is ${num}, but found ${elements.length}`,
1968
- ).assert(elements.length, num)
2300
+ return equals(`expected number of elements (${new Locator(locator)}) is ${num}, but found ${elements.length}`).assert(elements.length, num)
1969
2301
  }
1970
2302
 
1971
2303
  /**
@@ -1975,10 +2307,7 @@ class Playwright extends Helper {
1975
2307
  */
1976
2308
  async seeNumberOfVisibleElements(locator, num) {
1977
2309
  const res = await this.grabNumberOfVisibleElements(locator)
1978
- return equals(`expected number of visible elements (${new Locator(locator)}) is ${num}, but found ${res}`).assert(
1979
- res,
1980
- num,
1981
- )
2310
+ return equals(`expected number of visible elements (${new Locator(locator)}) is ${num}, but found ${res}`).assert(res, num)
1982
2311
  }
1983
2312
 
1984
2313
  /**
@@ -1997,7 +2326,7 @@ class Playwright extends Helper {
1997
2326
  */
1998
2327
  async seeCookie(name) {
1999
2328
  const cookies = await this.browserContext.cookies()
2000
- empty(`cookie ${name} to be set`).negate(cookies.filter((c) => c.name === name))
2329
+ empty(`cookie ${name} to be set`).negate(cookies.filter(c => c.name === name))
2001
2330
  }
2002
2331
 
2003
2332
  /**
@@ -2005,7 +2334,7 @@ class Playwright extends Helper {
2005
2334
  */
2006
2335
  async dontSeeCookie(name) {
2007
2336
  const cookies = await this.browserContext.cookies()
2008
- empty(`cookie ${name} not to be set`).assert(cookies.filter((c) => c.name === name))
2337
+ empty(`cookie ${name} not to be set`).assert(cookies.filter(c => c.name === name))
2009
2338
  }
2010
2339
 
2011
2340
  /**
@@ -2014,19 +2343,31 @@ class Playwright extends Helper {
2014
2343
  * {{> grabCookie }}
2015
2344
  */
2016
2345
  async grabCookie(name) {
2017
- const cookies = await this.browserContext.cookies()
2018
- if (!name) return cookies
2019
- const cookie = cookies.filter((c) => c.name === name)
2020
- if (cookie[0]) return cookie[0]
2346
+ if (!this.browserContext) {
2347
+ throw new Error('Browser context is not available for grabCookie')
2348
+ }
2349
+
2350
+ try {
2351
+ const cookies = await this.browserContext.cookies()
2352
+ if (!name) return cookies
2353
+ const cookie = cookies.filter(c => c.name === name)
2354
+ if (cookie[0]) return cookie[0]
2355
+ } catch (err) {
2356
+ if (err.message.includes('Target page, context or browser has been closed') || err.message.includes('Browser has been closed')) {
2357
+ throw new Error('Cannot grab cookies: browser context has been closed')
2358
+ }
2359
+ throw err
2360
+ }
2021
2361
  }
2022
2362
 
2023
2363
  /**
2024
2364
  * {{> clearCookie }}
2025
2365
  */
2026
- async clearCookie() {
2027
- // Playwright currently doesn't support to delete a certain cookie
2028
- // https://github.com/microsoft/playwright/blob/main/docs/src/api/class-browsercontext.md#async-method-browsercontextclearcookies
2366
+ async clearCookie(cookieName) {
2029
2367
  if (!this.browserContext) return
2368
+ if (cookieName) {
2369
+ return this.browserContext.clearCookies({ name: cookieName })
2370
+ }
2030
2371
  return this.browserContext.clearCookies()
2031
2372
  }
2032
2373
 
@@ -2083,9 +2424,28 @@ class Playwright extends Helper {
2083
2424
  *
2084
2425
  */
2085
2426
  async grabTextFrom(locator) {
2086
- locator = this._contextLocator(locator)
2087
- const text = await this.page.textContent(locator)
2088
- assertElementExists(text, locator)
2427
+ const originalLocator = locator
2428
+ const matchedLocator = new Locator(locator)
2429
+
2430
+ if (!matchedLocator.isFuzzy()) {
2431
+ const els = await this._locate(matchedLocator)
2432
+ assertElementExists(els, locator)
2433
+ const text = await els[0].innerText()
2434
+ this.debugSection('Text', text)
2435
+ 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`)
2445
+ }
2446
+ throw err
2447
+ }
2448
+ assertElementExists(text, contextAwareLocator)
2089
2449
  this.debugSection('Text', text)
2090
2450
  return text
2091
2451
  }
@@ -2098,7 +2458,7 @@ class Playwright extends Helper {
2098
2458
  const els = await this._locate(locator)
2099
2459
  const texts = []
2100
2460
  for (const el of els) {
2101
- texts.push(await await el.innerText())
2461
+ texts.push(await el.innerText())
2102
2462
  }
2103
2463
  this.debug(`Matched ${els.length} elements`)
2104
2464
  return texts
@@ -2120,7 +2480,7 @@ class Playwright extends Helper {
2120
2480
  async grabValueFromAll(locator) {
2121
2481
  const els = await findFields.call(this, locator)
2122
2482
  this.debug(`Matched ${els.length} elements`)
2123
- return Promise.all(els.map((el) => el.inputValue()))
2483
+ return Promise.all(els.map(el => el.inputValue()))
2124
2484
  }
2125
2485
 
2126
2486
  /**
@@ -2139,7 +2499,7 @@ class Playwright extends Helper {
2139
2499
  async grabHTMLFromAll(locator) {
2140
2500
  const els = await this._locate(locator)
2141
2501
  this.debug(`Matched ${els.length} elements`)
2142
- return Promise.all(els.map((el) => el.innerHTML()))
2502
+ return Promise.all(els.map(el => el.innerHTML()))
2143
2503
  }
2144
2504
 
2145
2505
  /**
@@ -2160,11 +2520,7 @@ class Playwright extends Helper {
2160
2520
  async grabCssPropertyFromAll(locator, cssProperty) {
2161
2521
  const els = await this._locate(locator)
2162
2522
  this.debug(`Matched ${els.length} elements`)
2163
- const cssValues = await Promise.all(
2164
- els.map((el) =>
2165
- el.evaluate((el, cssProperty) => getComputedStyle(el).getPropertyValue(cssProperty), cssProperty),
2166
- ),
2167
- )
2523
+ const cssValues = await Promise.all(els.map(el => el.evaluate((el, cssProperty) => getComputedStyle(el).getPropertyValue(cssProperty), cssProperty)))
2168
2524
 
2169
2525
  return cssValues
2170
2526
  }
@@ -2192,19 +2548,16 @@ class Playwright extends Helper {
2192
2548
  }
2193
2549
  }
2194
2550
 
2195
- const values = Object.keys(cssPropertiesCamelCase).map((key) => cssPropertiesCamelCase[key])
2551
+ const values = Object.keys(cssPropertiesCamelCase).map(key => cssPropertiesCamelCase[key])
2196
2552
  if (!Array.isArray(props)) props = [props]
2197
2553
  let chunked = chunkArray(props, values.length)
2198
- chunked = chunked.filter((val) => {
2554
+ chunked = chunked.filter(val => {
2199
2555
  for (let i = 0; i < val.length; ++i) {
2200
- // eslint-disable-next-line eqeqeq
2201
2556
  if (val[i] != values[i]) return false
2202
2557
  }
2203
2558
  return true
2204
2559
  })
2205
- return equals(
2206
- `all elements (${new Locator(locator)}) to have CSS property ${JSON.stringify(cssProperties)}`,
2207
- ).assert(chunked.length, elemAmount)
2560
+ return equals(`all elements (${new Locator(locator)}) to have CSS property ${JSON.stringify(cssProperties)}`).assert(chunked.length, elemAmount)
2208
2561
  }
2209
2562
 
2210
2563
  /**
@@ -2217,16 +2570,16 @@ class Playwright extends Helper {
2217
2570
 
2218
2571
  const elemAmount = res.length
2219
2572
  const commands = []
2220
- res.forEach((el) => {
2221
- Object.keys(attributes).forEach((prop) => {
2573
+ res.forEach(el => {
2574
+ Object.keys(attributes).forEach(prop => {
2222
2575
  commands.push(el.evaluate((el, attr) => el[attr] || el.getAttribute(attr), prop))
2223
2576
  })
2224
2577
  })
2225
2578
  let attrs = await Promise.all(commands)
2226
- const values = Object.keys(attributes).map((key) => attributes[key])
2579
+ const values = Object.keys(attributes).map(key => attributes[key])
2227
2580
  if (!Array.isArray(attrs)) attrs = [attrs]
2228
2581
  let chunked = chunkArray(attrs, values.length)
2229
- chunked = chunked.filter((val) => {
2582
+ chunked = chunked.filter(val => {
2230
2583
  for (let i = 0; i < val.length; ++i) {
2231
2584
  // the attribute could be a boolean
2232
2585
  if (typeof val[i] === 'boolean') return val[i] === values[i]
@@ -2235,10 +2588,7 @@ class Playwright extends Helper {
2235
2588
  }
2236
2589
  return true
2237
2590
  })
2238
- return equals(`all elements (${new Locator(locator)}) to have attributes ${JSON.stringify(attributes)}`).assert(
2239
- chunked.length,
2240
- elemAmount,
2241
- )
2591
+ return equals(`all elements (${new Locator(locator)}) to have attributes ${JSON.stringify(attributes)}`).assert(chunked.length, elemAmount)
2242
2592
  }
2243
2593
 
2244
2594
  /**
@@ -2297,11 +2647,16 @@ class Playwright extends Helper {
2297
2647
  async saveElementScreenshot(locator, fileName) {
2298
2648
  const outputFile = screenshotOutputFolder(fileName)
2299
2649
 
2300
- const res = await this._locateElement(locator)
2301
- assertElementExists(res, locator)
2302
- const elem = res
2303
- this.debug(`Screenshot of ${new Locator(locator)} element has been saved to ${outputFile}`)
2304
- return elem.screenshot({ path: outputFile, type: 'png' })
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
+ }
2305
2660
  }
2306
2661
 
2307
2662
  /**
@@ -2311,27 +2666,72 @@ class Playwright extends Helper {
2311
2666
  const fullPageOption = fullPage || this.options.fullPageScreenshots
2312
2667
  let outputFile = screenshotOutputFolder(fileName)
2313
2668
 
2314
- this.debug(`Screenshot is saving to ${outputFile}`)
2669
+ this.debugSection('Screenshot', relativeDir(outputFile))
2315
2670
 
2316
- await this.page.screenshot({
2317
- path: outputFile,
2318
- fullPage: fullPageOption,
2319
- type: 'png',
2320
- })
2671
+ if (!this.page || !this.browser || !this.browserContext) {
2672
+ this.debug(`Cannot take screenshot: page=${!!this.page}, browser=${!!this.browser}, browserContext=${!!this.browserContext}`)
2673
+ return
2674
+ }
2675
+ if (this.page.isClosed && this.page.isClosed()) {
2676
+ this.debug('Cannot take screenshot: page is closed')
2677
+ return
2678
+ }
2679
+
2680
+ try {
2681
+ await Promise.race([
2682
+ this.page.screenshot({
2683
+ path: outputFile,
2684
+ fullPage: fullPageOption,
2685
+ type: 'png',
2686
+ }),
2687
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Screenshot timeout')), 5000)),
2688
+ ])
2689
+ } catch (err) {
2690
+ this.debug(`Failed to take screenshot: ${err.message}`)
2691
+
2692
+ this.hasCleanupError = true
2693
+ this.testFailures.push(`Screenshot failed: ${err.message}`)
2694
+
2695
+ if (err.message.includes('closed') || err.message.includes('Protocol error') || err.message.includes('timeout')) {
2696
+ this.debug('Screenshot failed due to browser/page closure or timeout, continuing...')
2697
+ return
2698
+ }
2699
+ throw err
2700
+ }
2321
2701
 
2322
- if (this.activeSessionName) {
2702
+ // Handle session screenshots for ALL sessions, not just active one
2703
+ if (this.sessionPages && Object.keys(this.sessionPages).length > 0) {
2323
2704
  for (const sessionName in this.sessionPages) {
2324
- const activeSessionPage = this.sessionPages[sessionName]
2705
+ const sessionPage = this.sessionPages[sessionName]
2325
2706
  outputFile = screenshotOutputFolder(`${sessionName}_${fileName}`)
2326
2707
 
2327
- this.debug(`${sessionName} - Screenshot is saving to ${outputFile}`)
2708
+ this.debugSection('Screenshot', `${sessionName} - ${relativeDir(outputFile)}`)
2328
2709
 
2329
- if (activeSessionPage) {
2330
- await activeSessionPage.screenshot({
2331
- path: outputFile,
2332
- fullPage: fullPageOption,
2333
- type: 'png',
2334
- })
2710
+ try {
2711
+ // Add timeout protection for session screenshots
2712
+ await Promise.race([
2713
+ (async () => {
2714
+ if (sessionPage && !sessionPage.isClosed()) {
2715
+ await sessionPage.screenshot({
2716
+ path: outputFile,
2717
+ fullPage: fullPageOption,
2718
+ type: 'png',
2719
+ })
2720
+ } else {
2721
+ this.debug(`Cannot take session screenshot: session page for '${sessionName}' is closed or undefined`)
2722
+ }
2723
+ })(),
2724
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Session screenshot timeout')), 3000)),
2725
+ ])
2726
+ } catch (err) {
2727
+ this.debug(`Failed to take session screenshot for '${sessionName}': ${err.message}`)
2728
+
2729
+ // Track session screenshot failures
2730
+ this.hasCleanupError = true
2731
+ this.testFailures.push(`Session screenshot failed for '${sessionName}': ${err.message}`)
2732
+
2733
+ // Don't throw here - main screenshot was successful and we don't want to hang
2734
+ // Just log and continue
2335
2735
  }
2336
2736
  }
2337
2737
  }
@@ -2358,9 +2758,7 @@ class Playwright extends Helper {
2358
2758
  method = method.toLowerCase()
2359
2759
  const allowedMethods = ['get', 'post', 'patch', 'head', 'fetch', 'delete']
2360
2760
  if (!allowedMethods.includes(method)) {
2361
- throw new Error(
2362
- `Method ${method} is not allowed, use the one from a list ${allowedMethods} or switch to using REST helper`,
2363
- )
2761
+ throw new Error(`Method ${method} is not allowed, use the one from a list ${allowedMethods} or switch to using REST helper`)
2364
2762
  }
2365
2763
 
2366
2764
  if (url.startsWith('/')) {
@@ -2397,10 +2795,7 @@ class Playwright extends Helper {
2397
2795
  if (this.options.recordVideo && this.page && this.page.video()) {
2398
2796
  test.artifacts.video = saveVideoForPage(this.page, `${test.title}.failed`)
2399
2797
  for (const sessionName in this.sessionPages) {
2400
- test.artifacts[`video_${sessionName}`] = saveVideoForPage(
2401
- this.sessionPages[sessionName],
2402
- `${test.title}_${sessionName}.failed`,
2403
- )
2798
+ test.artifacts[`video_${sessionName}`] = saveVideoForPage(this.sessionPages[sessionName], `${test.title}_${sessionName}.failed`)
2404
2799
  }
2405
2800
  }
2406
2801
 
@@ -2408,10 +2803,7 @@ class Playwright extends Helper {
2408
2803
  test.artifacts.trace = await saveTraceForContext(this.browserContext, `${test.title}.failed`)
2409
2804
  for (const sessionName in this.sessionPages) {
2410
2805
  if (!this.sessionPages[sessionName].context) continue
2411
- test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(
2412
- this.sessionPages[sessionName].context,
2413
- `${test.title}_${sessionName}.failed`,
2414
- )
2806
+ test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(this.sessionPages[sessionName].context, `${test.title}_${sessionName}.failed`)
2415
2807
  }
2416
2808
  }
2417
2809
 
@@ -2425,16 +2817,13 @@ class Playwright extends Helper {
2425
2817
  if (this.options.keepVideoForPassedTests) {
2426
2818
  test.artifacts.video = saveVideoForPage(this.page, `${test.title}.passed`)
2427
2819
  for (const sessionName of Object.keys(this.sessionPages)) {
2428
- test.artifacts[`video_${sessionName}`] = saveVideoForPage(
2429
- this.sessionPages[sessionName],
2430
- `${test.title}_${sessionName}.passed`,
2431
- )
2820
+ test.artifacts[`video_${sessionName}`] = saveVideoForPage(this.sessionPages[sessionName], `${test.title}_${sessionName}.passed`)
2432
2821
  }
2433
2822
  } else {
2434
2823
  this.page
2435
2824
  .video()
2436
2825
  .delete()
2437
- .catch((e) => {})
2826
+ .catch(e => {})
2438
2827
  }
2439
2828
  }
2440
2829
 
@@ -2444,10 +2833,7 @@ class Playwright extends Helper {
2444
2833
  test.artifacts.trace = await saveTraceForContext(this.browserContext, `${test.title}.passed`)
2445
2834
  for (const sessionName in this.sessionPages) {
2446
2835
  if (!this.sessionPages[sessionName].context) continue
2447
- test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(
2448
- this.sessionPages[sessionName].context,
2449
- `${test.title}_${sessionName}.passed`,
2450
- )
2836
+ test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(this.sessionPages[sessionName].context, `${test.title}_${sessionName}.passed`)
2451
2837
  }
2452
2838
  }
2453
2839
  } else {
@@ -2464,7 +2850,7 @@ class Playwright extends Helper {
2464
2850
  * {{> wait }}
2465
2851
  */
2466
2852
  async wait(sec) {
2467
- return new Promise((done) => {
2853
+ return new Promise(done => {
2468
2854
  setTimeout(done, sec * 1000)
2469
2855
  })
2470
2856
  }
@@ -2480,20 +2866,18 @@ class Playwright extends Helper {
2480
2866
  const context = await this._getContext()
2481
2867
  if (!locator.isXPath()) {
2482
2868
  const valueFn = function ([locator]) {
2483
- return Array.from(document.querySelectorAll(locator)).filter((el) => !el.disabled).length > 0
2869
+ return Array.from(document.querySelectorAll(locator)).filter(el => !el.disabled).length > 0
2484
2870
  }
2485
2871
  waiter = context.waitForFunction(valueFn, [locator.value], { timeout: waitTimeout })
2486
2872
  } else {
2487
2873
  const enabledFn = function ([locator, $XPath]) {
2488
- eval($XPath) // eslint-disable-line no-eval
2489
- return $XPath(null, locator).filter((el) => !el.disabled).length > 0
2874
+ eval($XPath)
2875
+ return $XPath(null, locator).filter(el => !el.disabled).length > 0
2490
2876
  }
2491
2877
  waiter = context.waitForFunction(enabledFn, [locator.value, $XPath.toString()], { timeout: waitTimeout })
2492
2878
  }
2493
- return waiter.catch((err) => {
2494
- throw new Error(
2495
- `element (${locator.toString()}) still not enabled after ${waitTimeout / 1000} sec\n${err.message}`,
2496
- )
2879
+ return waiter.catch(err => {
2880
+ throw new Error(`element (${locator.toString()}) still not enabled after ${waitTimeout / 1000} sec\n${err.message}`)
2497
2881
  })
2498
2882
  }
2499
2883
 
@@ -2508,20 +2892,18 @@ class Playwright extends Helper {
2508
2892
  const context = await this._getContext()
2509
2893
  if (!locator.isXPath()) {
2510
2894
  const valueFn = function ([locator]) {
2511
- return Array.from(document.querySelectorAll(locator)).filter((el) => el.disabled).length > 0
2895
+ return Array.from(document.querySelectorAll(locator)).filter(el => el.disabled).length > 0
2512
2896
  }
2513
2897
  waiter = context.waitForFunction(valueFn, [locator.value], { timeout: waitTimeout })
2514
2898
  } else {
2515
2899
  const disabledFn = function ([locator, $XPath]) {
2516
- eval($XPath) // eslint-disable-line no-eval
2517
- return $XPath(null, locator).filter((el) => el.disabled).length > 0
2900
+ eval($XPath)
2901
+ return $XPath(null, locator).filter(el => el.disabled).length > 0
2518
2902
  }
2519
2903
  waiter = context.waitForFunction(disabledFn, [locator.value, $XPath.toString()], { timeout: waitTimeout })
2520
2904
  }
2521
- return waiter.catch((err) => {
2522
- throw new Error(
2523
- `element (${locator.toString()}) is still enabled after ${waitTimeout / 1000} sec\n${err.message}`,
2524
- )
2905
+ return waiter.catch(err => {
2906
+ throw new Error(`element (${locator.toString()}) is still enabled after ${waitTimeout / 1000} sec\n${err.message}`)
2525
2907
  })
2526
2908
  }
2527
2909
 
@@ -2536,26 +2918,21 @@ class Playwright extends Helper {
2536
2918
  const context = await this._getContext()
2537
2919
  if (!locator.isXPath()) {
2538
2920
  const valueFn = function ([locator, value]) {
2539
- return (
2540
- Array.from(document.querySelectorAll(locator)).filter((el) => (el.value || '').indexOf(value) !== -1).length >
2541
- 0
2542
- )
2921
+ return Array.from(document.querySelectorAll(locator)).filter(el => (el.value || '').indexOf(value) !== -1).length > 0
2543
2922
  }
2544
2923
  waiter = context.waitForFunction(valueFn, [locator.value, value], { timeout: waitTimeout })
2545
2924
  } else {
2546
2925
  const valueFn = function ([locator, $XPath, value]) {
2547
- eval($XPath) // eslint-disable-line no-eval
2548
- return $XPath(null, locator).filter((el) => (el.value || '').indexOf(value) !== -1).length > 0
2926
+ eval($XPath)
2927
+ return $XPath(null, locator).filter(el => (el.value || '').indexOf(value) !== -1).length > 0
2549
2928
  }
2550
2929
  waiter = context.waitForFunction(valueFn, [locator.value, $XPath.toString(), value], {
2551
2930
  timeout: waitTimeout,
2552
2931
  })
2553
2932
  }
2554
- return waiter.catch((err) => {
2933
+ return waiter.catch(err => {
2555
2934
  const loc = locator.toString()
2556
- throw new Error(
2557
- `element (${loc}) is not in DOM or there is no element(${loc}) with value "${value}" after ${waitTimeout / 1000} sec\n${err.message}`,
2558
- )
2935
+ throw new Error(`element (${loc}) is not in DOM or there is no element(${loc}) with value "${value}" after ${waitTimeout / 1000} sec\n${err.message}`)
2559
2936
  })
2560
2937
  }
2561
2938
 
@@ -2575,22 +2952,20 @@ class Playwright extends Helper {
2575
2952
  if (!els || els.length === 0) {
2576
2953
  return false
2577
2954
  }
2578
- return Array.prototype.filter.call(els, (el) => el.offsetParent !== null).length === num
2955
+ return Array.prototype.filter.call(els, el => el.offsetParent !== null).length === num
2579
2956
  }
2580
2957
  waiter = context.waitForFunction(visibleFn, [locator.value, num], { timeout: waitTimeout })
2581
2958
  } else {
2582
2959
  const visibleFn = function ([locator, $XPath, num]) {
2583
- eval($XPath) // eslint-disable-line no-eval
2584
- return $XPath(null, locator).filter((el) => el.offsetParent !== null).length === num
2960
+ eval($XPath)
2961
+ return $XPath(null, locator).filter(el => el.offsetParent !== null).length === num
2585
2962
  }
2586
2963
  waiter = context.waitForFunction(visibleFn, [locator.value, $XPath.toString(), num], {
2587
2964
  timeout: waitTimeout,
2588
2965
  })
2589
2966
  }
2590
- return waiter.catch((err) => {
2591
- throw new Error(
2592
- `The number of elements (${locator.toString()}) is not ${num} after ${waitTimeout / 1000} sec\n${err.message}`,
2593
- )
2967
+ return waiter.catch(err => {
2968
+ throw new Error(`The number of elements (${locator.toString()}) is not ${num} after ${waitTimeout / 1000} sec\n${err.message}`)
2594
2969
  })
2595
2970
  }
2596
2971
 
@@ -2598,9 +2973,7 @@ class Playwright extends Helper {
2598
2973
  * {{> waitForClickable }}
2599
2974
  */
2600
2975
  async waitForClickable(locator, waitTimeout) {
2601
- console.log(
2602
- 'I.waitForClickable is DEPRECATED: This is no longer needed, Playwright automatically waits for element to be clickable',
2603
- )
2976
+ console.log('I.waitForClickable is DEPRECATED: This is no longer needed, Playwright automatically waits for element to be clickable')
2604
2977
  console.log('Remove usage of this function')
2605
2978
  }
2606
2979
 
@@ -2616,9 +2989,7 @@ class Playwright extends Helper {
2616
2989
  try {
2617
2990
  await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' })
2618
2991
  } catch (e) {
2619
- throw new Error(
2620
- `element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${e.message}`,
2621
- )
2992
+ throw new Error(`element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${e.message}`)
2622
2993
  }
2623
2994
  }
2624
2995
 
@@ -2710,10 +3081,8 @@ class Playwright extends Helper {
2710
3081
  .locator(buildLocatorString(locator))
2711
3082
  .first()
2712
3083
  .waitFor({ timeout: waitTimeout, state: 'hidden' })
2713
- .catch((err) => {
2714
- throw new Error(
2715
- `element (${locator.toString()}) still not hidden after ${waitTimeout / 1000} sec\n${err.message}`,
2716
- )
3084
+ .catch(err => {
3085
+ throw new Error(`element (${locator.toString()}) still not hidden after ${waitTimeout / 1000} sec\n${err.message}`)
2717
3086
  })
2718
3087
  }
2719
3088
 
@@ -2739,6 +3108,9 @@ class Playwright extends Helper {
2739
3108
  if ((this.context && this.context.constructor.name === 'FrameLocator') || this.context) {
2740
3109
  return this.context
2741
3110
  }
3111
+ if (this.frame) {
3112
+ return this.frame
3113
+ }
2742
3114
  return this.page
2743
3115
  }
2744
3116
 
@@ -2750,14 +3122,14 @@ class Playwright extends Helper {
2750
3122
 
2751
3123
  return this.page
2752
3124
  .waitForFunction(
2753
- (urlPart) => {
3125
+ urlPart => {
2754
3126
  const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
2755
3127
  return currUrl.indexOf(urlPart) > -1
2756
3128
  },
2757
3129
  urlPart,
2758
3130
  { timeout: waitTimeout },
2759
3131
  )
2760
- .catch(async (e) => {
3132
+ .catch(async e => {
2761
3133
  const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
2762
3134
  if (/Timeout/i.test(e.message)) {
2763
3135
  throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
@@ -2780,14 +3152,14 @@ class Playwright extends Helper {
2780
3152
 
2781
3153
  return this.page
2782
3154
  .waitForFunction(
2783
- (urlPart) => {
3155
+ urlPart => {
2784
3156
  const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
2785
3157
  return currUrl.indexOf(urlPart) > -1
2786
3158
  },
2787
3159
  urlPart,
2788
3160
  { timeout: waitTimeout },
2789
3161
  )
2790
- .catch(async (e) => {
3162
+ .catch(async e => {
2791
3163
  const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
2792
3164
  if (/Timeout/i.test(e.message)) {
2793
3165
  throw new Error(`expected url to be ${urlPart}, but found ${currUrl}`)
@@ -2803,28 +3175,23 @@ class Playwright extends Helper {
2803
3175
  async waitForText(text, sec = null, context = null) {
2804
3176
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
2805
3177
  const errorMessage = `Text "${text}" was not found on page after ${waitTimeout / 1000} sec.`
2806
- let waiter
2807
3178
 
2808
3179
  const contextObject = await this._getContext()
2809
3180
 
2810
3181
  if (context) {
2811
3182
  const locator = new Locator(context, 'css')
2812
- if (!locator.isXPath()) {
2813
- try {
2814
- await contextObject
3183
+ try {
3184
+ if (!locator.isXPath()) {
3185
+ return contextObject
2815
3186
  .locator(`${locator.isCustom() ? `${locator.type}=${locator.value}` : locator.simplify()} >> text=${text}`)
2816
3187
  .first()
2817
3188
  .waitFor({ timeout: waitTimeout, state: 'visible' })
2818
- } catch (e) {
2819
- throw new Error(`${errorMessage}\n${e.message}`)
2820
3189
  }
2821
- }
2822
3190
 
2823
- if (locator.isXPath()) {
2824
- try {
2825
- await contextObject.waitForFunction(
3191
+ if (locator.isXPath()) {
3192
+ return contextObject.waitForFunction(
2826
3193
  ([locator, text, $XPath]) => {
2827
- eval($XPath) // eslint-disable-line no-eval
3194
+ eval($XPath)
2828
3195
  const el = $XPath(null, locator)
2829
3196
  if (!el.length) return false
2830
3197
  return el[0].innerText.indexOf(text) > -1
@@ -2832,27 +3199,34 @@ class Playwright extends Helper {
2832
3199
  [locator.value, text, $XPath.toString()],
2833
3200
  { timeout: waitTimeout },
2834
3201
  )
2835
- } catch (e) {
2836
- throw new Error(`${errorMessage}\n${e.message}`)
2837
3202
  }
3203
+ } catch (e) {
3204
+ throw new Error(`${errorMessage}\n${e.message}`)
2838
3205
  }
2839
- } else {
2840
- // we have this as https://github.com/microsoft/playwright/issues/26829 is not yet implemented
2841
-
2842
- const _contextObject = this.frame ? this.frame : contextObject
2843
- let count = 0
2844
- do {
2845
- waiter = await _contextObject
2846
- .locator(`:has-text(${JSON.stringify(text)})`)
2847
- .first()
2848
- .isVisible()
2849
- if (waiter) break
2850
- await this.wait(1)
2851
- count += 1000
2852
- } while (count <= waitTimeout)
2853
-
2854
- if (!waiter) throw new Error(`${errorMessage}`)
2855
3206
  }
3207
+
3208
+ const timeoutGap = waitTimeout + 1000
3209
+
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
+ 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)
3226
+ },
3227
+ { retries: 1000, minTimeout: 500, maxTimeout: 500, factor: 1 },
3228
+ ),
3229
+ ])
2856
3230
  }
2857
3231
 
2858
3232
  /**
@@ -2902,8 +3276,13 @@ class Playwright extends Helper {
2902
3276
  }
2903
3277
 
2904
3278
  if (locator >= 0 && locator < childFrames.length) {
2905
- this.context = await this.page.frameLocator('iframe').nth(locator)
2906
- this.contextLocator = locator
3279
+ try {
3280
+ this.context = await Promise.race([this.page.frameLocator('iframe').nth(locator), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame locator timeout')), 5000))])
3281
+ this.contextLocator = locator
3282
+ } catch (e) {
3283
+ console.warn('Warning during frame selection:', e.message)
3284
+ throw new Error('Element #invalidIframeSelector was not found by text|CSS|XPath')
3285
+ }
2907
3286
  } else {
2908
3287
  throw new Error('Element #invalidIframeSelector was not found by text|CSS|XPath')
2909
3288
  }
@@ -2919,16 +3298,25 @@ class Playwright extends Helper {
2919
3298
 
2920
3299
  // iframe by selector
2921
3300
  locator = buildLocatorString(new Locator(locator, 'css'))
2922
- const frame = await this._locateElement(locator)
3301
+
3302
+ let frame
3303
+ try {
3304
+ frame = await Promise.race([this._locateElement(locator), new Promise((_, reject) => setTimeout(() => reject(new Error('Locate frame timeout')), 5000))])
3305
+ } catch (e) {
3306
+ console.warn('Warning during frame location:', e.message)
3307
+ frame = null
3308
+ }
2923
3309
 
2924
3310
  if (!frame) {
2925
3311
  throw new Error(`Frame ${JSON.stringify(locator)} was not found by text|CSS|XPath`)
2926
3312
  }
2927
3313
 
2928
- if (this.frame) {
2929
- this.frame = await this.frame.frameLocator(locator)
2930
- } else {
2931
- this.frame = await this.page.frameLocator(locator)
3314
+ try {
3315
+ // Always create frame locator from page to avoid nested frame paths
3316
+ this.frame = await Promise.race([this.page.frameLocator(locator), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame locator timeout')), 5000))])
3317
+ } catch (e) {
3318
+ console.warn('Warning during frame locator creation:', e.message)
3319
+ throw new Error(`Frame ${JSON.stringify(locator)} could not be accessed`)
2932
3320
  }
2933
3321
 
2934
3322
  const contentFrame = this.frame
@@ -2937,8 +3325,14 @@ class Playwright extends Helper {
2937
3325
  this.context = contentFrame
2938
3326
  this.contextLocator = null
2939
3327
  } else {
2940
- this.context = this.page.frame(this.page.frames()[1].name())
2941
- this.contextLocator = locator
3328
+ try {
3329
+ this.context = this.page.frame(this.page.frames()[1].name())
3330
+ this.contextLocator = locator
3331
+ } catch (e) {
3332
+ console.warn('Warning during frame context setup:', e.message)
3333
+ this.context = this.page
3334
+ this.contextLocator = null
3335
+ }
2942
3336
  }
2943
3337
  }
2944
3338
 
@@ -3021,11 +3415,11 @@ class Playwright extends Helper {
3021
3415
  }
3022
3416
  } else {
3023
3417
  const visibleFn = function ([locator, $XPath]) {
3024
- eval($XPath) // eslint-disable-line no-eval
3418
+ eval($XPath)
3025
3419
  return $XPath(null, locator).length === 0
3026
3420
  }
3027
3421
  waiter = context.waitForFunction(visibleFn, [locator.value, $XPath.toString()], { timeout: waitTimeout })
3028
- return waiter.catch((err) => {
3422
+ return waiter.catch(err => {
3029
3423
  throw new Error(`element (${locator.toString()}) still on page after ${waitTimeout / 1000} sec\n${err.message}`)
3030
3424
  })
3031
3425
  }
@@ -3047,9 +3441,9 @@ class Playwright extends Helper {
3047
3441
 
3048
3442
  return promiseRetry(
3049
3443
  async (retry, number) => {
3050
- const _grabCookie = async (name) => {
3444
+ const _grabCookie = async name => {
3051
3445
  const cookies = await this.browserContext.cookies()
3052
- const cookie = cookies.filter((c) => c.name === name)
3446
+ const cookie = cookies.filter(c => c.name === name)
3053
3447
  if (cookie.length === 0) throw Error(`Cookie ${name} is not found after ${retries}s`)
3054
3448
  }
3055
3449
 
@@ -3128,7 +3522,7 @@ class Playwright extends Helper {
3128
3522
  this.recording = true
3129
3523
  this.recordedAtLeastOnce = true
3130
3524
 
3131
- this.page.on('requestfinished', async (request) => {
3525
+ this.page.on('requestfinished', async request => {
3132
3526
  const information = {
3133
3527
  url: request.url(),
3134
3528
  method: request.method(),
@@ -3167,20 +3561,20 @@ class Playwright extends Helper {
3167
3561
  */
3168
3562
  blockTraffic(urls) {
3169
3563
  if (Array.isArray(urls)) {
3170
- urls.forEach((url) => {
3171
- this.page.route(url, (route) => {
3564
+ urls.forEach(url => {
3565
+ this.page.route(url, route => {
3172
3566
  route
3173
3567
  .abort()
3174
3568
  // Sometimes it happens that browser has been closed in the meantime. It is ok to ignore error then.
3175
- .catch((e) => {})
3569
+ .catch(e => {})
3176
3570
  })
3177
3571
  })
3178
3572
  } else {
3179
- this.page.route(urls, (route) => {
3573
+ this.page.route(urls, route => {
3180
3574
  route
3181
3575
  .abort()
3182
3576
  // Sometimes it happens that browser has been closed in the meantime. It is ok to ignore error then.
3183
- .catch((e) => {})
3577
+ .catch(e => {})
3184
3578
  })
3185
3579
  }
3186
3580
  }
@@ -3209,8 +3603,8 @@ class Playwright extends Helper {
3209
3603
  urls = [urls]
3210
3604
  }
3211
3605
 
3212
- urls.forEach((url) => {
3213
- this.page.route(url, (route) => {
3606
+ urls.forEach(url => {
3607
+ this.page.route(url, route => {
3214
3608
  if (this.page.isClosed()) {
3215
3609
  // Sometimes it happens that browser has been closed in the meantime.
3216
3610
  // In this case we just don't fulfill to prevent error in test scenario.
@@ -3256,13 +3650,10 @@ class Playwright extends Helper {
3256
3650
  */
3257
3651
  grabTrafficUrl(urlMatch) {
3258
3652
  if (!this.recordedAtLeastOnce) {
3259
- throw new Error(
3260
- 'Failure in test automation. You use "I.grabTrafficUrl", but "I.startRecordingTraffic" was never called before.',
3261
- )
3653
+ throw new Error('Failure in test automation. You use "I.grabTrafficUrl", but "I.startRecordingTraffic" was never called before.')
3262
3654
  }
3263
3655
 
3264
3656
  for (const i in this.requests) {
3265
- // eslint-disable-next-line no-prototype-builtins
3266
3657
  if (this.requests.hasOwnProperty(i)) {
3267
3658
  const request = this.requests[i]
3268
3659
 
@@ -3312,15 +3703,15 @@ class Playwright extends Helper {
3312
3703
  await this.cdpSession.send('Network.enable')
3313
3704
  await this.cdpSession.send('Page.enable')
3314
3705
 
3315
- this.cdpSession.on('Network.webSocketFrameReceived', (payload) => {
3706
+ this.cdpSession.on('Network.webSocketFrameReceived', payload => {
3316
3707
  this._logWebsocketMessages(this._getWebSocketLog('RECEIVED', payload))
3317
3708
  })
3318
3709
 
3319
- this.cdpSession.on('Network.webSocketFrameSent', (payload) => {
3710
+ this.cdpSession.on('Network.webSocketFrameSent', payload => {
3320
3711
  this._logWebsocketMessages(this._getWebSocketLog('SENT', payload))
3321
3712
  })
3322
3713
 
3323
- this.cdpSession.on('Network.webSocketFrameError', (payload) => {
3714
+ this.cdpSession.on('Network.webSocketFrameError', payload => {
3324
3715
  this._logWebsocketMessages(this._getWebSocketLog('ERROR', payload))
3325
3716
  })
3326
3717
  }
@@ -3344,9 +3735,7 @@ class Playwright extends Helper {
3344
3735
  grabWebSocketMessages() {
3345
3736
  if (!this.recordingWebSocketMessages) {
3346
3737
  if (!this.recordedWebSocketMessagesAtLeastOnce) {
3347
- throw new Error(
3348
- 'Failure in test automation. You use "I.grabWebSocketMessages", but "I.startRecordingWebSocketMessages" was never called before.',
3349
- )
3738
+ throw new Error('Failure in test automation. You use "I.grabWebSocketMessages", but "I.startRecordingWebSocketMessages" was never called before.')
3350
3739
  }
3351
3740
  }
3352
3741
  return this.webSocketMessages
@@ -3441,48 +3830,7 @@ class Playwright extends Helper {
3441
3830
  }
3442
3831
  }
3443
3832
 
3444
- module.exports = Playwright
3445
-
3446
- function buildLocatorString(locator) {
3447
- if (locator.isCustom()) {
3448
- return `${locator.type}=${locator.value}`
3449
- }
3450
- if (locator.isXPath()) {
3451
- return `xpath=${locator.value}`
3452
- }
3453
- return locator.simplify()
3454
- }
3455
-
3456
- async function findElements(matcher, locator) {
3457
- if (locator.react) return findReact(matcher, locator)
3458
- if (locator.vue) return findVue(matcher, locator)
3459
- if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
3460
- locator = new Locator(locator, 'css')
3461
-
3462
- return matcher.locator(buildLocatorString(locator)).all()
3463
- }
3464
-
3465
- async function findElement(matcher, locator) {
3466
- if (locator.react) return findReact(matcher, locator)
3467
- if (locator.vue) return findVue(matcher, locator)
3468
- if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
3469
- locator = new Locator(locator, 'css')
3470
-
3471
- return matcher.locator(buildLocatorString(locator)).first()
3472
- }
3473
-
3474
- async function getVisibleElements(elements) {
3475
- const visibleElements = []
3476
- for (const element of elements) {
3477
- if (await element.isVisible()) {
3478
- visibleElements.push(element)
3479
- }
3480
- }
3481
- if (visibleElements.length === 0) {
3482
- return elements
3483
- }
3484
- return visibleElements
3485
- }
3833
+ export default Playwright
3486
3834
 
3487
3835
  async function proceedClick(locator, context = null, options = {}) {
3488
3836
  let matcher = await this._getContext()
@@ -3493,17 +3841,13 @@ async function proceedClick(locator, context = null, options = {}) {
3493
3841
  }
3494
3842
  const els = await findClickable.call(this, matcher, locator)
3495
3843
  if (context) {
3496
- assertElementExists(
3497
- els,
3498
- locator,
3499
- 'Clickable element',
3500
- `was not found inside element ${new Locator(context).toString()}`,
3501
- )
3844
+ assertElementExists(els, locator, 'Clickable element', `was not found inside element ${new Locator(context).toString()}`)
3502
3845
  } else {
3503
3846
  assertElementExists(els, locator, 'Clickable element')
3504
3847
  }
3505
3848
 
3506
3849
  await highlightActiveElement.call(this, els[0])
3850
+ if (store.debugMode) this.debugSection('Clicked', await elToString(els[0], 1))
3507
3851
 
3508
3852
  /*
3509
3853
  using the force true options itself but instead dispatching a click
@@ -3524,15 +3868,26 @@ async function proceedClick(locator, context = null, options = {}) {
3524
3868
  }
3525
3869
 
3526
3870
  async function findClickable(matcher, locator) {
3527
- if (locator.react) return findReact(matcher, locator)
3528
- if (locator.vue) return findVue(matcher, locator)
3529
- if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
3871
+ const matchedLocator = new Locator(locator)
3530
3872
 
3531
- locator = new Locator(locator)
3532
- if (!locator.isFuzzy()) return findElements.call(this, matcher, locator)
3873
+ if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
3533
3874
 
3534
3875
  let els
3535
- const literal = xpathLocator.literal(locator.value)
3876
+ const literal = xpathLocator.literal(matchedLocator.value)
3877
+
3878
+ try {
3879
+ els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
3880
+ if (els.length) return els
3881
+ } catch (err) {
3882
+ // getByRole not supported or failed
3883
+ }
3884
+
3885
+ try {
3886
+ els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
3887
+ if (els.length) return els
3888
+ } catch (err) {
3889
+ // getByRole not supported or failed
3890
+ }
3536
3891
 
3537
3892
  els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
3538
3893
  if (els.length) return els
@@ -3547,7 +3902,7 @@ async function findClickable(matcher, locator) {
3547
3902
  // Do nothing
3548
3903
  }
3549
3904
 
3550
- return findElements.call(this, matcher, locator.value) // by css or xpath
3905
+ return findElements.call(this, matcher, matchedLocator.value) // by css or xpath
3551
3906
  }
3552
3907
 
3553
3908
  async function proceedSee(assertType, text, context, strict = false) {
@@ -3565,16 +3920,18 @@ async function proceedSee(assertType, text, context, strict = false) {
3565
3920
  description = `element ${locator.toString()}`
3566
3921
  const els = await this._locate(locator)
3567
3922
  assertElementExists(els, locator.toString())
3568
- allText = await Promise.all(els.map((el) => el.innerText()))
3923
+ allText = await Promise.all(els.map(el => el.innerText()))
3924
+ }
3925
+
3926
+ if (store?.currentStep?.opts?.ignoreCase === true) {
3927
+ text = text.toLowerCase()
3928
+ allText = allText.map(elText => elText.toLowerCase())
3569
3929
  }
3570
3930
 
3571
3931
  if (strict) {
3572
- return allText.map((elText) => equals(description)[assertType](text, elText))
3932
+ return allText.map(elText => equals(description)[assertType](text, elText))
3573
3933
  }
3574
- return stringIncludes(description)[assertType](
3575
- normalizeSpacesInString(text),
3576
- normalizeSpacesInString(allText.join(' | ')),
3577
- )
3934
+ return stringIncludes(description)[assertType](normalizeSpacesInString(text), normalizeSpacesInString(allText.join(' | ')))
3578
3935
  }
3579
3936
 
3580
3937
  async function findCheckable(locator, context) {
@@ -3586,10 +3943,10 @@ async function findCheckable(locator, context) {
3586
3943
 
3587
3944
  const matchedLocator = new Locator(locator)
3588
3945
  if (!matchedLocator.isFuzzy()) {
3589
- return findElements.call(this, contextEl, matchedLocator.simplify())
3946
+ return findElements.call(this, contextEl, matchedLocator)
3590
3947
  }
3591
3948
 
3592
- const literal = xpathLocator.literal(locator)
3949
+ const literal = xpathLocator.literal(matchedLocator.value)
3593
3950
  let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
3594
3951
  if (els.length) {
3595
3952
  return els
@@ -3598,13 +3955,13 @@ async function findCheckable(locator, context) {
3598
3955
  if (els.length) {
3599
3956
  return els
3600
3957
  }
3601
- return findElements.call(this, contextEl, locator)
3958
+ return findElements.call(this, contextEl, matchedLocator.value)
3602
3959
  }
3603
3960
 
3604
3961
  async function proceedIsChecked(assertType, option) {
3605
3962
  let els = await findCheckable.call(this, option)
3606
3963
  assertElementExists(els, option, 'Checkable')
3607
- els = await Promise.all(els.map((el) => el.isChecked()))
3964
+ els = await Promise.all(els.map(el => el.isChecked()))
3608
3965
  const selected = els.reduce((prev, cur) => prev || cur)
3609
3966
  return truth(`checkable ${option}`, 'to be checked')[assertType](selected)
3610
3967
  }
@@ -3636,10 +3993,10 @@ async function proceedSeeInField(assertType, field, value) {
3636
3993
  const els = await findFields.call(this, field)
3637
3994
  assertElementExists(els, field, 'Field')
3638
3995
  const el = els[0]
3639
- const tag = await el.evaluate((e) => e.tagName)
3996
+ const tag = await el.evaluate(e => e.tagName)
3640
3997
  const fieldType = await el.getAttribute('type')
3641
3998
 
3642
- const proceedMultiple = async (elements) => {
3999
+ const proceedMultiple = async elements => {
3643
4000
  const fields = Array.isArray(elements) ? elements : [elements]
3644
4001
 
3645
4002
  const elementValues = []
@@ -3653,7 +4010,7 @@ async function proceedSeeInField(assertType, field, value) {
3653
4010
  if (assertType === 'assert') {
3654
4011
  equals(`select option by ${field}`)[assertType](true, elementValues.length > 0)
3655
4012
  }
3656
- elementValues.forEach((val) => stringIncludes(`fields by ${field}`)[assertType](value, val))
4013
+ elementValues.forEach(val => stringIncludes(`fields by ${field}`)[assertType](value, val))
3657
4014
  }
3658
4015
  }
3659
4016
 
@@ -3740,6 +4097,8 @@ function isFrameLocator(locator) {
3740
4097
  }
3741
4098
 
3742
4099
  function assertElementExists(res, locator, prefix, suffix) {
4100
+ // if element text is an empty string, just exit this check
4101
+ if (typeof res === 'string' && res === '') return
3743
4102
  if (!res || res.length === 0) {
3744
4103
  throw new ElementNotFound(locator, prefix, suffix)
3745
4104
  }
@@ -3776,12 +4135,9 @@ async function targetCreatedHandler(page) {
3776
4135
  this.contextLocator = null
3777
4136
  })
3778
4137
  })
3779
- page.on('console', (msg) => {
4138
+ page.on('console', msg => {
3780
4139
  if (!consoleLogStore.includes(msg) && this.options.ignoreLog && !this.options.ignoreLog.includes(msg.type())) {
3781
- this.debugSection(
3782
- `Browser:${ucfirst(msg.type())}`,
3783
- ((msg.text && msg.text()) || msg._text || '') + msg.args().join(' '),
3784
- )
4140
+ this.debugSection(`Browser:${ucfirst(msg.type())}`, ((msg.text && msg.text()) || msg._text || '') + msg.args().join(' '))
3785
4141
  }
3786
4142
  consoleLogStore.add(msg)
3787
4143
  })
@@ -3884,36 +4240,67 @@ async function clickablePoint(el) {
3884
4240
  }
3885
4241
 
3886
4242
  async function refreshContextSession() {
3887
- // close other sessions
4243
+ // close other sessions with timeout protection, but preserve active session contexts
3888
4244
  try {
3889
- const contexts = await this.browser.contexts()
3890
- contexts.shift()
4245
+ const contexts = await Promise.race([this.browser.contexts(), new Promise((_, reject) => setTimeout(() => reject(new Error('Get contexts timeout')), 3000))])
4246
+
4247
+ // Keep the first context (default) and any contexts that belong to active sessions
4248
+ const defaultContext = contexts.shift()
4249
+ const activeSessionContexts = new Set()
4250
+
4251
+ // Identify contexts that are still in use by active sessions
4252
+ if (this.sessionPages) {
4253
+ for (const sessionName in this.sessionPages) {
4254
+ const sessionPage = this.sessionPages[sessionName]
4255
+ if (sessionPage && sessionPage.context) {
4256
+ activeSessionContexts.add(sessionPage.context)
4257
+ }
4258
+ }
4259
+ }
4260
+
4261
+ // Only close contexts that are not in use by active sessions
4262
+ const contextsToClose = contexts.filter(context => !activeSessionContexts.has(context))
3891
4263
 
3892
- await Promise.all(contexts.map((c) => c.close()))
4264
+ if (contextsToClose.length > 0) {
4265
+ await Promise.race([Promise.all(contextsToClose.map(c => c.close())), new Promise((_, reject) => setTimeout(() => reject(new Error('Close contexts timeout')), 5000))])
4266
+ }
3893
4267
  } catch (e) {
3894
- console.log(e)
4268
+ console.warn('Warning during context cleanup:', e.message)
3895
4269
  }
3896
4270
 
3897
4271
  if (this.page) {
3898
- const existingPages = await this.browserContext.pages()
3899
- await this._setPage(existingPages[0])
4272
+ try {
4273
+ const existingPages = await this.browserContext.pages()
4274
+ await this._setPage(existingPages[0])
4275
+ } catch (e) {
4276
+ console.warn('Warning during page setup:', e.message)
4277
+ }
3900
4278
  }
3901
4279
 
3902
4280
  if (this.options.keepBrowserState) return
3903
4281
 
3904
4282
  if (!this.options.keepCookies) {
3905
4283
  this.debugSection('Session', 'cleaning cookies and localStorage')
3906
- await this.clearCookie()
4284
+ try {
4285
+ await this.clearCookie()
4286
+ } catch (e) {
4287
+ console.warn('Warning during cookie cleanup:', e.message)
4288
+ }
3907
4289
  }
3908
- const currentUrl = await this.grabCurrentUrl()
3909
4290
 
3910
- if (currentUrl.startsWith('http')) {
3911
- await this.executeScript('localStorage.clear();').catch((err) => {
3912
- if (!(err.message.indexOf("Storage is disabled inside 'data:' URLs.") > -1)) throw err
3913
- })
3914
- await this.executeScript('sessionStorage.clear();').catch((err) => {
3915
- if (!(err.message.indexOf("Storage is disabled inside 'data:' URLs.") > -1)) throw err
3916
- })
4291
+ try {
4292
+ const currentUrl = await this.grabCurrentUrl()
4293
+
4294
+ if (currentUrl.startsWith('http')) {
4295
+ await this.executeScript('localStorage.clear();').catch(err => {
4296
+ if (!(err.message.indexOf("Storage is disabled inside 'data:' URLs.") > -1)) throw err
4297
+ })
4298
+ await this.executeScript('sessionStorage.clear();').catch(err => {
4299
+ if (!(err.message.indexOf("Storage is disabled inside 'data:' URLs.") > -1)) throw err
4300
+ })
4301
+ }
4302
+ } catch (e) {
4303
+ console.warn('Warning during storage cleanup:', e.message)
3917
4304
  }
3918
4305
  }
3919
4306
 
@@ -3941,11 +4328,22 @@ async function saveTraceForContext(context, name) {
3941
4328
  }
3942
4329
 
3943
4330
  async function highlightActiveElement(element) {
3944
- if (this.options.highlightElement && global.debugMode) {
3945
- await element.evaluate((el) => {
4331
+ if ((this.options.highlightElement || store.onPause) && store.debugMode) {
4332
+ await element.evaluate(el => {
3946
4333
  const prevStyle = el.style.boxShadow
3947
- el.style.boxShadow = '0px 0px 4px 3px rgba(255, 0, 0, 0.7)'
4334
+ el.style.boxShadow = '0px 0px 4px 3px rgba(147, 51, 234, 0.8)' // Bright purple that works on both dark/light modes
3948
4335
  setTimeout(() => (el.style.boxShadow = prevStyle), 2000)
3949
4336
  })
3950
4337
  }
3951
4338
  }
4339
+
4340
+ async function elToString(el, numberOfElements) {
4341
+ const html = await el.evaluate(node => node.outerHTML)
4342
+ return (
4343
+ html
4344
+ .replace(/\n/g, '')
4345
+ .replace(/\s+/g, ' ')
4346
+ .substring(0, 100 / numberOfElements)
4347
+ .trim() + '...'
4348
+ )
4349
+ }