codeceptjs 4.0.1-beta.9 → 4.0.1

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 (328) hide show
  1. package/README.md +39 -28
  2. package/bin/codecept.js +17 -4
  3. package/bin/codeceptq.js +49 -0
  4. package/bin/mcp-server.js +1189 -0
  5. package/docs/advanced.md +201 -0
  6. package/docs/agents.md +181 -0
  7. package/docs/ai.md +489 -0
  8. package/docs/aitrace.md +266 -0
  9. package/docs/api.md +332 -0
  10. package/docs/architecture.md +235 -0
  11. package/docs/assertions.md +415 -0
  12. package/docs/auth.md +318 -0
  13. package/docs/basics.md +424 -0
  14. package/docs/bdd.md +539 -0
  15. package/docs/best.md +240 -0
  16. package/docs/bootstrap.md +132 -0
  17. package/docs/commands.md +352 -0
  18. package/docs/community-helpers.md +63 -0
  19. package/docs/configuration.md +185 -0
  20. package/docs/continuous-integration.md +431 -0
  21. package/docs/custom-helpers.md +297 -0
  22. package/docs/data.md +448 -0
  23. package/docs/debugging.md +332 -0
  24. package/docs/detox.md +235 -0
  25. package/docs/docker.md +107 -0
  26. package/docs/effects.md +179 -0
  27. package/docs/element-based-testing.md +295 -0
  28. package/docs/element-selection.md +125 -0
  29. package/docs/els.md +328 -0
  30. package/docs/environment-variables.md +131 -0
  31. package/docs/examples.md +160 -0
  32. package/docs/heal.md +213 -0
  33. package/docs/helpers/ApiDataFactory.md +267 -0
  34. package/docs/helpers/Appium.md +1419 -0
  35. package/docs/helpers/Detox.md +665 -0
  36. package/docs/helpers/ExpectHelper.md +275 -0
  37. package/docs/helpers/FileSystem.md +152 -0
  38. package/docs/helpers/GraphQL.md +152 -0
  39. package/docs/helpers/GraphQLDataFactory.md +226 -0
  40. package/docs/helpers/JSONResponse.md +255 -0
  41. package/docs/helpers/MockRequest.md +377 -0
  42. package/docs/helpers/Playwright.md +2970 -0
  43. package/docs/helpers/Puppeteer-firefox.md +86 -0
  44. package/docs/helpers/Puppeteer.md +2583 -0
  45. package/docs/helpers/REST.md +289 -0
  46. package/docs/helpers/WebDriver.md +2639 -0
  47. package/docs/hooks.md +148 -0
  48. package/docs/index.md +111 -0
  49. package/docs/installation.md +121 -0
  50. package/docs/internal-test-server.md +89 -0
  51. package/docs/locators.md +355 -0
  52. package/docs/mcp.md +485 -0
  53. package/docs/migrate-from-cypress.md +98 -0
  54. package/docs/migrate-from-java.md +108 -0
  55. package/docs/migrate-from-protractor.md +101 -0
  56. package/docs/migrate-from-testcafe.md +99 -0
  57. package/docs/migration-4.md +745 -0
  58. package/docs/mobile.md +338 -0
  59. package/docs/pageobjects.md +399 -0
  60. package/docs/parallel.md +187 -0
  61. package/docs/playwright.md +714 -0
  62. package/docs/plugins/aiTrace.md +49 -0
  63. package/docs/plugins/analyze.md +66 -0
  64. package/docs/plugins/auth.md +241 -0
  65. package/docs/plugins/autoDelay.md +48 -0
  66. package/docs/plugins/browser.md +41 -0
  67. package/docs/plugins/coverage.md +39 -0
  68. package/docs/plugins/customLocator.md +119 -0
  69. package/docs/plugins/customReporter.md +16 -0
  70. package/docs/plugins/expose.md +75 -0
  71. package/docs/plugins/heal.md +44 -0
  72. package/docs/plugins/junitReporter.md +51 -0
  73. package/docs/plugins/pageInfo.md +34 -0
  74. package/docs/plugins/pause.md +43 -0
  75. package/docs/plugins/pauseOnFail.md +18 -0
  76. package/docs/plugins/retryFailedStep.md +75 -0
  77. package/docs/plugins/screencast.md +55 -0
  78. package/docs/plugins/screenshot.md +58 -0
  79. package/docs/plugins/screenshotOnFail.md +18 -0
  80. package/docs/plugins/stepTimeout.md +65 -0
  81. package/docs/plugins.md +87 -0
  82. package/docs/puppeteer.md +314 -0
  83. package/docs/quickstart.md +120 -0
  84. package/docs/reports.md +195 -0
  85. package/docs/retry.md +311 -0
  86. package/docs/secrets.md +150 -0
  87. package/docs/sessions.md +80 -0
  88. package/docs/shadow.md +68 -0
  89. package/docs/store.md +94 -0
  90. package/docs/test-structure.md +275 -0
  91. package/docs/timeouts.md +183 -0
  92. package/docs/translation.md +247 -0
  93. package/docs/tutorial.md +323 -0
  94. package/docs/typescript.md +159 -0
  95. package/docs/web-element.md +251 -0
  96. package/docs/webdriver.md +641 -0
  97. package/docs/within.md +55 -0
  98. package/lib/actor.js +1 -36
  99. package/lib/ai.js +3 -2
  100. package/lib/aria.js +260 -0
  101. package/lib/assertions.js +18 -0
  102. package/lib/codecept.js +34 -25
  103. package/lib/command/check.js +2 -1
  104. package/lib/command/definitions.js +14 -10
  105. package/lib/command/dryRun.js +24 -5
  106. package/lib/command/generate.js +3 -1
  107. package/lib/command/gherkin/snippets.js +5 -4
  108. package/lib/command/init.js +249 -270
  109. package/lib/command/list.js +150 -10
  110. package/lib/command/query.js +218 -0
  111. package/lib/command/run-multiple.js +3 -2
  112. package/lib/command/run-workers.js +14 -16
  113. package/lib/command/run.js +3 -17
  114. package/lib/command/utils.js +14 -0
  115. package/lib/command/workers/runTests.js +117 -9
  116. package/lib/config.js +98 -19
  117. package/lib/container.js +188 -19
  118. package/lib/effects.js +17 -0
  119. package/lib/element/WebElement.js +246 -2
  120. package/lib/els.js +12 -6
  121. package/lib/globals.js +32 -19
  122. package/lib/heal.js +7 -4
  123. package/lib/helper/ApiDataFactory.js +2 -1
  124. package/lib/helper/Appium.js +8 -8
  125. package/lib/helper/FileSystem.js +3 -2
  126. package/lib/helper/GraphQLDataFactory.js +2 -1
  127. package/lib/helper/Playwright.js +367 -516
  128. package/lib/helper/Puppeteer.js +343 -197
  129. package/lib/helper/WebDriver.js +324 -111
  130. package/lib/helper/errors/ElementNotFound.js +5 -2
  131. package/lib/helper/errors/MultipleElementsFound.js +52 -0
  132. package/lib/helper/errors/NonFocusedType.js +8 -0
  133. package/lib/helper/extras/Download.js +45 -0
  134. package/lib/helper/extras/PlaywrightLocator.js +7 -107
  135. package/lib/helper/extras/elementSelection.js +58 -0
  136. package/lib/helper/extras/focusCheck.js +43 -0
  137. package/lib/helper/extras/richTextEditor.js +178 -0
  138. package/lib/helper/scripts/dropFile.js +11 -0
  139. package/lib/history.js +3 -2
  140. package/lib/html.js +103 -16
  141. package/lib/index.js +9 -1
  142. package/lib/listener/config.js +6 -4
  143. package/lib/listener/emptyRun.js +2 -1
  144. package/lib/listener/globalRetry.js +32 -6
  145. package/lib/listener/helpers.js +6 -15
  146. package/lib/listener/mocha.js +2 -1
  147. package/lib/listener/pageobjects.js +43 -0
  148. package/lib/listener/result.js +3 -2
  149. package/lib/locator.js +158 -16
  150. package/lib/mocha/cli.js +19 -1
  151. package/lib/mocha/factory.js +13 -28
  152. package/lib/mocha/inject.js +1 -1
  153. package/lib/mocha/scenarioConfig.js +2 -1
  154. package/lib/mocha/test.js +4 -2
  155. package/lib/mocha/ui.js +5 -6
  156. package/lib/output.js +2 -2
  157. package/lib/parser.js +2 -2
  158. package/lib/pause.js +38 -4
  159. package/lib/plugin/aiTrace.js +457 -0
  160. package/lib/plugin/analyze.js +9 -9
  161. package/lib/plugin/auth.js +5 -4
  162. package/lib/plugin/browser.js +77 -0
  163. package/lib/plugin/expose.js +159 -0
  164. package/lib/plugin/heal.js +47 -3
  165. package/lib/plugin/junitReporter.js +303 -0
  166. package/lib/plugin/pageInfo.js +54 -52
  167. package/lib/plugin/pause.js +131 -0
  168. package/lib/plugin/pauseOnFail.js +11 -33
  169. package/lib/plugin/retryFailedStep.js +43 -32
  170. package/lib/plugin/screencast.js +289 -0
  171. package/lib/plugin/screenshot.js +558 -0
  172. package/lib/plugin/screenshotOnFail.js +9 -170
  173. package/lib/plugin/stepTimeout.js +3 -2
  174. package/lib/recorder.js +1 -1
  175. package/lib/rerun.js +2 -1
  176. package/lib/result.js +2 -1
  177. package/lib/step/base.js +23 -9
  178. package/lib/step/comment.js +2 -2
  179. package/lib/step/config.js +15 -2
  180. package/lib/step/helper.js +4 -4
  181. package/lib/step/meta.js +3 -3
  182. package/lib/step/record.js +12 -4
  183. package/lib/store.js +72 -3
  184. package/lib/translation.js +2 -1
  185. package/lib/utils/loaderCheck.js +41 -3
  186. package/lib/utils/mask_data.js +2 -1
  187. package/lib/utils/pluginParser.js +151 -0
  188. package/lib/utils/trace.js +297 -0
  189. package/lib/utils/typescript.js +261 -49
  190. package/lib/utils.js +77 -3
  191. package/lib/workers.js +123 -17
  192. package/package.json +48 -43
  193. package/typings/index.d.ts +120 -9
  194. package/typings/promiseBasedTypes.d.ts +3243 -6057
  195. package/typings/types.d.ts +3541 -6506
  196. package/docs/webapi/amOnPage.mustache +0 -11
  197. package/docs/webapi/appendField.mustache +0 -11
  198. package/docs/webapi/attachFile.mustache +0 -12
  199. package/docs/webapi/blur.mustache +0 -18
  200. package/docs/webapi/checkOption.mustache +0 -13
  201. package/docs/webapi/clearCookie.mustache +0 -9
  202. package/docs/webapi/clearField.mustache +0 -9
  203. package/docs/webapi/click.mustache +0 -29
  204. package/docs/webapi/clickLink.mustache +0 -8
  205. package/docs/webapi/closeCurrentTab.mustache +0 -7
  206. package/docs/webapi/closeOtherTabs.mustache +0 -8
  207. package/docs/webapi/dontSee.mustache +0 -11
  208. package/docs/webapi/dontSeeCheckboxIsChecked.mustache +0 -10
  209. package/docs/webapi/dontSeeCookie.mustache +0 -8
  210. package/docs/webapi/dontSeeCurrentUrlEquals.mustache +0 -10
  211. package/docs/webapi/dontSeeElement.mustache +0 -8
  212. package/docs/webapi/dontSeeElementInDOM.mustache +0 -8
  213. package/docs/webapi/dontSeeInCurrentUrl.mustache +0 -4
  214. package/docs/webapi/dontSeeInField.mustache +0 -11
  215. package/docs/webapi/dontSeeInSource.mustache +0 -8
  216. package/docs/webapi/dontSeeInTitle.mustache +0 -8
  217. package/docs/webapi/dontSeeTraffic.mustache +0 -13
  218. package/docs/webapi/doubleClick.mustache +0 -13
  219. package/docs/webapi/downloadFile.mustache +0 -12
  220. package/docs/webapi/dragAndDrop.mustache +0 -9
  221. package/docs/webapi/dragSlider.mustache +0 -11
  222. package/docs/webapi/executeAsyncScript.mustache +0 -24
  223. package/docs/webapi/executeScript.mustache +0 -26
  224. package/docs/webapi/fillField.mustache +0 -16
  225. package/docs/webapi/flushNetworkTraffics.mustache +0 -5
  226. package/docs/webapi/focus.mustache +0 -13
  227. package/docs/webapi/forceClick.mustache +0 -28
  228. package/docs/webapi/forceRightClick.mustache +0 -18
  229. package/docs/webapi/grabAllWindowHandles.mustache +0 -7
  230. package/docs/webapi/grabAttributeFrom.mustache +0 -10
  231. package/docs/webapi/grabAttributeFromAll.mustache +0 -9
  232. package/docs/webapi/grabBrowserLogs.mustache +0 -9
  233. package/docs/webapi/grabCookie.mustache +0 -11
  234. package/docs/webapi/grabCssPropertyFrom.mustache +0 -11
  235. package/docs/webapi/grabCssPropertyFromAll.mustache +0 -10
  236. package/docs/webapi/grabCurrentUrl.mustache +0 -9
  237. package/docs/webapi/grabCurrentWindowHandle.mustache +0 -6
  238. package/docs/webapi/grabDataFromPerformanceTiming.mustache +0 -20
  239. package/docs/webapi/grabElementBoundingRect.mustache +0 -20
  240. package/docs/webapi/grabGeoLocation.mustache +0 -8
  241. package/docs/webapi/grabHTMLFrom.mustache +0 -10
  242. package/docs/webapi/grabHTMLFromAll.mustache +0 -9
  243. package/docs/webapi/grabNumberOfOpenTabs.mustache +0 -8
  244. package/docs/webapi/grabNumberOfVisibleElements.mustache +0 -9
  245. package/docs/webapi/grabPageScrollPosition.mustache +0 -8
  246. package/docs/webapi/grabPopupText.mustache +0 -5
  247. package/docs/webapi/grabRecordedNetworkTraffics.mustache +0 -10
  248. package/docs/webapi/grabSource.mustache +0 -8
  249. package/docs/webapi/grabTextFrom.mustache +0 -10
  250. package/docs/webapi/grabTextFromAll.mustache +0 -9
  251. package/docs/webapi/grabTitle.mustache +0 -8
  252. package/docs/webapi/grabValueFrom.mustache +0 -9
  253. package/docs/webapi/grabValueFromAll.mustache +0 -8
  254. package/docs/webapi/grabWebElement.mustache +0 -9
  255. package/docs/webapi/grabWebElements.mustache +0 -9
  256. package/docs/webapi/moveCursorTo.mustache +0 -12
  257. package/docs/webapi/openNewTab.mustache +0 -7
  258. package/docs/webapi/pressKey.mustache +0 -12
  259. package/docs/webapi/pressKeyDown.mustache +0 -12
  260. package/docs/webapi/pressKeyUp.mustache +0 -12
  261. package/docs/webapi/pressKeyWithKeyNormalization.mustache +0 -60
  262. package/docs/webapi/refreshPage.mustache +0 -6
  263. package/docs/webapi/resizeWindow.mustache +0 -6
  264. package/docs/webapi/rightClick.mustache +0 -14
  265. package/docs/webapi/saveElementScreenshot.mustache +0 -10
  266. package/docs/webapi/saveScreenshot.mustache +0 -12
  267. package/docs/webapi/say.mustache +0 -10
  268. package/docs/webapi/scrollIntoView.mustache +0 -11
  269. package/docs/webapi/scrollPageToBottom.mustache +0 -6
  270. package/docs/webapi/scrollPageToTop.mustache +0 -6
  271. package/docs/webapi/scrollTo.mustache +0 -12
  272. package/docs/webapi/see.mustache +0 -11
  273. package/docs/webapi/seeAttributesOnElements.mustache +0 -9
  274. package/docs/webapi/seeCheckboxIsChecked.mustache +0 -10
  275. package/docs/webapi/seeCookie.mustache +0 -8
  276. package/docs/webapi/seeCssPropertiesOnElements.mustache +0 -9
  277. package/docs/webapi/seeCurrentUrlEquals.mustache +0 -11
  278. package/docs/webapi/seeElement.mustache +0 -8
  279. package/docs/webapi/seeElementInDOM.mustache +0 -8
  280. package/docs/webapi/seeInCurrentUrl.mustache +0 -8
  281. package/docs/webapi/seeInField.mustache +0 -12
  282. package/docs/webapi/seeInPopup.mustache +0 -8
  283. package/docs/webapi/seeInSource.mustache +0 -7
  284. package/docs/webapi/seeInTitle.mustache +0 -8
  285. package/docs/webapi/seeNumberOfElements.mustache +0 -11
  286. package/docs/webapi/seeNumberOfVisibleElements.mustache +0 -10
  287. package/docs/webapi/seeTextEquals.mustache +0 -9
  288. package/docs/webapi/seeTitleEquals.mustache +0 -8
  289. package/docs/webapi/seeTraffic.mustache +0 -36
  290. package/docs/webapi/selectOption.mustache +0 -21
  291. package/docs/webapi/setCookie.mustache +0 -16
  292. package/docs/webapi/setGeoLocation.mustache +0 -12
  293. package/docs/webapi/startRecordingTraffic.mustache +0 -8
  294. package/docs/webapi/startRecordingWebSocketMessages.mustache +0 -8
  295. package/docs/webapi/stopRecordingTraffic.mustache +0 -5
  296. package/docs/webapi/stopRecordingWebSocketMessages.mustache +0 -7
  297. package/docs/webapi/switchTo.mustache +0 -9
  298. package/docs/webapi/switchToNextTab.mustache +0 -10
  299. package/docs/webapi/switchToPreviousTab.mustache +0 -10
  300. package/docs/webapi/type.mustache +0 -21
  301. package/docs/webapi/uncheckOption.mustache +0 -13
  302. package/docs/webapi/wait.mustache +0 -8
  303. package/docs/webapi/waitForClickable.mustache +0 -11
  304. package/docs/webapi/waitForCookie.mustache +0 -9
  305. package/docs/webapi/waitForDetached.mustache +0 -10
  306. package/docs/webapi/waitForDisabled.mustache +0 -6
  307. package/docs/webapi/waitForElement.mustache +0 -11
  308. package/docs/webapi/waitForEnabled.mustache +0 -6
  309. package/docs/webapi/waitForFunction.mustache +0 -17
  310. package/docs/webapi/waitForInvisible.mustache +0 -10
  311. package/docs/webapi/waitForNumberOfTabs.mustache +0 -9
  312. package/docs/webapi/waitForText.mustache +0 -13
  313. package/docs/webapi/waitForValue.mustache +0 -10
  314. package/docs/webapi/waitForVisible.mustache +0 -10
  315. package/docs/webapi/waitInUrl.mustache +0 -9
  316. package/docs/webapi/waitNumberOfVisibleElements.mustache +0 -10
  317. package/docs/webapi/waitToHide.mustache +0 -10
  318. package/docs/webapi/waitUrlEquals.mustache +0 -10
  319. package/lib/helper/AI.js +0 -214
  320. package/lib/helper/Mochawesome.js +0 -96
  321. package/lib/helper/extras/PlaywrightReactVueLocator.js +0 -52
  322. package/lib/helper/extras/React.js +0 -65
  323. package/lib/listener/enhancedGlobalRetry.js +0 -110
  324. package/lib/plugin/enhancedRetryFailedStep.js +0 -99
  325. package/lib/plugin/htmlReporter.js +0 -3648
  326. package/lib/plugin/stepByStepReport.js +0 -427
  327. package/lib/plugin/subtitles.js +0 -89
  328. package/lib/retryCoordinator.js +0 -207
