codeceptjs 4.0.2-beta.9 → 4.0.2

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 (326) hide show
  1. package/README.md +39 -28
  2. package/bin/codecept.js +15 -2
  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 +6 -7
  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 -1
  112. package/lib/command/run-workers.js +2 -14
  113. package/lib/command/run.js +3 -17
  114. package/lib/command/utils.js +14 -0
  115. package/lib/command/workers/runTests.js +84 -41
  116. package/lib/config.js +96 -18
  117. package/lib/container.js +115 -17
  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 +358 -467
  128. package/lib/helper/Puppeteer.js +335 -192
  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 +4 -1
  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 +11 -1
  152. package/lib/mocha/inject.js +1 -1
  153. package/lib/mocha/scenarioConfig.js +2 -1
  154. package/lib/mocha/ui.js +5 -6
  155. package/lib/parser.js +2 -2
  156. package/lib/pause.js +38 -4
  157. package/lib/plugin/aiTrace.js +457 -0
  158. package/lib/plugin/analyze.js +9 -9
  159. package/lib/plugin/auth.js +5 -4
  160. package/lib/plugin/browser.js +77 -0
  161. package/lib/plugin/expose.js +159 -0
  162. package/lib/plugin/heal.js +47 -3
  163. package/lib/plugin/junitReporter.js +303 -0
  164. package/lib/plugin/pageInfo.js +54 -52
  165. package/lib/plugin/pause.js +131 -0
  166. package/lib/plugin/pauseOnFail.js +11 -33
  167. package/lib/plugin/retryFailedStep.js +43 -32
  168. package/lib/plugin/screencast.js +289 -0
  169. package/lib/plugin/screenshot.js +558 -0
  170. package/lib/plugin/screenshotOnFail.js +9 -170
  171. package/lib/plugin/stepTimeout.js +3 -2
  172. package/lib/recorder.js +1 -1
  173. package/lib/rerun.js +2 -1
  174. package/lib/result.js +2 -1
  175. package/lib/step/base.js +10 -9
  176. package/lib/step/comment.js +2 -2
  177. package/lib/step/config.js +15 -2
  178. package/lib/step/helper.js +4 -4
  179. package/lib/step/meta.js +3 -3
  180. package/lib/step/record.js +5 -5
  181. package/lib/store.js +72 -3
  182. package/lib/translation.js +2 -1
  183. package/lib/utils/loaderCheck.js +28 -0
  184. package/lib/utils/mask_data.js +2 -1
  185. package/lib/utils/pluginParser.js +151 -0
  186. package/lib/utils/trace.js +297 -0
  187. package/lib/utils/typescript.js +188 -23
  188. package/lib/utils.js +77 -3
  189. package/lib/workers.js +65 -40
  190. package/package.json +35 -30
  191. package/typings/index.d.ts +119 -8
  192. package/typings/promiseBasedTypes.d.ts +3158 -6065
  193. package/typings/types.d.ts +3453 -6494
  194. package/docs/webapi/amOnPage.mustache +0 -11
  195. package/docs/webapi/appendField.mustache +0 -11
  196. package/docs/webapi/attachFile.mustache +0 -12
  197. package/docs/webapi/blur.mustache +0 -18
  198. package/docs/webapi/checkOption.mustache +0 -13
  199. package/docs/webapi/clearCookie.mustache +0 -9
  200. package/docs/webapi/clearField.mustache +0 -9
  201. package/docs/webapi/click.mustache +0 -29
  202. package/docs/webapi/clickLink.mustache +0 -8
  203. package/docs/webapi/closeCurrentTab.mustache +0 -7
  204. package/docs/webapi/closeOtherTabs.mustache +0 -8
  205. package/docs/webapi/dontSee.mustache +0 -11
  206. package/docs/webapi/dontSeeCheckboxIsChecked.mustache +0 -10
  207. package/docs/webapi/dontSeeCookie.mustache +0 -8
  208. package/docs/webapi/dontSeeCurrentUrlEquals.mustache +0 -10
  209. package/docs/webapi/dontSeeElement.mustache +0 -8
  210. package/docs/webapi/dontSeeElementInDOM.mustache +0 -8
  211. package/docs/webapi/dontSeeInCurrentUrl.mustache +0 -4
  212. package/docs/webapi/dontSeeInField.mustache +0 -11
  213. package/docs/webapi/dontSeeInSource.mustache +0 -8
  214. package/docs/webapi/dontSeeInTitle.mustache +0 -8
  215. package/docs/webapi/dontSeeTraffic.mustache +0 -13
  216. package/docs/webapi/doubleClick.mustache +0 -13
  217. package/docs/webapi/downloadFile.mustache +0 -12
  218. package/docs/webapi/dragAndDrop.mustache +0 -9
  219. package/docs/webapi/dragSlider.mustache +0 -11
  220. package/docs/webapi/executeAsyncScript.mustache +0 -24
  221. package/docs/webapi/executeScript.mustache +0 -26
  222. package/docs/webapi/fillField.mustache +0 -16
  223. package/docs/webapi/flushNetworkTraffics.mustache +0 -5
  224. package/docs/webapi/focus.mustache +0 -13
  225. package/docs/webapi/forceClick.mustache +0 -28
  226. package/docs/webapi/forceRightClick.mustache +0 -18
  227. package/docs/webapi/grabAllWindowHandles.mustache +0 -7
  228. package/docs/webapi/grabAttributeFrom.mustache +0 -10
  229. package/docs/webapi/grabAttributeFromAll.mustache +0 -9
  230. package/docs/webapi/grabBrowserLogs.mustache +0 -9
  231. package/docs/webapi/grabCookie.mustache +0 -11
  232. package/docs/webapi/grabCssPropertyFrom.mustache +0 -11
  233. package/docs/webapi/grabCssPropertyFromAll.mustache +0 -10
  234. package/docs/webapi/grabCurrentUrl.mustache +0 -9
  235. package/docs/webapi/grabCurrentWindowHandle.mustache +0 -6
  236. package/docs/webapi/grabDataFromPerformanceTiming.mustache +0 -20
  237. package/docs/webapi/grabElementBoundingRect.mustache +0 -20
  238. package/docs/webapi/grabGeoLocation.mustache +0 -8
  239. package/docs/webapi/grabHTMLFrom.mustache +0 -10
  240. package/docs/webapi/grabHTMLFromAll.mustache +0 -9
  241. package/docs/webapi/grabNumberOfOpenTabs.mustache +0 -8
  242. package/docs/webapi/grabNumberOfVisibleElements.mustache +0 -9
  243. package/docs/webapi/grabPageScrollPosition.mustache +0 -8
  244. package/docs/webapi/grabPopupText.mustache +0 -5
  245. package/docs/webapi/grabRecordedNetworkTraffics.mustache +0 -10
  246. package/docs/webapi/grabSource.mustache +0 -8
  247. package/docs/webapi/grabTextFrom.mustache +0 -10
  248. package/docs/webapi/grabTextFromAll.mustache +0 -9
  249. package/docs/webapi/grabTitle.mustache +0 -8
  250. package/docs/webapi/grabValueFrom.mustache +0 -9
  251. package/docs/webapi/grabValueFromAll.mustache +0 -8
  252. package/docs/webapi/grabWebElement.mustache +0 -9
  253. package/docs/webapi/grabWebElements.mustache +0 -9
  254. package/docs/webapi/moveCursorTo.mustache +0 -12
  255. package/docs/webapi/openNewTab.mustache +0 -7
  256. package/docs/webapi/pressKey.mustache +0 -12
  257. package/docs/webapi/pressKeyDown.mustache +0 -12
  258. package/docs/webapi/pressKeyUp.mustache +0 -12
  259. package/docs/webapi/pressKeyWithKeyNormalization.mustache +0 -60
  260. package/docs/webapi/refreshPage.mustache +0 -6
  261. package/docs/webapi/resizeWindow.mustache +0 -6
  262. package/docs/webapi/rightClick.mustache +0 -14
  263. package/docs/webapi/saveElementScreenshot.mustache +0 -10
  264. package/docs/webapi/saveScreenshot.mustache +0 -12
  265. package/docs/webapi/say.mustache +0 -10
  266. package/docs/webapi/scrollIntoView.mustache +0 -11
  267. package/docs/webapi/scrollPageToBottom.mustache +0 -6
  268. package/docs/webapi/scrollPageToTop.mustache +0 -6
  269. package/docs/webapi/scrollTo.mustache +0 -12
  270. package/docs/webapi/see.mustache +0 -11
  271. package/docs/webapi/seeAttributesOnElements.mustache +0 -9
  272. package/docs/webapi/seeCheckboxIsChecked.mustache +0 -10
  273. package/docs/webapi/seeCookie.mustache +0 -8
  274. package/docs/webapi/seeCssPropertiesOnElements.mustache +0 -9
  275. package/docs/webapi/seeCurrentUrlEquals.mustache +0 -11
  276. package/docs/webapi/seeElement.mustache +0 -8
  277. package/docs/webapi/seeElementInDOM.mustache +0 -8
  278. package/docs/webapi/seeInCurrentUrl.mustache +0 -8
  279. package/docs/webapi/seeInField.mustache +0 -12
  280. package/docs/webapi/seeInPopup.mustache +0 -8
  281. package/docs/webapi/seeInSource.mustache +0 -7
  282. package/docs/webapi/seeInTitle.mustache +0 -8
  283. package/docs/webapi/seeNumberOfElements.mustache +0 -11
  284. package/docs/webapi/seeNumberOfVisibleElements.mustache +0 -10
  285. package/docs/webapi/seeTextEquals.mustache +0 -9
  286. package/docs/webapi/seeTitleEquals.mustache +0 -8
  287. package/docs/webapi/seeTraffic.mustache +0 -36
  288. package/docs/webapi/selectOption.mustache +0 -21
  289. package/docs/webapi/setCookie.mustache +0 -16
  290. package/docs/webapi/setGeoLocation.mustache +0 -12
  291. package/docs/webapi/startRecordingTraffic.mustache +0 -8
  292. package/docs/webapi/startRecordingWebSocketMessages.mustache +0 -8
  293. package/docs/webapi/stopRecordingTraffic.mustache +0 -5
  294. package/docs/webapi/stopRecordingWebSocketMessages.mustache +0 -7
  295. package/docs/webapi/switchTo.mustache +0 -9
  296. package/docs/webapi/switchToNextTab.mustache +0 -10
  297. package/docs/webapi/switchToPreviousTab.mustache +0 -10
  298. package/docs/webapi/type.mustache +0 -21
  299. package/docs/webapi/uncheckOption.mustache +0 -13
  300. package/docs/webapi/wait.mustache +0 -8
  301. package/docs/webapi/waitForClickable.mustache +0 -11
  302. package/docs/webapi/waitForCookie.mustache +0 -9
  303. package/docs/webapi/waitForDetached.mustache +0 -10
  304. package/docs/webapi/waitForDisabled.mustache +0 -6
  305. package/docs/webapi/waitForElement.mustache +0 -11
  306. package/docs/webapi/waitForEnabled.mustache +0 -6
  307. package/docs/webapi/waitForFunction.mustache +0 -17
  308. package/docs/webapi/waitForInvisible.mustache +0 -10
  309. package/docs/webapi/waitForNumberOfTabs.mustache +0 -9
  310. package/docs/webapi/waitForText.mustache +0 -13
  311. package/docs/webapi/waitForValue.mustache +0 -10
  312. package/docs/webapi/waitForVisible.mustache +0 -10
  313. package/docs/webapi/waitInUrl.mustache +0 -9
  314. package/docs/webapi/waitNumberOfVisibleElements.mustache +0 -10
  315. package/docs/webapi/waitToHide.mustache +0 -10
  316. package/docs/webapi/waitUrlEquals.mustache +0 -10
  317. package/lib/helper/AI.js +0 -214
  318. package/lib/helper/Mochawesome.js +0 -96
  319. package/lib/helper/extras/PlaywrightReactVueLocator.js +0 -52
  320. package/lib/helper/extras/React.js +0 -65
  321. package/lib/listener/enhancedGlobalRetry.js +0 -110
  322. package/lib/plugin/enhancedRetryFailedStep.js +0 -99
  323. package/lib/plugin/htmlReporter.js +0 -3648
  324. package/lib/plugin/stepByStepReport.js +0 -427
  325. package/lib/plugin/subtitles.js +0 -89
  326. 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,56 +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