@@ -7,6 +7,7 @@ import promiseRetry from 'promise-retry'
7
7
  import Locator from '../locator.js'
8
8
  import recorder from '../recorder.js'
9
9
  import store from '../store.js'
10
+ import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js'
10
11
  import { includes as stringIncludes } from '../assert/include.js'
11
12
  import { urlEquals, equals } from '../assert/equal.js'
12
13
  import { empty } from '../assert/empty.js'
@@ -23,26 +24,28 @@ import {
23
24
  clearString,
24
25
  requireWithFallback,
25
26
  normalizeSpacesInString,
27
+ normalizePath,
28
+ resolveUrl,
26
29
  relativeDir,
30
+ getMimeType,
31
+ base64EncodeFile,
27
32
  } from '../utils.js'
28
33
  import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
29
34
  import ElementNotFound from './errors/ElementNotFound.js'
35
+ import MultipleElementsFound from './errors/MultipleElementsFound.js'
30
36
  import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
31
37
  import Popup from './extras/Popup.js'
32
38
  import Console from './extras/Console.js'
33
- import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
39
+ import { findByPlaywrightLocator } from './extras/PlaywrightLocator.js'
40
+ import { dropFile } from './scripts/dropFile.js'
34
41
  import WebElement from '../element/WebElement.js'