-
47
- /**
48
- * Creates a Playwright selector engine factory for a custom locator strategy.
49
- * @param {string} name - Strategy name for error messages
50
- * @param {Function} func - The locator function (selector, root) => Element|Element[]
51
- * @returns {Function} Selector engine factory
52
- */
53
- function createCustomSelectorEngine(name, func) {
54
- return () => ({
55
- create: () => null,
56
- query(root, selector) {
57
- if (!root) return null
58
- try {
59
- const result = func(selector, root)
60
- return Array.isArray(result) ? result[0] : result
61
- } catch (e) {
62
- return null
63
- }
64
- },
65
- queryAll(root, selector) {
66
- if (!root) return []
67
- try {
68
- const result = func(selector, root)
69
- return Array.isArray(result) ? result : result ? [result] : []
70
- } catch (e) {
71
- return []
72
- }
73
- },
74
- })
75
- }
76
49
 
77
50
  const popupStore = new Popup()
78
51
  const consoleLogStore = new Console()
@@ -130,7 +103,6 @@ const pathSeparator = path.sep
130
103
  * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
131
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).
132
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).
133
- * @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}"]`) } }`
134
106
  * @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object)
135
107
  * passed directly to `browser.newContext`.
136
108
  * If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`),
@@ -386,18 +358,6 @@ class Playwright extends Helper {
386
358
  this.recordedWebSocketMessagesAtLeastOnce = false
387
359
  this.cdpSession = null
388
360
 
389
- // Filter out invalid customLocatorStrategies (empty arrays, objects without functions)
390
- // This can happen in worker threads where config is serialized/deserialized
391
- this.customLocatorStrategies = this._parseCustomLocatorStrategies(config.customLocatorStrategies)
392
- this._customLocatorsRegistered = false
393
-
394
- // Add custom locator strategies to global registry for early registration
395
- if (this.customLocatorStrategies) {
396
- for (const [name, func] of Object.entries(this.customLocatorStrategies)) {
397
- globalCustomLocatorStrategies.set(name, func)
398
- }
399
- }
400
-
401
361
  // Add test failure tracking to prevent false positives
402
362
  this.testFailures = []
403
363
  this.hasCleanupError = false
@@ -437,6 +397,7 @@ class Playwright extends Helper {
437
397
  highlightElement: false,
438
398
  storageState: undefined,
439
399
  onResponse: null,
400
+ strict: false,
440
401
  }
441
402
 
442
403
  process.env.testIdAttribute = 'data-testid'
@@ -485,7 +446,7 @@ class Playwright extends Helper {
485
446
  this.options.recordVideo = { size }
486
447
  }
487
448
  if (this.options.recordVideo && !this.options.recordVideo.dir) {
488
- this.options.recordVideo.dir = `${global.output_dir}/videos/`
449
+ this.options.recordVideo.dir = `${store.outputDir}/videos/`
489
450
  }
490
451
  this.isRemoteBrowser = !!this.playwrightOptions.browserWSEndpoint
491
452
  this.isElectron = this.options.browser === 'electron'
@@ -543,32 +504,22 @@ class Playwright extends Helper {
543
504
  }
544
505
  }
545
506
 
546
- // Ensure custom locators from this instance are in the global registry
547
- // This is critical for worker threads where globalCustomLocatorStrategies is a new Map
548
- if (this.customLocatorStrategies) {
549
- for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
550
- if (!globalCustomLocatorStrategies.has(strategyName)) {
551
- globalCustomLocatorStrategies.set(strategyName, strategyFunction)
552
- }
553
- }
554
- }
555
-
556
507
  // register an internal selector engine for reading value property of elements in a selector
557
508
  try {
558
509
  // Always wrap in try-catch since selectors might be registered globally across workers
559
510
  // Check global flag to avoid re-registration in worker processes
560
- if (!global.__playwrightSelectorsRegistered) {
511
+ if (!defaultSelectorEnginesInitialized) {
561
512
  try {
562
513
  await playwright.selectors.register('__value', createValueEngine)
563
514
  await playwright.selectors.register('__disabled', createDisabledEngine)
564
- global.__playwrightSelectorsRegistered = true
515
+ defaultSelectorEnginesInitialized = true
565
516
  defaultSelectorEnginesInitialized = true
566
517
  } catch (e) {
567
518
  if (!e.message.includes('already registered')) {
568
519
  throw e
569
520
  }
570
521
  // Selector already registered globally by another worker
571
- global.__playwrightSelectorsRegistered = true
522
+ defaultSelectorEnginesInitialized = true
572
523
  defaultSelectorEnginesInitialized = true
573
524
  }
574
525
  } else {
@@ -583,28 +534,11 @@ class Playwright extends Helper {
583
534
  // Ignore if already set
584
535
  }
585
536
  }
586
-
587
- // Register all custom locator strategies from the global registry
588
- await this._registerGlobalCustomLocators()
589
537
  } catch (e) {
590
538
  console.warn(e)
591
539
  }
592
540
  }
593
541
 
594
- async _registerGlobalCustomLocators() {
595
- for (const [name, func] of globalCustomLocatorStrategies.entries()) {
596
- if (registeredCustomLocatorStrategies.has(name)) continue
597
- try {
598
- await playwright.selectors.register(name, createCustomSelectorEngine(name, func))
599
- registeredCustomLocatorStrategies.add(name)
600
- } catch (e) {
601
- if (!e.message.includes('already registered')) {
602
- this.debugSection('Custom Locator', `Failed to register '${name}': ${e.message}`)
603
- }
604
- }
605
- }
606
- }
607
-
608
542
  _beforeSuite() {
609
543
  // Skip browser start in dry-run mode (used by check command)
610
544
  if (store.dryRun) {
@@ -678,7 +612,7 @@ class Playwright extends Helper {
678
612
  if (this.options.recordVideo) contextOptions.recordVideo = this.options.recordVideo
679
613
  if (this.options.recordHar) {
680
614
  const harExt = this.options.recordHar.content && this.options.recordHar.content === 'attach' ? 'zip' : 'har'
681
- 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}`
682
616
  const dir = path.dirname(fileName)