42
+ import { selectElement } from './extras/elementSelection.js'
43
+ import { fillRichEditor } from './extras/richTextEditor.js'
35
44
 
36
45
  let playwright
37
46
  let perfTiming
38
47
  let defaultSelectorEnginesInitialized = false
39
- let registeredCustomLocatorStrategies = new Set()
40
- let globalCustomLocatorStrategies = new Map()
41
48
 
42
- // Use global object to track selector registration across workers
43
- if (typeof global.__playwrightSelectorsRegistered === 'undefined') {
44
- global.__playwrightSelectorsRegistered = false
45
- }
46
49
 
47
50
  const popupStore = new Popup()
48
51
  const consoleLogStore = new Console()
@@ -100,7 +103,6 @@ const pathSeparator = path.sep
100
103
  * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
101
104
  * @prop {object} [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har).
102
105
  * @prop {string} [testIdAttribute=data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id).
103
- * @prop {object} [customLocatorStrategies] - custom locator strategies. An object with keys as strategy names and values as JavaScript functions. Example: `{ byRole: (selector, root) => { return root.querySelector(`[role="${selector}"]`) } }`
104
106
  * @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object)
105
107
  * passed directly to `browser.newContext`.
106
108
  * If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`),
@@ -356,28 +358,6 @@ class Playwright extends Helper {
356
358
  this.recordedWebSocketMessagesAtLeastOnce = false
357
359
  this.cdpSession = null
358
360
 
359
- // Filter out invalid customLocatorStrategies (empty arrays, objects without functions)
360
- // This can happen in worker threads where config is serialized/deserialized
361
- let validCustomLocators = null
362
- if (typeof config.customLocatorStrategies === 'object' && config.customLocatorStrategies !== null) {
363
- // Check if it's an empty array or object with no function properties
364
- const entries = Object.entries(config.customLocatorStrategies)
365
- const hasFunctions = entries.some(([_, value]) => typeof value === 'function')
366
- if (hasFunctions) {
367
- validCustomLocators = config.customLocatorStrategies
368
- }
369
- }
370
-
371
- this.customLocatorStrategies = validCustomLocators
372
- this._customLocatorsRegistered = false
373
-
374
- // Add custom locator strategies to global registry for early registration
375
- if (this.customLocatorStrategies) {
376
- for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
377
- globalCustomLocatorStrategies.set(strategyName, strategyFunction)
378
- }
379
- }
380
-
381
361
  // Add test failure tracking to prevent false positives
382
362
  this.testFailures = []
383
363
  this.hasCleanupError = false
@@ -417,6 +397,7 @@ class Playwright extends Helper {
417
397
  highlightElement: false,
418
398
  storageState: undefined,
419
399
  onResponse: null,
400
+ strict: false,
420
401
  }
421
402
 
422
403
  process.env.testIdAttribute = 'data-testid'
@@ -465,7 +446,7 @@ class Playwright extends Helper {
465
446
  this.options.recordVideo = { size }
466
447
  }
467
448
  if (this.options.recordVideo && !this.options.recordVideo.dir) {
468
- this.options.recordVideo.dir = `${global.output_dir}/videos/`
449
+ this.options.recordVideo.dir = `${store.outputDir}/videos/`
469
450
  }
470
451
  this.isRemoteBrowser = !!this.playwrightOptions.browserWSEndpoint
471
452
  this.isElectron = this.options.browser === 'electron'
@@ -523,32 +504,22 @@ class Playwright extends Helper {
523
504
  }
524
505
  }
525
506
 
526
- // Ensure custom locators from this instance are in the global registry
527
- // This is critical for worker threads where globalCustomLocatorStrategies is a new Map
528
- if (this.customLocatorStrategies) {
529
- for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
530
- if (!globalCustomLocatorStrategies.has(strategyName)) {
531
- globalCustomLocatorStrategies.set(strategyName, strategyFunction)
532
- }
533
- }
534
- }
535
-
536
507
  // register an internal selector engine for reading value property of elements in a selector
537
508
  try {
538
509
  // Always wrap in try-catch since selectors might be registered globally across workers
539
510
  // Check global flag to avoid re-registration in worker processes
540
- if (!global.__playwrightSelectorsRegistered) {
511
+ if (!defaultSelectorEnginesInitialized) {
541
512
  try {
542
513
  await playwright.selectors.register('__value', createValueEngine)
543
514
  await playwright.selectors.register('__disabled', createDisabledEngine)
544
- global.__playwrightSelectorsRegistered = true
515
+ defaultSelectorEnginesInitialized = true
545
516
  defaultSelectorEnginesInitialized = true
546
517
  } catch (e) {
547
518
  if (!e.message.includes('already registered')) {
548
519
  throw e
549
520
  }
550
521
  // Selector already registered globally by another worker
551
- global.__playwrightSelectorsRegistered = true
522
+ defaultSelectorEnginesInitialized = true
552
523
  defaultSelectorEnginesInitialized = true
553
524
  }
554
525
  } else {
@@ -563,54 +534,6 @@ class Playwright extends Helper {
563
534
  // Ignore if already set
564
535
  }
565
536
  }
566
-
567
- // Register all custom locator strategies from the global registry
568
- for (const [strategyName, strategyFunction] of globalCustomLocatorStrategies.entries()) {
569
- if (!registeredCustomLocatorStrategies.has(strategyName)) {
570
- try {
571
- // Create a selector engine factory function exactly like createValueEngine pattern
572
- // Capture variables in closure to avoid reference issues
573
- const createCustomEngine = ((name, func) => {
574
- return () => {
575
- return {
576
- create() {
577
- return null
578
- },
579
- query(root, selector) {
580
- try {
581
- if (!root) return null
582
- const result = func(selector, root)
583
- return Array.isArray(result) ? result[0] : result
584
- } catch (error) {
585
- console.warn(`Error in custom locator "${name}":`, error)
586
- return null
587
- }
588
- },
589
- queryAll(root, selector) {
590
- try {
591
- if (!root) return []
592
- const result = func(selector, root)
593
- return Array.isArray(result) ? result : result ? [result] : []
594
- } catch (error) {
595
- console.warn(`Error in custom locator "${name}":`, error)
596
- return []
597
- }
598
- },
599
- }
600
- }
601
- })(strategyName, strategyFunction)
602
-
603
- await playwright.selectors.register(strategyName, createCustomEngine)
604
- registeredCustomLocatorStrategies.add(strategyName)
605
- } catch (error) {
606
- if (!error.message.includes('already registered')) {
607
- console.warn(`Failed to register custom locator strategy '${strategyName}':`, error)
608
- } else {
609
- console.log(`Custom locator strategy '${strategyName}' already registered`)
610
- }
611
- }
612
- }
613
- }
614
537
  } catch (e) {
615
538
  console.warn(e)
616
539
  }
@@ -689,7 +612,7 @@ class Playwright extends Helper {
689
612
  if (this.options.recordVideo) contextOptions.recordVideo = this.options.recordVideo
690
613
  if (this.options.recordHar) {
691
614
  const harExt = this.options.recordHar.content && this.options.recordHar.content === 'attach' ? 'zip' : 'har'
692
- const fileName = `${`${global.output_dir}${path.sep}har${path.sep}${uuidv4()}_${clearString(this.currentRunningTest.title)}`.slice(0, 245)}.${harExt}`
615
+ const fileName = `${`${store.outputDir}${path.sep}har${path.sep}${uuidv4()}_${clearString(this.currentRunningTest.title)}`.slice(0, 245)}.${harExt}`
693
616
  const dir = path.dirname(fileName)
694
617
  if (!fileExists(dir)) fs.mkdirSync(dir)
695
618
  this.options.recordHar.path = fileName
@@ -832,6 +755,11 @@ class Playwright extends Helper {
832
755
  }
833
756
 
834
757
  async _afterSuite() {
758
+ // Reset leftover test-level cleanup state (e.g. screenshot failures)
759
+ // so only errors from this suite teardown are evaluated below.
760
+ this.hasCleanupError = false
761
+ this.testFailures = []
762
+
835
763
  // Stop browser after suite completes
836
764
  // For restart strategies: stop after each suite
837
765
  // For session mode (restart:false): stop after the last suite
@@ -923,7 +851,7 @@ class Playwright extends Helper {
923
851
  }
924
852
 
925
853
  async _finishTest() {
926
- if ((restartsSession() || restartsContext() || restartsBrowser()) && this.isRunning) {
854
+ if (this.isRunning) {
927
855
  try {
928
856
  await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Test finish timeout')), 10000))])
929
857
  } catch (e) {
@@ -1277,30 +1205,6 @@ class Playwright extends Helper {
1277
1205
  return this.browser
1278
1206
  }
1279
1207
 
1280
- _lookupCustomLocator(customStrategy) {
1281
- if (typeof this.customLocatorStrategies !== 'object' || this.customLocatorStrategies === null) {
1282
- return null
1283
- }
1284
- const strategy = this.customLocatorStrategies[customStrategy]
1285
- return typeof strategy === 'function' ? strategy : null
1286
- }
1287
-
1288
- _isCustomLocator(locator) {
1289
- const locatorObj = new Locator(locator)
1290
- if (locatorObj.isCustom()) {
1291
- const customLocator = this._lookupCustomLocator(locatorObj.type)
1292
- if (customLocator) {
1293
- return true
1294
- }
1295
- throw new Error('Please define "customLocatorStrategies" as an Object and the Locator Strategy as a "function".')
1296
- }
1297
- return false
1298
- }
1299
-
1300
- _isCustomLocatorStrategyDefined() {
1301
- return !!(this.customLocatorStrategies && Object.keys(this.customLocatorStrategies).length > 0)
1302
- }
1303
-
1304
1208
  /**
1305
1209
  * Create a new browser context with a page. \
1306
1210
  * Usually it should be run from a custom helper after call of `_startBrowser()`
@@ -1312,63 +1216,11 @@ class Playwright extends Helper {
1312
1216
  }
1313
1217
  this.browserContext = await this.browser.newContext(contextOptions)
1314
1218
 
1315
- // Register custom locator strategies for this context
1316
- await this._registerCustomLocatorStrategies()
1317
-
1318
1219
  const page = await this.browserContext.newPage()
1319
1220
  targetCreatedHandler.call(this, page)
1320
1221
  await this._setPage(page)
1321
1222
  }
1322
1223
 
1323
- async _registerCustomLocatorStrategies() {
1324
- if (!this.customLocatorStrategies) return
1325
-
1326
- for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
1327
- if (!registeredCustomLocatorStrategies.has(strategyName)) {
1328
- try {
1329
- const createCustomEngine = ((name, func) => {
1330
- return () => {
1331
- return {
1332
- create(root, target) {
1333
- return null
1334
- },
1335
- query(root, selector) {
1336
- try {
1337
- if (!root) return null
1338
- const result = func(selector, root)
1339
- return Array.isArray(result) ? result[0] : result
1340
- } catch (error) {
1341
- console.warn(`Error in custom locator "${name}":`, error)
1342
- return null
1343
- }
1344
- },
1345
- queryAll(root, selector) {
1346
- try {
1347
- if (!root) return []
1348
- const result = func(selector, root)
1349
- return Array.isArray(result) ? result : result ? [result] : []
1350
- } catch (error) {
1351
- console.warn(`Error in custom locator "${name}":`, error)
1352
- return []
1353
- }
1354
- },
1355
- }
1356
- }
1357
- })(strategyName, strategyFunction)
1358
-
1359
- await playwright.selectors.register(strategyName, createCustomEngine)
1360
- registeredCustomLocatorStrategies.add(strategyName)
1361
- } catch (error) {
1362
- if (!error.message.includes('already registered')) {
1363
- console.warn(`Failed to register custom locator strategy '${strategyName}':`, error)
1364
- } else {
1365
- console.log(`Custom locator strategy '${strategyName}' already registered`)
1366
- }
1367
- }
1368
- }
1369
- }
1370
- }
1371
-
1372
1224
  _getType() {
1373
1225
  return this.browser._type
1374
1226
  }
@@ -1389,6 +1241,7 @@ class Playwright extends Helper {
1389
1241
  }
1390
1242
  }
1391
1243
 
1244
+ // Close browserContext if recordHar is enabled
1392
1245
  if (this.options.recordHar && this.browserContext) {
1393
1246
  try {
1394
1247
  await this.browserContext.close()
@@ -1398,16 +1251,16 @@ class Playwright extends Helper {
1398
1251
  }
1399
1252
  this.browserContext = null
1400
1253
 
1254
+ // Initiate browser close without waiting for it to complete
1255
+ // The browser process will be cleaned up when the Node process exits
1401
1256
  if (this.browser) {
1402
1257
  try {
1403
- // Add timeout to prevent browser.close() from hanging indefinitely
1404
- await Promise.race([this.browser.close(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser close timeout')), 5000))])
1258
+ // Fire and forget - don't wait for close to complete
1259
+ this.browser.close().catch(() => {
1260
+ // Silently ignore any errors during async close
1261
+ })
1405
1262
  } catch (e) {
1406
- // Ignore errors if browser is already closed or timeout
1407
- if (!e.message?.includes('Browser close timeout')) {
1408
- // Non-timeout error, can be ignored as well
1409
- }
1410
- // Force cleanup even on error
1263
+ // Ignore any synchronous errors
1411
1264
  }
1412
1265
  }
1413
1266
  this.browser = null
@@ -1646,8 +1499,23 @@ class Playwright extends Helper {
1646
1499
  *
1647
1500
  */
1648
1501
  async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
1649
- const el = await this._locateElement(locator)
1650
- assertElementExists(el, locator)
1502
+ let context = null
1503
+ if (typeof offsetX !== 'number') {
1504
+ context = offsetX
1505
+ offsetX = 0
1506
+ }
1507
+
1508
+ let el
1509
+ if (context) {
1510
+ const contextEls = await this._locate(context)
1511
+ assertElementExists(contextEls, context, 'Context element')
1512
+ el = await findElements.call(this, contextEls[0], locator)
1513
+ assertElementExists(el, locator)
1514
+ el = el[0]
1515
+ } else {
1516
+ el = await this._locateElement(locator)
1517
+ assertElementExists(el, locator)
1518
+ }
1651
1519
 
1652
1520
  // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
1653
1521
  const { x, y } = await clickablePoint(el)
@@ -1771,7 +1639,7 @@ class Playwright extends Helper {
1771
1639
  * @returns Promise<void>
1772
1640
  */
1773
1641
  async replayFromHar(harFilePath, opts) {
1774
- const file = path.join(global.codecept_dir, harFilePath)
1642
+ const file = path.join(store.codeceptDir, harFilePath)
1775
1643
 
1776
1644
  if (!fileExists(file)) {
1777
1645
  throw new Error(`File at ${file} cannot be found on local system`)
@@ -1911,7 +1779,11 @@ class Playwright extends Helper {
1911
1779
  */
1912
1780
  async _locateElement(locator) {
1913
1781
  const context = await this._getContext()
1914
- return findElement(context, locator)
1782
+ const elements = await findElements.call(this, context, locator)
1783
+ if (elements.length === 0) {
1784
+ throw new ElementNotFound(locator, 'Element', 'was not found')
1785
+ }
1786
+ return selectElement(elements, locator, this)
1915
1787
  }
1916
1788
 
1917
1789
  /**
@@ -1926,7 +1798,7 @@ class Playwright extends Helper {
1926
1798
  const context = providedContext || (await this._getContext())
1927
1799
  const els = await findCheckable.call(this, locator, context)
1928
1800
  assertElementExists(els[0], locator, 'Checkbox or radio')
1929
- return els[0]
1801
+ return selectElement(els, locator, this)
1930
1802
  }
1931
1803
 
1932
1804
  /**
@@ -2094,8 +1966,15 @@ class Playwright extends Helper {
2094
1966
  * {{> seeElement }}
2095
1967
  *
2096
1968
  */
2097
- async seeElement(locator) {
2098
- let els = await this._locate(locator)
1969
+ async seeElement(locator, context = null) {
1970
+ let els
1971
+ if (context) {
1972
+ const contextEls = await this._locate(context)
1973
+ assertElementExists(contextEls, context, 'Context element')
1974
+ els = await findElements.call(this, contextEls[0], locator)
1975
+ } else {
1976
+ els = await this._locate(locator)
1977
+ }
2099
1978
  els = await Promise.all(els.map(el => el.isVisible()))
2100
1979
  try {
2101
1980
  return empty('visible elements').negate(els.filter(v => v).fill('ELEMENT'))
@@ -2108,8 +1987,15 @@ class Playwright extends Helper {
2108
1987
  * {{> dontSeeElement }}
2109
1988
  *
2110
1989
  */
2111
- async dontSeeElement(locator) {
2112
- let els = await this._locate(locator)
1990
+ async dontSeeElement(locator, context = null) {
1991
+ let els
1992
+ if (context) {
1993
+ const contextEls = await this._locate(context)
1994
+ assertElementExists(contextEls, context, 'Context element')
1995
+ els = await findElements.call(this, contextEls[0], locator)
1996
+ } else {
1997
+ els = await this._locate(locator)
1998
+ }
2113
1999
  els = await Promise.all(els.map(el => el.isVisible()))
2114
2000
  try {
2115
2001
  return empty('visible elements').assert(els.filter(v => v).fill('ELEMENT'))
@@ -2164,7 +2050,7 @@ class Playwright extends Helper {
2164
2050
  const filePath = await download.path()
2165
2051
  fileName = fileName || `downloads/${path.basename(filePath)}`
2166
2052
 
2167
- const downloadPath = path.join(global.output_dir, fileName)
2053
+ const downloadPath = path.join(store.outputDir, fileName)
2168
2054
  if (!fs.existsSync(path.dirname(downloadPath))) {
2169
2055
  fs.mkdirSync(path.dirname(downloadPath), '0777')
2170
2056
  }
@@ -2195,15 +2081,6 @@ class Playwright extends Helper {
2195
2081
  return proceedClick.call(this, locator, context, options)
2196
2082
  }
2197
2083
 
2198
- /**
2199
- * Clicks link and waits for navigation (deprecated)
2200
- */
2201
- async clickLink(locator, context = null) {
2202
- console.log('clickLink deprecated: Playwright automatically waits for navigation to happen.')
2203
- console.log('Replace I.clickLink with I.click')
2204
- return this.click(locator, context)
2205
- }
2206
-
2207
2084
  /**
2208
2085
  * {{> forceClick }}
2209
2086
  */
@@ -2348,6 +2225,7 @@ class Playwright extends Helper {
2348
2225
  * {{> pressKeyWithKeyNormalization }}
2349
2226
  */
2350
2227
  async pressKey(key) {
2228
+ await checkFocusBeforePressKey(this, key)
2351
2229
  const modifiers = []
2352
2230
  if (Array.isArray(key)) {
2353
2231
  for (let k of key) {
@@ -2376,6 +2254,8 @@ class Playwright extends Helper {
2376
2254
  * {{> type }}
2377
2255
  */
2378
2256
  async type(keys, delay = null) {
2257
+ await checkFocusBeforeType(this)
2258
+
2379
2259
  // Always use page.keyboard.type for any string (including single character and national characters).
2380
2260
  if (!Array.isArray(keys)) {
2381
2261
  keys = keys.toString()
@@ -2395,43 +2275,33 @@ class Playwright extends Helper {
2395
2275
  * {{> fillField }}
2396
2276
  *
2397
2277
  */
2398
- async fillField(field, value) {
2399
- const els = await findFields.call(this, field)
2278
+ async fillField(field, value, context = null) {
2279
+ const els = await findFields.call(this, field, context)
2400
2280
  assertElementExists(els, field, 'Field')
2401
- const el = els[0]
2281
+ const el = selectElement(els, field, this)
2282
+
2283
+ await highlightActiveElement.call(this, el)
2284
+
2285
+ if (await fillRichEditor(this, el, value)) {
2286
+ return this._waitForAction()
2287
+ }
2402
2288
 
2403
2289
  await el.clear()
2404
2290
  if (store.debugMode) this.debugSection('Focused', await elToString(el, 1))
2405
2291
 
2406
- await highlightActiveElement.call(this, el)
2407
-
2408
2292
  await el.type(value.toString(), { delay: this.options.pressKeyDelay })
2409
2293
 
2410
2294
  return this._waitForAction()
2411
2295
  }
2412
2296
 
2413
2297
  /**
2414
- * Clears the text input element: `<input>`, `<textarea>` or `[contenteditable]` .
2415
- *
2416
- *
2417
- * Examples:
2418
- *
2419
- * ```js
2420
- * I.clearField('.text-area')
2421
- *
2422
- * // if this doesn't work use force option
2423
- * I.clearField('#submit', { force: true })
2424
- * ```
2425
- * Use `force` to bypass the [actionability](https://playwright.dev/docs/actionability) checks.
2426
- *
2427
- * @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
2428
- * @param {any} [options] [Additional options](https://playwright.dev/docs/api/class-locator#locator-clear) for available options object as 2nd argument.
2298
+ * {{> clearField }}
2429
2299
  */
2430
- async clearField(locator, options = {}) {
2431
- const els = await findFields.call(this, locator)
2300
+ async clearField(locator, context = null) {
2301
+ const els = await findFields.call(this, locator, context)
2432
2302
  assertElementExists(els, locator, 'Field to clear')
2433
2303
 
2434
- const el = els[0]
2304
+ const el = selectElement(els, locator, this)
2435
2305
 
2436
2306
  await highlightActiveElement.call(this, el)
2437
2307
 
@@ -2443,68 +2313,101 @@ class Playwright extends Helper {
2443
2313
  /**
2444
2314
  * {{> appendField }}
2445
2315
  */
2446
- async appendField(field, value) {
2447
- const els = await findFields.call(this, field)
2316
+ async appendField(field, value, context = null) {
2317
+ const els = await findFields.call(this, field, context)
2448
2318
  assertElementExists(els, field, 'Field')
2449
- await highlightActiveElement.call(this, els[0])
2450
- await els[0].press('End')
2451
- await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
2319
+ const el = selectElement(els, field, this)
2320
+ await highlightActiveElement.call(this, el)
2321
+ await el.press('End')
2322
+ await el.type(value.toString(), { delay: this.options.pressKeyDelay })
2452
2323
  return this._waitForAction()
2453
2324
  }
2454
2325
 
2455
2326
  /**
2456
2327
  * {{> seeInField }}
2457
2328
  */
2458
- async seeInField(field, value) {
2329
+ async seeInField(field, value, context = null) {
2459
2330
  const _value = typeof value === 'boolean' ? value : value.toString()
2460
- return proceedSeeInField.call(this, 'assert', field, _value)
2331
+ return proceedSeeInField.call(this, 'assert', field, _value, context)
2461
2332
  }
2462
2333
 
2463
2334
  /**
2464
2335
  * {{> dontSeeInField }}
2465
2336
  */
2466
- async dontSeeInField(field, value) {
2337
+ async dontSeeInField(field, value, context = null) {
2467
2338
  const _value = typeof value === 'boolean' ? value : value.toString()
2468
- return proceedSeeInField.call(this, 'negate', field, _value)
2339
+ return proceedSeeInField.call(this, 'negate', field, _value, context)
2469
2340
  }
2470
2341
 
2471
2342
  /**
2472
2343
  * {{> attachFile }}
2473
2344
  *
2474
2345
  */
2475
- async attachFile(locator, pathToFile) {
2476
- const file = path.join(global.codecept_dir, pathToFile)
2346
+ async attachFile(locator, pathToFile, context = null) {
2347
+ const file = path.join(store.codeceptDir, pathToFile)
2477
2348
 
2478
2349
  if (!fileExists(file)) {
2479
2350
  throw new Error(`File at ${file} can not be found on local system`)
2480
2351
  }
2481
- const els = await findFields.call(this, locator)
2482
- assertElementExists(els, locator, 'Field')
2483
- await els[0].setInputFiles(file)
2352
+ const els = await findFields.call(this, locator, context)
2353
+ if (els.length) {
2354
+ const el = selectElement(els, locator, this)
2355
+ const tag = await el.evaluate(el => el.tagName)
2356
+ const type = await el.evaluate(el => el.type)
2357
+ if (tag === 'INPUT' && type === 'file') {
2358
+ await el.setInputFiles(file)
2359
+ return this._waitForAction()
2360
+ }
2361
+ }
2362
+
2363
+ const targetEls = els.length ? els : await this._locate(locator)
2364
+ assertElementExists(targetEls, locator, 'Element')
2365
+ const el = selectElement(targetEls, locator, this)
2366
+ const fileData = {
2367
+ base64Content: base64EncodeFile(file),
2368
+ fileName: path.basename(file),
2369
+ mimeType: getMimeType(path.basename(file)),
2370
+ }
2371
+ await el.evaluate(dropFile, fileData)
2484
2372
  return this._waitForAction()
2485
2373
  }
2486
2374
 
2487
2375
  /**
2488
2376
  * {{> selectOption }}
2489
2377
  */
2490
- async selectOption(select, option) {
2491
- const els = await findFields.call(this, select)
2492
- assertElementExists(els, select, 'Selectable field')
2493
- const el = els[0]
2378
+ async selectOption(select, option, context = null) {
2379
+ const pageContext = await this.context
2380
+ const matchedLocator = new Locator(select)
2494
2381
 
2495
- await highlightActiveElement.call(this, el)
2496
- let optionToSelect = ''
2382
+ let contextEl
2383
+ if (context) {
2384
+ const contextEls = await this._locate(context)
2385
+ assertElementExists(contextEls, context, 'Context element')
2386
+ contextEl = contextEls[0]
2387
+ }
2497
2388
 
2498
- try {
2499
- optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
2500
- } catch (e) {
2501
- optionToSelect = option
2389
+ // Strict locator
2390
+ if (!matchedLocator.isFuzzy()) {
2391
+ this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
2392
+ const els = contextEl ? await findElements.call(this, contextEl, matchedLocator) : await this._locate(matchedLocator)
2393
+ assertElementExists(els, select, 'Selectable element')
2394
+ return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2502
2395
  }
2503
2396
 
2504
- if (!Array.isArray(option)) option = [optionToSelect]
2397
+ // Fuzzy: try combobox
2398
+ this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
2399
+ const comboboxSearchCtx = contextEl || pageContext
2400
+ let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value })
2401
+ if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2505
2402
 
2506
- await el.selectOption(option)
2507
- return this._waitForAction()
2403
+ // Fuzzy: try listbox
2404
+ els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
2405
+ if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2406
+
2407
+ // Fuzzy: try native select
2408
+ els = await findFields.call(this, select, context)
2409
+ assertElementExists(els, select, 'Selectable element')
2410
+ return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
2508
2411
  }
2509
2412
 
2510
2413
  /**
@@ -2545,6 +2448,26 @@ class Playwright extends Helper {
2545
2448
  urlEquals(this.options.url).negate(url, await this._getPageUrl())
2546
2449
  }
2547
2450
 
2451
+ /**
2452
+ * {{> seeCurrentPathEquals }}
2453
+ */
2454
+ async seeCurrentPathEquals(path) {
2455
+ const currentUrl = await this._getPageUrl()
2456
+ const baseUrl = this.options.url || 'http://localhost'
2457
+ const actualPath = new URL(currentUrl, baseUrl).pathname
2458
+ return equals('url path').assert(normalizePath(path), normalizePath(actualPath))
2459
+ }
2460
+
2461
+ /**
2462
+ * {{> dontSeeCurrentPathEquals }}
2463
+ */
2464
+ async dontSeeCurrentPathEquals(path) {
2465
+ const currentUrl = await this._getPageUrl()
2466
+ const baseUrl = this.options.url || 'http://localhost'
2467
+ const actualPath = new URL(currentUrl, baseUrl).pathname
2468
+ return equals('url path').negate(normalizePath(path), normalizePath(actualPath))
2469
+ }
2470
+
2548
2471
  /**
2549
2472
  * {{> see }}
2550
2473
  *
@@ -2747,8 +2670,11 @@ class Playwright extends Helper {
2747
2670
  * @returns {Promise<any>}
2748
2671
  */
2749
2672
  async executeScript(fn, arg) {
2750
- if (this.context && this.context.constructor.name === 'FrameLocator') {
2751
- // switching to iframe context
2673
+ if (arg && typeof arg.getNativeElement === 'function') arg = arg.getNativeElement()
2674
+ if (arg && typeof arg.evaluate === 'function' && typeof arg.locator === 'function') {
2675
+ return arg.evaluate(fn)
2676
+ }
2677
+ if (this.context && typeof this.context.url !== 'function' && typeof this.context.innerText !== 'function') {
2752
2678
  return this.context.locator(':root').evaluate(fn, arg)
2753
2679
  }
2754
2680
  return this.page.evaluate.apply(this.page, [fn, arg])
@@ -2762,23 +2688,12 @@ class Playwright extends Helper {
2762
2688
  _contextLocator(locator) {
2763
2689
  const locatorObj = new Locator(locator, 'css')
2764
2690
 
2765
- // Handle custom locators differently
2766
- if (locatorObj.isCustom()) {
2767
- return buildCustomLocatorString(locatorObj)
2768
- }
2769
-
2770
2691
  locator = buildLocatorString(locatorObj)
2771
2692
 
2772
2693
  if (this.contextLocator) {
2773
2694
  const contextLocatorObj = new Locator(this.contextLocator, 'css')
2774
- if (contextLocatorObj.isCustom()) {
2775
- // For custom context locators, we can't use the >> syntax
2776
- // Instead, we'll need to handle this differently in the calling methods
2777
- return locator
2778
- } else {
2779
- const contextLocator = buildLocatorString(contextLocatorObj)
2780
- locator = `${contextLocator} >> ${locator}`
2781
- }
2695
+ const contextLocator = buildLocatorString(contextLocatorObj)
2696
+ locator = `${contextLocator} >> ${locator}`
2782
2697
  }
2783
2698
 
2784
2699
  return locator
@@ -2789,43 +2704,28 @@ class Playwright extends Helper {
2789
2704
  *
2790
2705
  */
2791
2706
  async grabTextFrom(locator) {
2792
- // Handle role locators with text/exact options
2793
- if (isRoleLocatorObject(locator)) {
2794
- const elements = await handleRoleLocator(this.page, locator)
2795
- if (elements && elements.length > 0) {
2796
- const text = await elements[0].textContent()
2797
- assertElementExists(text, JSON.stringify(locator))
2798
- this.debugSection('Text', text)
2799
- return text
2800
- }
2707
+ const roleElements = await handleRoleLocator(this.page, locator)
2708
+ if (roleElements && roleElements.length > 0) {
2709
+ const text = await roleElements[0].textContent()
2710
+ assertElementExists(text, JSON.stringify(locator))
2711
+ this.debugSection('Text', text)
2712
+ return text
2801
2713
  }
2802
2714
 
2803
2715
  const locatorObj = new Locator(locator, 'css')
2804
2716
 
2805
- if (locatorObj.isCustom()) {
2806
- // For custom locators, find the element first
2807
- const elements = await findCustomElements.call(this, this.page, locatorObj)
2808
- if (elements.length === 0) {
2809
- throw new Error(`Element not found: ${locatorObj.toString()}`)
2810
- }
2811
- const text = await elements[0].textContent()
2812
- assertElementExists(text, locatorObj.toString())
2717
+ locator = this._contextLocator(locator)
2718
+ try {
2719
+ const text = await this.page.textContent(locator)
2720
+ assertElementExists(text, locator)
2813
2721
  this.debugSection('Text', text)
2814
2722
  return text
2815
- } else {
2816
- locator = this._contextLocator(locator)
2817
- try {
2818
- const text = await this.page.textContent(locator)
2819
- assertElementExists(text, locator)
2820
- this.debugSection('Text', text)
2821
- return text
2822
- } catch (error) {
2823
- // Convert Playwright timeout errors to ElementNotFound for consistency
2824
- if (error.message && error.message.includes('Timeout')) {
2825
- throw new ElementNotFound(locator, 'text')
2826
- }
2827
- throw error
2723
+ } catch (error) {
2724
+ // Convert Playwright timeout errors to ElementNotFound for consistency
2725
+ if (error.message && error.message.includes('Timeout')) {
2726
+ throw new ElementNotFound(locator, 'text')
2828
2727
  }
2728
+ throw error
2829
2729
  }
2830
2730
  }
2831
2731
 
@@ -3037,7 +2937,7 @@ class Playwright extends Helper {
3037
2937
  const els = await this._locate(matchedLocator)
3038
2938
  assertElementExists(els, locator)
3039
2939
  const snapshot = await els[0].ariaSnapshot()
3040
- this.debugSection('Aria Snapshot', snapshot)
2940
+ this.debugSection('Aria Snapshot', `${snapshot.split('\n').length} lines`)
3041
2941
  return snapshot
3042
2942
  }
3043
2943
 
@@ -3390,24 +3290,13 @@ class Playwright extends Helper {
3390
3290
 
3391
3291
  const context = await this._getContext()
3392
3292
  try {
3393
- if (locator.isCustom()) {
3394
- // For custom locators, we need to use our custom element finding logic
3395
- const elements = await findCustomElements.call(this, context, locator)
3396
- if (elements.length === 0) {
3397
- throw new Error(`Custom locator ${locator.type}=${locator.value} not found`)
3398
- }
3399
- await elements[0].waitFor({ timeout: waitTimeout, state: 'attached' })
3400
- } else {
3401
- await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' })
3402
- }
3293
+ await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' })
3403
3294
  } catch (e) {
3404
3295
  throw new Error(`element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${e.message}`)
3405
3296
  }
3406
3297
  }
3407
3298
 
3408
3299
  /**
3409
- * This method accepts [React selectors](https://codecept.io/react).
3410
- *
3411
3300
  * {{> waitForVisible }}
3412
3301
  */
3413
3302
  async waitForVisible(locator, sec) {
@@ -3417,26 +3306,6 @@ class Playwright extends Helper {
3417
3306
  const context = await this._getContext()
3418
3307
  let count = 0
3419
3308
 
3420
- // Handle custom locators
3421
- if (locator.isCustom()) {
3422
- let waiter
3423
- do {
3424
- const elements = await findCustomElements.call(this, context, locator)
3425
- if (elements.length > 0) {
3426
- waiter = await elements[0].isVisible()
3427
- } else {
3428
- waiter = false
3429
- }
3430
- if (!waiter) {
3431
- await this.wait(1)
3432
- count += 1000
3433
- }
3434
- } while (!waiter && count <= waitTimeout)
3435
-
3436
- if (!waiter) throw new Error(`element (${locator.toString()}) still not visible after ${waitTimeout / 1000} sec.`)
3437
- return
3438
- }
3439
-
3440
3309
  // we have this as https://github.com/microsoft/playwright/issues/26829 is not yet implemented
3441
3310
  let waiter
3442
3311
  if (this.frame) {
@@ -3540,7 +3409,7 @@ class Playwright extends Helper {
3540
3409
  }
3541
3410
 
3542
3411
  async _getContext() {
3543
- if ((this.context && this.context.constructor.name === 'FrameLocator') || this.context) {
3412
+ if (this.context) {
3544
3413
  return this.context
3545
3414
  }
3546
3415
  if (this.frame) {
@@ -3554,6 +3423,7 @@ class Playwright extends Helper {
3554
3423
  */
3555
3424
  async waitInUrl(urlPart, sec = null) {
3556
3425
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3426
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
3557
3427
 
3558
3428
  return this.page
3559
3429
  .waitForFunction(
@@ -3561,13 +3431,13 @@ class Playwright extends Helper {
3561
3431
  const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
3562
3432
  return currUrl.indexOf(urlPart) > -1
3563
3433
  },
3564
- urlPart,
3434
+ expectedUrl,
3565
3435
  { timeout: waitTimeout },
3566
3436
  )
3567
3437
  .catch(async e => {
3568
- const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
3438
+ const currUrl = await this._getPageUrl()
3569
3439
  if (/Timeout/i.test(e.message)) {
3570
- throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
3440
+ throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`)
3571
3441
  } else {
3572
3442
  throw e
3573
3443
  }
@@ -3579,29 +3449,50 @@ class Playwright extends Helper {
3579
3449
  */
3580
3450
  async waitUrlEquals(urlPart, sec = null) {
3581
3451
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3452
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
3582
3453
 
3583
- const baseUrl = this.options.url
3584
- if (urlPart.indexOf('http') < 0) {
3585
- urlPart = baseUrl + urlPart
3454
+ try {
3455
+ await this.page.waitForURL(
3456
+ url => url.href === expectedUrl,
3457
+ { timeout: waitTimeout },
3458
+ )
3459
+ } catch (e) {
3460
+ const currUrl = await this._getPageUrl()
3461
+ if (/Timeout/i.test(e.message)) {
3462
+ throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`)
3463
+ } else {
3464
+ throw e
3465
+ }
3586
3466
  }
3467
+ }
3587
3468
 
3588
- return this.page
3589
- .waitForFunction(
3590
- urlPart => {
3591
- const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
3592
- return currUrl.indexOf(urlPart) > -1
3469
+ /**
3470
+ * {{> waitCurrentPathEquals }}
3471
+ */
3472
+ async waitCurrentPathEquals(path, sec = null) {
3473
+ const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3474
+ const normalizedPath = normalizePath(path)
3475
+
3476
+ try {
3477
+ await this.page.waitForFunction(
3478
+ expectedPath => {
3479
+ const actualPath = window.location.pathname
3480
+ const normalizePath = p => (p === '' || p === '/' ? '/' : p.replace(/\/+/g, '/').replace(/\/$/, '') || '/')
3481
+ return normalizePath(actualPath) === expectedPath
3593
3482
  },
3594
- urlPart,
3483
+ normalizedPath,
3595
3484
  { timeout: waitTimeout },
3596
3485
  )
3597
- .catch(async e => {
3598
- const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
3599
- if (/Timeout/i.test(e.message)) {
3600
- throw new Error(`expected url to be ${urlPart}, but found ${currUrl}`)
3601
- } else {
3602
- throw e
3603
- }
3604
- })
3486
+ } catch (e) {
3487
+ const currentUrl = await this._getPageUrl()
3488
+ const baseUrl = this.options.url || 'http://localhost'
3489
+ const actualPath = new URL(currentUrl, baseUrl).pathname
3490
+ if (/Timeout/i.test(e.message)) {
3491
+ throw new Error(`expected path to be ${normalizedPath}, but found ${normalizePath(actualPath)}`)
3492
+ } else {
3493
+ throw e
3494
+ }
3495
+ }
3605
3496
  }
3606
3497
 
3607
3498
  /**
@@ -3616,15 +3507,6 @@ class Playwright extends Helper {
3616
3507
  if (context) {
3617
3508
  const locator = new Locator(context, 'css')
3618
3509
  try {
3619
- if (locator.isCustom()) {
3620
- // For custom locators, find the elements first then check for text within them
3621
- const elements = await findCustomElements.call(this, contextObject, locator)
3622
- if (elements.length === 0) {
3623
- throw new Error(`Context element not found: ${locator.toString()}`)
3624
- }
3625
- return elements[0].locator(`text=${text}`).first().waitFor({ timeout: waitTimeout, state: 'visible' })
3626
- }
3627
-
3628
3510
  if (!locator.isXPath()) {
3629
3511
  return contextObject
3630
3512
  .locator(`${locator.simplify()} >> text=${text}`)
@@ -3867,7 +3749,7 @@ class Playwright extends Helper {
3867
3749
  if (!locator.isXPath()) {
3868
3750
  try {
3869
3751
  await context
3870
- .locator(`${locator.isCustom() ? `${locator.type}=${locator.value}` : locator.simplify()}`)
3752
+ .locator(locator.simplify())
3871
3753
  .first()
3872
3754
  .waitFor({ timeout: waitTimeout, state: 'detached' })
3873
3755
  } catch (e) {
@@ -4292,51 +4174,54 @@ class Playwright extends Helper {
4292
4174
 
4293
4175
  export default Playwright
4294
4176
 
4295
- function buildCustomLocatorString(locator) {
4296
- // Note: this.debug not available in standalone function, using console.log
4297
- console.log(`Building custom locator string: ${locator.type}=${locator.value}`)
4298
- return `${locator.type}=${locator.value}`
4299
- }
4300
-
4301
- function buildLocatorString(locator) {
4302
- if (locator.isCustom()) {
4303
- return buildCustomLocatorString(locator)
4304
- }
4177
+ export function buildLocatorString(locator) {
4305
4178
  if (locator.isXPath()) {
4306
- return `xpath=${locator.value}`
4179
+ // Make XPath relative so it works correctly within scoped contexts (e.g. within()).
4180
+ // Playwright's XPath engine auto-converts "//..." to ".//..." when the root is not a Document,
4181
+ // but only when the selector starts with "/". Locator methods like at() wrap XPath in
4182
+ // parentheses (e.g. "(//...)[position()=1]"), bypassing that auto-conversion.
4183
+ // We fix this by prepending "." before the first "//" that follows any leading parentheses.
4184
+ const value = locator.value.replace(/^(\(*)\/\//, '$1.//')
4185
+ return `xpath=${value}`
4186
+ }
4187
+ if (locator.isShadow()) {
4188
+ // Convert shadow locator to CSS with >> chaining operator
4189
+ // Playwright pierces shadow DOM by default, >> chains selectors
4190
+ // { shadow: ['my-app', 'my-form', 'button'] } => 'my-app >> my-form >> button'
4191
+ return locator.value.join(' >> ')
4307
4192
  }
4308
4193
  return locator.simplify()
4309
4194
  }
4310
4195
 
4311
- /**
4312
- * Checks if a locator is a role locator object (e.g., {role: 'button', text: 'Submit', exact: true})
4313
- */
4314
- function isRoleLocatorObject(locator) {
4315
- return locator && typeof locator === 'object' && locator.role && !locator.type
4316
- }
4317
-
4318
4196
  /**
4319
4197
  * Handles role locator objects by converting them to Playwright's getByRole() API
4198
+ * Accepts both raw objects ({role: 'button', text: 'Submit'}) and Locator-wrapped role objects.
4320
4199
  * Returns elements array if role locator, null otherwise
4321
4200
  */
4322
4201
  async function handleRoleLocator(context, locator) {
4323
- if (!isRoleLocatorObject(locator)) return null
4202
+ const loc = new Locator(locator)
4203
+ if (!loc.isRole()) return null
4324
4204
 
4205
+ const roleObj = loc.locator || {}
4325
4206
  const options = {}
4326
- if (locator.text) options.name = locator.text
4327
- if (locator.exact !== undefined) options.exact = locator.exact
4207
+ if (roleObj.text) options.name = roleObj.text
4208
+ if (roleObj.name) options.name = roleObj.name
4209
+ if (roleObj.exact !== undefined) options.exact = roleObj.exact
4328
4210
 
4211
+ return context.getByRole(roleObj.role, Object.keys(options).length > 0 ? options : undefined).all()
4212
+ }
4213
+
4214
+ async function findByRole(context, locator) {
4215
+ if (!locator || !locator.role) return null
4216
+ const options = {}
4217
+ if (locator.name) options.name = locator.name
4218
+ if (locator.exact !== undefined) options.exact = locator.exact
4329
4219
  return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4330
4220
  }
4331
4221
 
4332
4222
  async function findElements(matcher, locator) {
4333
- // Check if locator is a Locator object with react/vue type, or a raw object with react/vue property
4334
- const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react
4335
- const isVueLocator = locator.type === 'vue' || (locator.locator && locator.locator.vue) || locator.vue
4336
4223
  const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
4337
4224
 
4338
- if (isReactLocator) return findReact(matcher, locator)
4339
- if (isVueLocator) return findVue(matcher, locator)
4340
4225
  if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
4341
4226
 
4342
4227
  // Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
@@ -4345,122 +4230,12 @@ async function findElements(matcher, locator) {
4345
4230
 
4346
4231
  locator = new Locator(locator, 'css')
4347
4232
 
4348
- // Handle custom locators directly instead of relying on Playwright selector engines
4349
- if (locator.isCustom()) {
4350
- return findCustomElements.call(this, matcher, locator)
4351
- }
4352
-
4353
- // Check if we have a custom context locator and need to search within it
4354
- if (this.contextLocator) {
4355
- const contextLocatorObj = new Locator(this.contextLocator, 'css')
4356
- if (contextLocatorObj.isCustom()) {
4357
- // Find the context elements first
4358
- const contextElements = await findCustomElements.call(this, matcher, contextLocatorObj)
4359
- if (contextElements.length === 0) {
4360
- return []
4361
- }
4362
-
4363
- // Search within the first context element
4364
- const locatorString = buildLocatorString(locator)
4365
- return contextElements[0].locator(locatorString).all()
4366
- }
4367
- }
4368
-
4369
4233
  const locatorString = buildLocatorString(locator)
4370
4234
 
4371
4235
  return matcher.locator(locatorString).all()
4372
4236
  }
4373
4237
 
4374
- async function findCustomElements(matcher, locator) {
4375
- // Always prioritize this.customLocatorStrategies which is set in constructor from config
4376
- // and persists in every worker thread instance
4377
- let strategyFunction = null
4378
-
4379
- if (this.customLocatorStrategies && this.customLocatorStrategies[locator.type]) {
4380
- strategyFunction = this.customLocatorStrategies[locator.type]
4381
- } else if (globalCustomLocatorStrategies.has(locator.type)) {
4382
- // Fallback to global registry (populated in constructor and _init)
4383
- strategyFunction = globalCustomLocatorStrategies.get(locator.type)
4384
- }
4385
-
4386
- if (!strategyFunction) {
4387
- throw new Error(`Custom locator strategy "${locator.type}" is not defined. Please define "customLocatorStrategies" in your configuration.`)
4388
- }
4389
-
4390
- // Execute the custom locator function in the browser context using page.evaluate
4391
- const page = matcher.constructor.name === 'Page' ? matcher : await matcher.page()
4392
-
4393
- const elements = await page.evaluate(
4394
- ({ strategyCode, selector }) => {
4395
- const strategy = new Function('return ' + strategyCode)()
4396
- const result = strategy(selector, document)
4397
-
4398
- // Convert NodeList or single element to array
4399
- if (result && result.nodeType) {
4400
- return [result]
4401
- } else if (result && result.length !== undefined) {
4402
- return Array.from(result)
4403
- } else if (Array.isArray(result)) {
4404
- return result
4405
- }
4406
-
4407
- return []
4408
- },
4409
- {
4410
- strategyCode: strategyFunction.toString(),
4411
- selector: locator.value,
4412
- },
4413
- )
4414
-
4415
- // Convert the found elements back to Playwright locators
4416
- if (elements.length === 0) {
4417
- return []
4418
- }
4419
-
4420
- // Create CSS selectors for the found elements and return as locators
4421
- const locators = []
4422
- const timestamp = Date.now()
4423
-
4424
- for (let i = 0; i < elements.length; i++) {
4425
- // Use a unique attribute approach to target specific elements
4426
- const uniqueAttr = `data-codecept-custom-${timestamp}-${i}`
4427
-
4428
- await page.evaluate(
4429
- ({ index, uniqueAttr, strategyCode, selector }) => {
4430
- // Re-execute the strategy to find elements and mark the specific one
4431
- const strategy = new Function('return ' + strategyCode)()
4432
- const result = strategy(selector, document)
4433
-
4434
- let elementsArray = []
4435
- if (result && result.nodeType) {
4436
- elementsArray = [result]
4437
- } else if (result && result.length !== undefined) {
4438
- elementsArray = Array.from(result)
4439
- } else if (Array.isArray(result)) {
4440
- elementsArray = result
4441
- }
4442
-
4443
- if (elementsArray[index]) {
4444
- elementsArray[index].setAttribute(uniqueAttr, 'true')
4445
- }
4446
- },
4447
- {
4448
- index: i,
4449
- uniqueAttr,
4450
- strategyCode: strategyFunction.toString(),
4451
- selector: locator.value,
4452
- },
4453
- )
4454
-
4455
- locators.push(page.locator(`[${uniqueAttr}="true"]`))
4456
- }
4457
-
4458
- return locators
4459
- }
4460
-
4461
4238
  async function findElement(matcher, locator) {
4462
- if (locator.react) return findReact(matcher, locator)
4463
- if (locator.vue) return findVue(matcher, locator)
4464
4239
  if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4465
4240
 
4466
4241
  locator = new Locator(locator, 'css')
@@ -4495,16 +4270,22 @@ async function proceedClick(locator, context = null, options = {}) {
4495
4270
  assertElementExists(els, locator, 'Clickable element')
4496
4271
  }
4497
4272
 
4498
- await highlightActiveElement.call(this, els[0])
4499
- if (store.debugMode) this.debugSection('Clicked', await elToString(els[0], 1))
4273
+ const opts = store.currentStep?.opts
4274
+ let element
4275
+ if (opts?.elementIndex != null) {
4276
+ element = selectElement(els, locator, this)
4277
+ } else {
4278
+ const strict = (opts?.exact === false || opts?.strictMode === false) ? false : (this.options.strict || opts?.exact === true || opts?.strictMode === true)
4279
+ if (strict) assertOnlyOneElement(els, locator, this)
4280
+ element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0]
4281
+ }
4282
+
4283
+ await highlightActiveElement.call(this, element)
4284
+ if (store.debugMode) this.debugSection('Clicked', await elToString(element, 1))
4500
4285
 
4501
- /*
4502
- using the force true options itself but instead dispatching a click
4503
- */
4504
4286
  if (options.force) {
4505
- await els[0].dispatchEvent('click')
4287
+ await element.dispatchEvent('click')
4506
4288
  } else {
4507
- const element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0]
4508
4289
  await element.click(options)
4509
4290
  }
4510
4291
  const promises = []
@@ -4519,7 +4300,10 @@ async function proceedClick(locator, context = null, options = {}) {
4519
4300
  async function findClickable(matcher, locator) {
4520
4301
  const matchedLocator = new Locator(locator)
4521
4302
 
4522
- if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
4303
+ if (!matchedLocator.isFuzzy()) {
4304
+ const els = await findElements.call(this, matcher, matchedLocator)
4305
+ return els
4306
+ }
4523
4307
 
4524
4308
  let els
4525
4309
  const literal = xpathLocator.literal(matchedLocator.value)
@@ -4561,7 +4345,9 @@ async function proceedSee(assertType, text, context, strict = false) {
4561
4345
  if (!context) {
4562
4346
  const el = await this.context
4563
4347
 
4564
- allText = el.constructor.name !== 'Locator' ? [await el.locator('body').innerText()] : [await el.innerText()]
4348
+ allText = typeof el.url !== 'function' && typeof el.innerText === 'function'
4349
+ ? [await el.innerText()]
4350
+ : [await el.locator('body').innerText()]
4565
4351
 
4566
4352
  description = 'web application'
4567
4353
  } else {
@@ -4619,38 +4405,92 @@ async function proceedIsChecked(assertType, option) {
4619
4405
  return truth(`checkable ${option}`, 'to be checked')[assertType](selected)
4620
4406
  }
4621
4407
 
4622
- async function findFields(locator) {
4623
- // Handle role locators with text/exact options
4624
- if (isRoleLocatorObject(locator)) {
4625
- const page = await this.page
4626
- const roleElements = await handleRoleLocator(page, locator)
4627
- if (roleElements) return roleElements
4408
+ async function findFields(locator, context = null) {
4409
+ let contextEl
4410
+ if (context) {
4411
+ const contextEls = await this._locate(context)
4412
+ assertElementExists(contextEls, context, 'Context element')
4413
+ contextEl = contextEls[0]
4628
4414
  }
4629
4415
 
4416
+ const locateFn = contextEl
4417
+ ? loc => findElements.call(this, contextEl, loc)
4418
+ : loc => this._locate(loc)
4419
+
4420
+ const matcher = contextEl || (await this.page)
4421
+ const roleElements = await handleRoleLocator(matcher, locator)
4422
+ if (roleElements) return roleElements
4423
+
4630
4424
  const matchedLocator = new Locator(locator)
4631
4425
  if (!matchedLocator.isFuzzy()) {
4632
- return this._locate(matchedLocator)
4426
+ return locateFn(matchedLocator)
4633
4427
  }
4634
4428
  const literal = xpathLocator.literal(locator)
4635
4429
 
4636
- let els = await this._locate({ xpath: Locator.field.labelEquals(literal) })
4430
+ let els = await locateFn({ xpath: Locator.field.labelEquals(literal) })
4637
4431
  if (els.length) {
4638
4432
  return els
4639
4433
  }
4640
4434
 
4641
- els = await this._locate({ xpath: Locator.field.labelContains(literal) })
4435
+ els = await locateFn({ xpath: Locator.field.labelContains(literal) })
4642
4436
  if (els.length) {
4643
4437
  return els
4644
4438
  }
4645
- els = await this._locate({ xpath: Locator.field.byName(literal) })
4439
+ els = await locateFn({ xpath: Locator.field.byName(literal) })
4646
4440
  if (els.length) {
4647
4441
  return els
4648
4442
  }
4649
- return this._locate({ css: locator })
4443
+ return locateFn({ css: locator })
4650
4444
  }
4651
4445
 
4652
- async function proceedSeeInField(assertType, field, value) {
4653
- const els = await findFields.call(this, field)
4446
+ async function proceedSelect(context, el, option) {
4447
+ const role = await el.getAttribute('role')
4448
+ const options = Array.isArray(option) ? option : [option]
4449
+
4450
+ if (role === 'combobox') {
4451
+ this.debugSection('SelectOption', 'Expanding combobox')
4452
+ await highlightActiveElement.call(this, el)
4453
+ const [ariaOwns, ariaControls] = await Promise.all([el.getAttribute('aria-owns'), el.getAttribute('aria-controls')])
4454
+ await el.click()
4455
+ await this._waitForAction()
4456
+
4457
+ const listboxId = ariaOwns || ariaControls
4458
+ let listbox = listboxId ? context.locator(`#${listboxId}`).first() : null
4459
+ if (!listbox || !(await listbox.count())) listbox = context.getByRole('listbox').first()
4460
+
4461
+ for (const opt of options) {
4462
+ const optEl = listbox.getByRole('option', { name: opt }).first()
4463
+ this.debugSection('SelectOption', `Clicking: "${opt}"`)
4464
+ await highlightActiveElement.call(this, optEl)
4465
+ await optEl.click()
4466
+ }
4467
+ return this._waitForAction()
4468
+ }
4469
+
4470
+ if (role === 'listbox') {
4471
+ for (const opt of options) {
4472
+ const optEl = el.getByRole('option', { name: opt }).first()
4473
+ this.debugSection('SelectOption', `Clicking: "${opt}"`)
4474
+ await highlightActiveElement.call(this, optEl)
4475
+ await optEl.click()
4476
+ }
4477
+ return this._waitForAction()
4478
+ }
4479
+
4480
+ await highlightActiveElement.call(this, el)
4481
+ let optionToSelect = option
4482
+ try {
4483
+ optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
4484
+ } catch (e) {
4485
+ optionToSelect = option
4486
+ }
4487
+ if (!Array.isArray(option)) option = [optionToSelect]
4488
+ await el.selectOption(option)
4489
+ return this._waitForAction()
4490
+ }
4491
+
4492
+ async function proceedSeeInField(assertType, field, value, context) {
4493
+ const els = await findFields.call(this, field, context)
4654
4494
  assertElementExists(els, field, 'Field')
4655
4495
  const el = els[0]
4656
4496
  const tag = await el.evaluate(e => e.tagName)
@@ -4764,6 +4604,13 @@ function assertElementExists(res, locator, prefix, suffix) {
4764
4604
  }
4765
4605
  }
4766
4606
 
4607
+ function assertOnlyOneElement(elements, locator, helper) {
4608
+ if (elements.length > 1) {
4609
+ const webElements = elements.map(el => new WebElement(el, helper))
4610
+ throw new MultipleElementsFound(locator, webElements)
4611
+ }
4612
+ }
4613
+
4767
4614
  function $XPath(element, selector) {
4768
4615
  const found = document.evaluate(selector, element || document.body, null, 5, null)
4769
4616
  const res = []
@@ -4783,12 +4630,16 @@ async function targetCreatedHandler(page) {
4783
4630
  .catch(() => null)
4784
4631
  .then(async () => {
4785
4632
  if (this.context && this.context._type === 'Frame') {
4786
- // we are inside iframe?
4633
+ // we are inside iframe via Frame object — refresh handle
4787
4634
  const frameEl = await this.context.frameElement()
4788
4635
  this.context = await frameEl.contentFrame()
4789
4636
  this.contextLocator = null
4790
4637
  return
4791
4638
  }
4639
+ if (this.context && this.context.constructor && this.context.constructor.name === 'FrameLocator') {
4640
+ // we are inside iframe via FrameLocator — keep it across load events
4641
+ return
4642
+ }
4792
4643
  // if context element was in iframe - keep it
4793
4644
  // if (await this.context.ownerFrame()) return;
4794
4645
  this.context = page
@@ -4969,7 +4820,7 @@ async function refreshContextSession() {
4969
4820
 
4970
4821
  function saveVideoForPage(page, name) {
4971
4822
  if (!page.video()) return null
4972
- const fileName = `${`${global.output_dir}${pathSeparator}videos${pathSeparator}${uuidv4()}_${clearString(name)}`.slice(0, 245)}.webm`
4823
+ const fileName = `${`${store.outputDir}${pathSeparator}videos${pathSeparator}${uuidv4()}_${clearString(name)}`.slice(0, 245)}.webm`
4973
4824
  page
4974
4825
  .video()
4975
4826
  .saveAs(fileName)
@@ -4986,7 +4837,7 @@ async function saveTraceForContext(context, name) {
4986
4837
  if (!context) return
4987
4838
  if (!context.tracing) return
4988
4839
  try {
4989
- const fileName = `${`${global.output_dir}${pathSeparator}trace${pathSeparator}${uuidv4()}_${clearString(name)}`.slice(0, 245)}.zip`
4840
+ const fileName = `${`${store.outputDir}${pathSeparator}trace${pathSeparator}${uuidv4()}_${clearString(name)}`.slice(0, 245)}.zip`
4990
4841
  await context.tracing.stop({ path: fileName })
4991
4842
  return fileName
4992
4843
  } catch (err) {