683
617
  if (!fileExists(dir)) fs.mkdirSync(dir)
684
618
  this.options.recordHar.path = fileName
@@ -821,6 +755,11 @@ class Playwright extends Helper {
821
755
  }
822
756
 
823
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
+
824
763
  // Stop browser after suite completes
825
764
  // For restart strategies: stop after each suite
826
765
  // For session mode (restart:false): stop after the last suite
@@ -1266,33 +1205,6 @@ class Playwright extends Helper {
1266
1205
  return this.browser
1267
1206
  }
1268
1207
 
1269
- _hasCustomLocatorStrategies() {
1270
- return !!(this.customLocatorStrategies && Object.keys(this.customLocatorStrategies).length > 0)
1271
- }
1272
-
1273
- _parseCustomLocatorStrategies(strategies) {
1274
- if (typeof strategies !== 'object' || strategies === null) return null
1275
- const hasValidFunctions = Object.values(strategies).some(v => typeof v === 'function')
1276
- return hasValidFunctions ? strategies : null
1277
- }
1278
-
1279
- _lookupCustomLocator(customStrategy) {
1280
- if (!this._hasCustomLocatorStrategies()) return null
1281
- const strategy = this.customLocatorStrategies[customStrategy]
1282
- return typeof strategy === 'function' ? strategy : null
1283
- }
1284
-
1285
- _isCustomLocator(locator) {
1286
- const locatorObj = new Locator(locator)
1287
- if (!locatorObj.isCustom()) return false
1288
- if (this._lookupCustomLocator(locatorObj.type)) return true
1289
- throw new Error('Please define "customLocatorStrategies" as an Object and the Locator Strategy as a "function".')
1290
- }
1291
-
1292
- _isCustomLocatorStrategyDefined() {
1293
- return this._hasCustomLocatorStrategies()
1294
- }
1295
-
1296
1208
  /**
1297
1209
  * Create a new browser context with a page. \
1298
1210
  * Usually it should be run from a custom helper after call of `_startBrowser()`
@@ -1304,30 +1216,11 @@ class Playwright extends Helper {
1304
1216
  }
1305
1217
  this.browserContext = await this.browser.newContext(contextOptions)
1306
1218
 
1307
- // Register custom locator strategies for this context
1308
- await this._registerCustomLocatorStrategies()
1309
-
1310
1219
  const page = await this.browserContext.newPage()
1311
1220
  targetCreatedHandler.call(this, page)
1312
1221
  await this._setPage(page)
1313
1222
  }
1314
1223
 
1315
- async _registerCustomLocatorStrategies() {
1316
- if (!this._hasCustomLocatorStrategies()) return
1317
-
1318
- for (const [name, func] of Object.entries(this.customLocatorStrategies)) {
1319
- if (registeredCustomLocatorStrategies.has(name)) continue
1320
- try {
1321
- await playwright.selectors.register(name, createCustomSelectorEngine(name, func))
1322
- registeredCustomLocatorStrategies.add(name)
1323
- } catch (e) {
1324
- if (!e.message.includes('already registered')) {
1325
- this.debugSection('Custom Locator', `Failed to register '${name}': ${e.message}`)
1326
- }
1327
- }
1328
- }
1329
- }
1330
-
1331
1224
  _getType() {
1332
1225
  return this.browser._type
1333
1226
  }
@@ -1606,8 +1499,23 @@ class Playwright extends Helper {
1606
1499
  *
1607
1500
  */
1608
1501
  async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
1609
- const el = await this._locateElement(locator)
1610
- 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
+ }
1611
1519
 
1612
1520
  // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
1613
1521
  const { x, y } = await clickablePoint(el)
@@ -1731,7 +1639,7 @@ class Playwright extends Helper {
1731
1639
  * @returns Promise<void>
1732
1640
  */
1733
1641
  async replayFromHar(harFilePath, opts) {
1734
- const file = path.join(global.codecept_dir, harFilePath)
1642
+ const file = path.join(store.codeceptDir, harFilePath)
1735
1643
 
1736
1644
  if (!fileExists(file)) {
1737
1645
  throw new Error(`File at ${file} cannot be found on local system`)
@@ -1871,7 +1779,11 @@ class Playwright extends Helper {
1871
1779
  */
1872
1780
  async _locateElement(locator) {
1873
1781
  const context = await this._getContext()
1874
- 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)
1875
1787
  }
1876
1788
 
1877
1789
  /**
@@ -1886,7 +1798,7 @@ class Playwright extends Helper {
1886
1798
  const context = providedContext || (await this._getContext())
1887
1799
  const els = await findCheckable.call(this, locator, context)
1888
1800
  assertElementExists(els[0], locator, 'Checkbox or radio')
1889
- return els[0]
1801
+ return selectElement(els, locator, this)
1890
1802
  }
1891
1803
 
1892
1804
  /**
@@ -2054,8 +1966,15 @@ class Playwright extends Helper {
2054
1966
  * {{> seeElement }}
2055
1967
  *
2056
1968
  */
2057
- async seeElement(locator) {
2058
- 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
+ }
2059
1978
  els = await Promise.all(els.map(el => el.isVisible()))
2060
1979
  try {
2061
1980
  return empty('visible elements').negate(els.filter(v => v).fill('ELEMENT'))
@@ -2068,8 +1987,15 @@ class Playwright extends Helper {
2068
1987
  * {{> dontSeeElement }}
2069
1988
  *
2070
1989
  */
2071
- async dontSeeElement(locator) {
2072
- 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
+ }
2073
1999
  els = await Promise.all(els.map(el => el.isVisible()))
2074
2000
  try {
2075
2001
  return empty('visible elements').assert(els.filter(v => v).fill('ELEMENT'))
@@ -2124,7 +2050,7 @@ class Playwright extends Helper {
2124
2050
  const filePath = await download.path()
2125
2051
  fileName = fileName || `downloads/${path.basename(filePath)}`
2126
2052
 
2127
- const downloadPath = path.join(global.output_dir, fileName)
2053
+ const downloadPath = path.join(store.outputDir, fileName)
2128
2054
  if (!fs.existsSync(path.dirname(downloadPath))) {
2129
2055
  fs.mkdirSync(path.dirname(downloadPath), '0777')
2130
2056
  }
@@ -2155,15 +2081,6 @@ class Playwright extends Helper {
2155
2081
  return proceedClick.call(this, locator, context, options)
2156
2082
  }
2157
2083
 
2158
- /**
2159
- * Clicks link and waits for navigation (deprecated)
2160
- */
2161
- async clickLink(locator, context = null) {
2162
- console.log('clickLink deprecated: Playwright automatically waits for navigation to happen.')
2163
- console.log('Replace I.clickLink with I.click')
2164
- return this.click(locator, context)
2165
- }
2166
-
2167
2084
  /**
2168
2085
  * {{> forceClick }}
2169
2086
  */
@@ -2308,6 +2225,7 @@ class Playwright extends Helper {
2308
2225
  * {{> pressKeyWithKeyNormalization }}
2309
2226
  */
2310
2227
  async pressKey(key) {
2228
+ await checkFocusBeforePressKey(this, key)
2311
2229
  const modifiers = []
2312
2230
  if (Array.isArray(key)) {
2313
2231
  for (let k of key) {
@@ -2336,6 +2254,8 @@ class Playwright extends Helper {
2336
2254
  * {{> type }}
2337
2255
  */
2338
2256
  async type(keys, delay = null) {
2257
+ await checkFocusBeforeType(this)
2258
+
2339
2259
  // Always use page.keyboard.type for any string (including single character and national characters).
2340
2260
  if (!Array.isArray(keys)) {
2341
2261
  keys = keys.toString()
@@ -2355,43 +2275,33 @@ class Playwright extends Helper {
2355
2275
  * {{> fillField }}
2356
2276
  *
2357
2277
  */
2358
- async fillField(field, value) {
2359
- const els = await findFields.call(this, field)
2278
+ async fillField(field, value, context = null) {
2279
+ const els = await findFields.call(this, field, context)
2360
2280
  assertElementExists(els, field, 'Field')
2361
- 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
+ }
2362
2288
 
2363
2289
  await el.clear()
2364
2290
  if (store.debugMode) this.debugSection('Focused', await elToString(el, 1))
2365
2291
 
2366
- await highlightActiveElement.call(this, el)
2367
-
2368
2292
  await el.type(value.toString(), { delay: this.options.pressKeyDelay })
2369
2293
 
2370
2294
  return this._waitForAction()
2371
2295
  }
2372
2296
 
2373
2297
  /**
2374
- * Clears the text input element: `<input>`, `<textarea>` or `[contenteditable]` .
2375
- *
2376
- *
2377
- * Examples:
2378
- *
2379
- * ```js
2380
- * I.clearField('.text-area')
2381
- *
2382
- * // if this doesn't work use force option
2383
- * I.clearField('#submit', { force: true })
2384
- * ```
2385
- * Use `force` to bypass the [actionability](https://playwright.dev/docs/actionability) checks.
2386
- *
2387
- * @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
2388
- * @param {any} [options] [Additional options](https://playwright.dev/docs/api/class-locator#locator-clear) for available options object as 2nd argument.
2298
+ * {{> clearField }}
2389
2299
  */
2390
- async clearField(locator, options = {}) {
2391
- const els = await findFields.call(this, locator)
2300
+ async clearField(locator, context = null) {
2301
+ const els = await findFields.call(this, locator, context)
2392
2302
  assertElementExists(els, locator, 'Field to clear')
2393
2303
 
2394
- const el = els[0]
2304
+ const el = selectElement(els, locator, this)
2395
2305
 
2396
2306
  await highlightActiveElement.call(this, el)
2397
2307
 
@@ -2403,68 +2313,101 @@ class Playwright extends Helper {
2403
2313
  /**
2404
2314
  * {{> appendField }}
2405
2315
  */
2406
- async appendField(field, value) {
2407
- const els = await findFields.call(this, field)
2316
+ async appendField(field, value, context = null) {
2317
+ const els = await findFields.call(this, field, context)
2408
2318
  assertElementExists(els, field, 'Field')
2409
- await highlightActiveElement.call(this, els[0])
2410
- await els[0].press('End')
2411
- 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 })
2412
2323
  return this._waitForAction()
2413
2324
  }
2414
2325
 
2415
2326
  /**
2416
2327
  * {{> seeInField }}
2417
2328
  */
2418
- async seeInField(field, value) {
2329
+ async seeInField(field, value, context = null) {
2419
2330
  const _value = typeof value === 'boolean' ? value : value.toString()
2420
- return proceedSeeInField.call(this, 'assert', field, _value)
2331
+ return proceedSeeInField.call(this, 'assert', field, _value, context)
2421
2332
  }
2422
2333
 
2423
2334
  /**
2424
2335
  * {{> dontSeeInField }}
2425
2336
  */
2426
- async dontSeeInField(field, value) {
2337
+ async dontSeeInField(field, value, context = null) {
2427
2338
  const _value = typeof value === 'boolean' ? value : value.toString()
2428
- return proceedSeeInField.call(this, 'negate', field, _value)
2339
+ return proceedSeeInField.call(this, 'negate', field, _value, context)
2429
2340
  }
2430
2341
 
2431
2342
  /**
2432
2343
  * {{> attachFile }}
2433
2344
  *
2434
2345
  */
2435
- async attachFile(locator, pathToFile) {
2436
- const file = path.join(global.codecept_dir, pathToFile)
2346
+ async attachFile(locator, pathToFile, context = null) {
2347
+ const file = path.join(store.codeceptDir, pathToFile)
2437
2348
 
2438
2349
  if (!fileExists(file)) {
2439
2350
  throw new Error(`File at ${file} can not be found on local system`)
2440
2351
  }
2441
- const els = await findFields.call(this, locator)
2442
- assertElementExists(els, locator, 'Field')
2443
- 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)
2444
2372
  return this._waitForAction()
2445
2373
  }
2446
2374
 
2447
2375
  /**
2448
2376
  * {{> selectOption }}
2449
2377
  */
2450
- async selectOption(select, option) {
2451
- const els = await findFields.call(this, select)
2452
- assertElementExists(els, select, 'Selectable field')
2453
- const el = els[0]
2378
+ async selectOption(select, option, context = null) {
2379
+ const pageContext = await this.context
2380
+ const matchedLocator = new Locator(select)
2454
2381
 
2455
- await highlightActiveElement.call(this, el)
2456
- 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
+ }
2457
2388
 
2458
- try {
2459
- optionToSelect = (await el.locator('option', { hasText: option }).textContent()).trim()
2460
- } catch (e) {
2461
- 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)
2462
2395
  }
2463
2396
 
2464
- 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)
2465
2402
 
2466
- await el.selectOption(option)
2467
- 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)
2468
2411
  }
2469
2412
 
2470
2413
  /**
@@ -2505,6 +2448,26 @@ class Playwright extends Helper {
2505
2448
  urlEquals(this.options.url).negate(url, await this._getPageUrl())
2506
2449
  }
2507
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
+
2508
2471
  /**
2509
2472
  * {{> see }}
2510
2473
  *
@@ -2707,8 +2670,11 @@ class Playwright extends Helper {
2707
2670
  * @returns {Promise<any>}
2708
2671
  */
2709
2672
  async executeScript(fn, arg) {
2710
- if (this.context && this.context.constructor.name === 'FrameLocator') {
2711
- // 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') {
2712
2678
  return this.context.locator(':root').evaluate(fn, arg)
2713
2679
  }
2714
2680
  return this.page.evaluate.apply(this.page, [fn, arg])
@@ -2722,23 +2688,12 @@ class Playwright extends Helper {
2722
2688
  _contextLocator(locator) {
2723
2689
  const locatorObj = new Locator(locator, 'css')
2724
2690
 
2725
- // Handle custom locators differently
2726
- if (locatorObj.isCustom()) {
2727
- return buildCustomLocatorString(locatorObj)
2728
- }
2729
-
2730
2691
  locator = buildLocatorString(locatorObj)
2731
2692
 
2732
2693
  if (this.contextLocator) {
2733
2694
  const contextLocatorObj = new Locator(this.contextLocator, 'css')
2734
- if (contextLocatorObj.isCustom()) {
2735
- // For custom context locators, we can't use the >> syntax
2736
- // Instead, we'll need to handle this differently in the calling methods
2737
- return locator
2738
- } else {
2739
- const contextLocator = buildLocatorString(contextLocatorObj)
2740
- locator = `${contextLocator} >> ${locator}`
2741
- }
2695
+ const contextLocator = buildLocatorString(contextLocatorObj)
2696
+ locator = `${contextLocator} >> ${locator}`
2742
2697
  }
2743
2698
 
2744
2699
  return locator
@@ -2749,43 +2704,28 @@ class Playwright extends Helper {
2749
2704
  *
2750
2705
  */
2751
2706
  async grabTextFrom(locator) {
2752
- // Handle role locators with text/exact options
2753
- if (isRoleLocatorObject(locator)) {
2754
- const elements = await handleRoleLocator(this.page, locator)
2755
- if (elements && elements.length > 0) {
2756
- const text = await elements[0].textContent()
2757
- assertElementExists(text, JSON.stringify(locator))
2758
- this.debugSection('Text', text)
2759
- return text
2760
- }
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
2761
2713
  }
2762
2714
 
2763
2715
  const locatorObj = new Locator(locator, 'css')
2764
2716
 
2765
- if (locatorObj.isCustom()) {
2766
- // For custom locators, find the element first
2767
- const elements = await findCustomElements.call(this, this.page, locatorObj)
2768
- if (elements.length === 0) {
2769
- throw new Error(`Element not found: ${locatorObj.toString()}`)
2770
- }
2771
- const text = await elements[0].textContent()
2772
- assertElementExists(text, locatorObj.toString())
2717
+ locator = this._contextLocator(locator)
2718
+ try {
2719
+ const text = await this.page.textContent(locator)
2720
+ assertElementExists(text, locator)
2773
2721
  this.debugSection('Text', text)
2774
2722
  return text
2775
- } else {
2776
- locator = this._contextLocator(locator)
2777
- try {
2778
- const text = await this.page.textContent(locator)
2779
- assertElementExists(text, locator)
2780
- this.debugSection('Text', text)
2781
- return text
2782
- } catch (error) {
2783
- // Convert Playwright timeout errors to ElementNotFound for consistency
2784
- if (error.message && error.message.includes('Timeout')) {
2785
- throw new ElementNotFound(locator, 'text')
2786
- }
2787
- 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')
2788
2727
  }
2728
+ throw error
2789
2729
  }
2790
2730
  }
2791
2731
 
@@ -2997,7 +2937,7 @@ class Playwright extends Helper {
2997
2937
  const els = await this._locate(matchedLocator)
2998
2938
  assertElementExists(els, locator)
2999
2939
  const snapshot = await els[0].ariaSnapshot()
3000
- this.debugSection('Aria Snapshot', snapshot)
2940
+ this.debugSection('Aria Snapshot', `${snapshot.split('\n').length} lines`)
3001
2941
  return snapshot
3002
2942
  }
3003
2943
 
@@ -3350,24 +3290,13 @@ class Playwright extends Helper {
3350
3290
 
3351
3291
  const context = await this._getContext()
3352
3292
  try {
3353
- if (locator.isCustom()) {
3354
- // For custom locators, we need to use our custom element finding logic
3355
- const elements = await findCustomElements.call(this, context, locator)
3356
- if (elements.length === 0) {
3357
- throw new Error(`Custom locator ${locator.type}=${locator.value} not found`)
3358
- }
3359
- await elements[0].waitFor({ timeout: waitTimeout, state: 'attached' })
3360
- } else {
3361
- await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' })
3362
- }
3293
+ await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' })
3363
3294
  } catch (e) {
3364
3295
  throw new Error(`element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${e.message}`)
3365
3296
  }
3366
3297
  }
3367
3298
 
3368
3299
  /**
3369
- * This method accepts [React selectors](https://codecept.io/react).
3370
- *
3371
3300
  * {{> waitForVisible }}
3372
3301
  */
3373
3302
  async waitForVisible(locator, sec) {
@@ -3377,26 +3306,6 @@ class Playwright extends Helper {
3377
3306
  const context = await this._getContext()
3378
3307
  let count = 0
3379
3308
 
3380
- // Handle custom locators
3381
- if (locator.isCustom()) {
3382
- let waiter
3383
- do {
3384
- const elements = await findCustomElements.call(this, context, locator)
3385
- if (elements.length > 0) {
3386
- waiter = await elements[0].isVisible()
3387
- } else {
3388
- waiter = false
3389
- }
3390
- if (!waiter) {
3391
- await this.wait(1)
3392
- count += 1000
3393
- }
3394
- } while (!waiter && count <= waitTimeout)
3395
-
3396
- if (!waiter) throw new Error(`element (${locator.toString()}) still not visible after ${waitTimeout / 1000} sec.`)
3397
- return
3398
- }
3399
-
3400
3309
  // we have this as https://github.com/microsoft/playwright/issues/26829 is not yet implemented
3401
3310
  let waiter
3402
3311
  if (this.frame) {
@@ -3500,7 +3409,7 @@ class Playwright extends Helper {
3500
3409
  }
3501
3410
 
3502
3411
  async _getContext() {
3503
- if ((this.context && this.context.constructor.name === 'FrameLocator') || this.context) {
3412
+ if (this.context) {
3504
3413
  return this.context
3505
3414
  }
3506
3415
  if (this.frame) {
@@ -3514,6 +3423,7 @@ class Playwright extends Helper {
3514
3423
  */
3515
3424
  async waitInUrl(urlPart, sec = null) {
3516
3425
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3426
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
3517
3427
 
3518
3428
  return this.page
3519
3429
  .waitForFunction(
@@ -3521,13 +3431,13 @@ class Playwright extends Helper {
3521
3431
  const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
3522
3432
  return currUrl.indexOf(urlPart) > -1
3523
3433
  },
3524
- urlPart,
3434
+ expectedUrl,
3525
3435
  { timeout: waitTimeout },
3526
3436
  )
3527
3437
  .catch(async e => {
3528
- const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
3438
+ const currUrl = await this._getPageUrl()
3529
3439
  if (/Timeout/i.test(e.message)) {
3530
- throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`)
3440
+ throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`)
3531
3441
  } else {
3532
3442
  throw e
3533
3443
  }
@@ -3539,29 +3449,50 @@ class Playwright extends Helper {
3539
3449
  */
3540
3450
  async waitUrlEquals(urlPart, sec = null) {
3541
3451
  const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
3452
+ const expectedUrl = resolveUrl(urlPart, this.options.url)
3542
3453
 
3543
- const baseUrl = this.options.url
3544
- if (urlPart.indexOf('http') < 0) {
3545
- 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
+ }
3546
3466
  }
3467
+ }
3547
3468
 
3548
- return this.page
3549
- .waitForFunction(
3550
- urlPart => {
3551
- const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href)))
3552
- 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
3553
3482
  },
3554
- urlPart,
3483
+ normalizedPath,
3555
3484
  { timeout: waitTimeout },
3556
3485
  )
3557
- .catch(async e => {
3558
- const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data.
3559
- if (/Timeout/i.test(e.message)) {
3560
- throw new Error(`expected url to be ${urlPart}, but found ${currUrl}`)
3561
- } else {
3562
- throw e
3563
- }
3564
- })
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
+ }
3565
3496
  }
3566
3497
 
3567
3498
  /**
@@ -3576,15 +3507,6 @@ class Playwright extends Helper {
3576
3507
  if (context) {
3577
3508
  const locator = new Locator(context, 'css')
3578
3509
  try {
3579
- if (locator.isCustom()) {
3580
- // For custom locators, find the elements first then check for text within them
3581
- const elements = await findCustomElements.call(this, contextObject, locator)
3582
- if (elements.length === 0) {
3583
- throw new Error(`Context element not found: ${locator.toString()}`)
3584
- }
3585
- return elements[0].locator(`text=${text}`).first().waitFor({ timeout: waitTimeout, state: 'visible' })
3586
- }
3587
-
3588
3510
  if (!locator.isXPath()) {
3589
3511
  return contextObject
3590
3512
  .locator(`${locator.simplify()} >> text=${text}`)
@@ -3827,7 +3749,7 @@ class Playwright extends Helper {
3827
3749
  if (!locator.isXPath()) {
3828
3750
  try {
3829
3751
  await context
3830
- .locator(`${locator.isCustom() ? `${locator.type}=${locator.value}` : locator.simplify()}`)
3752
+ .locator(locator.simplify())
3831
3753
  .first()
3832
3754
  .waitFor({ timeout: waitTimeout, state: 'detached' })
3833
3755
  } catch (e) {
@@ -4252,51 +4174,54 @@ class Playwright extends Helper {
4252
4174
 
4253
4175
  export default Playwright
4254
4176
 
4255
- function buildCustomLocatorString(locator) {
4256
- // Note: this.debug not available in standalone function, using console.log
4257
- console.log(`Building custom locator string: ${locator.type}=${locator.value}`)
4258
- return `${locator.type}=${locator.value}`
4259
- }
4260
-
4261
- function buildLocatorString(locator) {
4262
- if (locator.isCustom()) {
4263
- return buildCustomLocatorString(locator)
4264
- }
4177
+ export function buildLocatorString(locator) {
4265
4178
  if (locator.isXPath()) {
4266
- 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(' >> ')
4267
4192
  }
4268
4193
  return locator.simplify()
4269
4194
  }
4270
4195
 
4271
- /**
4272
- * Checks if a locator is a role locator object (e.g., {role: 'button', text: 'Submit', exact: true})
4273
- */
4274
- function isRoleLocatorObject(locator) {
4275
- return locator && typeof locator === 'object' && locator.role && !locator.type
4276
- }
4277
-
4278
4196
  /**
4279
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.
4280
4199
  * Returns elements array if role locator, null otherwise
4281
4200
  */
4282
4201
  async function handleRoleLocator(context, locator) {
4283
- if (!isRoleLocatorObject(locator)) return null
4202
+ const loc = new Locator(locator)
4203
+ if (!loc.isRole()) return null
4284
4204
 
4205
+ const roleObj = loc.locator || {}
4285
4206
  const options = {}
4286
- if (locator.text) options.name = locator.text
4287
- 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
4288
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
4289
4219
  return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
4290
4220
  }
4291
4221
 
4292
4222
  async function findElements(matcher, locator) {
4293
- // Check if locator is a Locator object with react/vue type, or a raw object with react/vue property
4294
- const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react
4295
- const isVueLocator = locator.type === 'vue' || (locator.locator && locator.locator.vue) || locator.vue
4296
4223
  const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
4297
4224
 
4298
- if (isReactLocator) return findReact(matcher, locator)
4299
- if (isVueLocator) return findVue(matcher, locator)
4300
4225
  if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
4301
4226
 
4302
4227
  // Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
@@ -4305,122 +4230,12 @@ async function findElements(matcher, locator) {
4305
4230
 
4306
4231
  locator = new Locator(locator, 'css')
4307
4232
 
4308
- // Handle custom locators directly instead of relying on Playwright selector engines
4309
- if (locator.isCustom()) {
4310
- return findCustomElements.call(this, matcher, locator)
4311
- }
4312
-
4313
- // Check if we have a custom context locator and need to search within it
4314
- if (this.contextLocator) {
4315
- const contextLocatorObj = new Locator(this.contextLocator, 'css')
4316
- if (contextLocatorObj.isCustom()) {
4317
- // Find the context elements first
4318
- const contextElements = await findCustomElements.call(this, matcher, contextLocatorObj)
4319
- if (contextElements.length === 0) {
4320
- return []
4321
- }
4322
-
4323
- // Search within the first context element
4324
- const locatorString = buildLocatorString(locator)
4325
- return contextElements[0].locator(locatorString).all()
4326
- }
4327
- }
4328
-
4329
4233
  const locatorString = buildLocatorString(locator)
4330
4234
 
4331
4235
  return matcher.locator(locatorString).all()
4332
4236
  }
4333
4237
 
4334
- async function findCustomElements(matcher, locator) {
4335
- // Always prioritize this.customLocatorStrategies which is set in constructor from config
4336
- // and persists in every worker thread instance
4337
- let strategyFunction = null
4338
-
4339
- if (this.customLocatorStrategies && this.customLocatorStrategies[locator.type]) {
4340
- strategyFunction = this.customLocatorStrategies[locator.type]
4341
- } else if (globalCustomLocatorStrategies.has(locator.type)) {
4342
- // Fallback to global registry (populated in constructor and _init)
4343
- strategyFunction = globalCustomLocatorStrategies.get(locator.type)
4344
- }
4345
-
4346
- if (!strategyFunction) {
4347
- throw new Error(`Custom locator strategy "${locator.type}" is not defined. Please define "customLocatorStrategies" in your configuration.`)
4348
- }
4349
-
4350
- // Execute the custom locator function in the browser context using page.evaluate
4351
- const page = matcher.constructor.name === 'Page' ? matcher : await matcher.page()
4352
-
4353
- const elements = await page.evaluate(
4354
- ({ strategyCode, selector }) => {
4355
- const strategy = new Function('return ' + strategyCode)()
4356
- const result = strategy(selector, document)
4357
-
4358
- // Convert NodeList or single element to array
4359
- if (result && result.nodeType) {
4360
- return [result]
4361
- } else if (result && result.length !== undefined) {
4362
- return Array.from(result)
4363
- } else if (Array.isArray(result)) {
4364
- return result
4365
- }
4366
-
4367
- return []
4368
- },
4369
- {
4370
- strategyCode: strategyFunction.toString(),
4371
- selector: locator.value,
4372
- },
4373
- )
4374
-
4375
- // Convert the found elements back to Playwright locators
4376
- if (elements.length === 0) {
4377
- return []
4378
- }
4379
-
4380
- // Create CSS selectors for the found elements and return as locators
4381
- const locators = []
4382
- const timestamp = Date.now()
4383
-
4384
- for (let i = 0; i < elements.length; i++) {
4385
- // Use a unique attribute approach to target specific elements
4386
- const uniqueAttr = `data-codecept-custom-${timestamp}-${i}`
4387
-
4388
- await page.evaluate(
4389
- ({ index, uniqueAttr, strategyCode, selector }) => {
4390
- // Re-execute the strategy to find elements and mark the specific one
4391
- const strategy = new Function('return ' + strategyCode)()
4392
- const result = strategy(selector, document)
4393
-
4394
- let elementsArray = []
4395
- if (result && result.nodeType) {
4396
- elementsArray = [result]
4397
- } else if (result && result.length !== undefined) {
4398
- elementsArray = Array.from(result)
4399
- } else if (Array.isArray(result)) {
4400
- elementsArray = result
4401
- }
4402
-
4403
- if (elementsArray[index]) {
4404
- elementsArray[index].setAttribute(uniqueAttr, 'true')
4405
- }
4406
- },
4407
- {
4408
- index: i,
4409
- uniqueAttr,
4410
- strategyCode: strategyFunction.toString(),
4411
- selector: locator.value,
4412
- },
4413
- )
4414
-
4415
- locators.push(page.locator(`[${uniqueAttr}="true"]`))
4416
- }
4417
-
4418
- return locators
4419
- }
4420
-
4421
4238
  async function findElement(matcher, locator) {
4422
- if (locator.react) return findReact(matcher, locator)
4423
- if (locator.vue) return findVue(matcher, locator)
4424
4239
  if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
4425
4240
 
4426
4241
  locator = new Locator(locator, 'css')
@@ -4455,16 +4270,22 @@ async function proceedClick(locator, context = null, options = {}) {
4455
4270
  assertElementExists(els, locator, 'Clickable element')
4456
4271
  }
4457
4272
 
4458
- await highlightActiveElement.call(this, els[0])
4459
- 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))
4460
4285
 
4461
- /*
4462
- using the force true options itself but instead dispatching a click
4463
- */
4464
4286
  if (options.force) {
4465
- await els[0].dispatchEvent('click')
4287
+ await element.dispatchEvent('click')
4466
4288
  } else {
4467
- const element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0]
4468
4289
  await element.click(options)
4469
4290
  }
4470
4291
  const promises = []
@@ -4479,7 +4300,10 @@ async function proceedClick(locator, context = null, options = {}) {
4479
4300
  async function findClickable(matcher, locator) {
4480
4301
  const matchedLocator = new Locator(locator)
4481
4302
 
4482
- 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
+ }
4483
4307
 
4484
4308
  let els
4485
4309
  const literal = xpathLocator.literal(matchedLocator.value)
@@ -4521,7 +4345,9 @@ async function proceedSee(assertType, text, context, strict = false) {
4521
4345
  if (!context) {
4522
4346
  const el = await this.context
4523
4347
 
4524
- 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()]
4525
4351
 
4526
4352
  description = 'web application'
4527
4353
  } else {
@@ -4579,38 +4405,92 @@ async function proceedIsChecked(assertType, option) {
4579
4405
  return truth(`checkable ${option}`, 'to be checked')[assertType](selected)
4580
4406
  }
4581
4407
 
4582
- async function findFields(locator) {
4583
- // Handle role locators with text/exact options
4584
- if (isRoleLocatorObject(locator)) {
4585
- const page = await this.page
4586
- const roleElements = await handleRoleLocator(page, locator)
4587
- 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]
4588
4414
  }
4589
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
+
4590
4424
  const matchedLocator = new Locator(locator)
4591
4425
  if (!matchedLocator.isFuzzy()) {
4592
- return this._locate(matchedLocator)
4426
+ return locateFn(matchedLocator)
4593
4427
  }
4594
4428
  const literal = xpathLocator.literal(locator)
4595
4429
 
4596
- let els = await this._locate({ xpath: Locator.field.labelEquals(literal) })
4430
+ let els = await locateFn({ xpath: Locator.field.labelEquals(literal) })
4597
4431
  if (els.length) {
4598
4432
  return els
4599
4433
  }
4600
4434
 
4601
- els = await this._locate({ xpath: Locator.field.labelContains(literal) })
4435
+ els = await locateFn({ xpath: Locator.field.labelContains(literal) })
4602
4436
  if (els.length) {
4603
4437
  return els
4604
4438
  }
4605
- els = await this._locate({ xpath: Locator.field.byName(literal) })
4439
+ els = await locateFn({ xpath: Locator.field.byName(literal) })
4606
4440
  if (els.length) {
4607
4441
  return els
4608
4442
  }
4609
- return this._locate({ css: locator })
4443
+ return locateFn({ css: locator })
4610
4444
  }
4611
4445
 
4612
- async function proceedSeeInField(assertType, field, value) {
4613
- 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)
4614
4494
  assertElementExists(els, field, 'Field')
4615
4495
  const el = els[0]
4616
4496
  const tag = await el.evaluate(e => e.tagName)
@@ -4724,6 +4604,13 @@ function assertElementExists(res, locator, prefix, suffix) {
4724
4604
  }
4725
4605
  }
4726
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
+
4727
4614
  function $XPath(element, selector) {
4728
4615
  const found = document.evaluate(selector, element || document.body, null, 5, null)
4729
4616
  const res = []
@@ -4743,12 +4630,16 @@ async function targetCreatedHandler(page) {
4743
4630
  .catch(() => null)
4744
4631
  .then(async () => {
4745
4632
  if (this.context && this.context._type === 'Frame') {
4746
- // we are inside iframe?
4633
+ // we are inside iframe via Frame object — refresh handle
4747
4634
  const frameEl = await this.context.frameElement()
4748
4635
  this.context = await frameEl.contentFrame()
4749
4636
  this.contextLocator = null
4750
4637
  return
4751
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
+ }
4752
4643
  // if context element was in iframe - keep it
4753
4644
  // if (await this.context.ownerFrame()) return;
4754
4645
  this.context = page
@@ -4929,7 +4820,7 @@ async function refreshContextSession() {
4929
4820
 
4930
4821
  function saveVideoForPage(page, name) {
4931
4822
  if (!page.video()) return null
4932
- 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`
4933
4824
  page
4934
4825
  .video()
4935
4826
  .saveAs(fileName)
@@ -4946,7 +4837,7 @@ async function saveTraceForContext(context, name) {
4946
4837
  if (!context) return
4947
4838
  if (!context.tracing) return
4948
4839
  try {
4949
- 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`
4950
4841
  await context.tracing.stop({ path: fileName })
4951
4842
  return fileName
4952
4843
  } catch (err) {