codeceptjs 4.0.0-rc.2 → 4.0.0-rc.20

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 (294) hide show
  1. package/README.md +39 -27
  2. package/bin/codecept.js +15 -2
  3. package/bin/codeceptq.js +49 -0
  4. package/bin/mcp-server.js +1187 -0
  5. package/docs/advanced.md +201 -0
  6. package/docs/agents.md +159 -0
  7. package/docs/ai.md +537 -0
  8. package/docs/aitrace.md +266 -0
  9. package/docs/api.md +332 -0
  10. package/docs/assertions.md +415 -0
  11. package/docs/auth.md +318 -0
  12. package/docs/basics.md +424 -0
  13. package/docs/bdd.md +539 -0
  14. package/docs/best.md +240 -0
  15. package/docs/bootstrap.md +132 -0
  16. package/docs/commands.md +352 -0
  17. package/docs/community-helpers.md +63 -0
  18. package/docs/configuration.md +230 -0
  19. package/docs/continuous-integration.md +497 -0
  20. package/docs/custom-helpers.md +297 -0
  21. package/docs/data.md +448 -0
  22. package/docs/debugging.md +332 -0
  23. package/docs/detox.md +235 -0
  24. package/docs/docker.md +136 -0
  25. package/docs/effects.md +179 -0
  26. package/docs/element-based-testing.md +295 -0
  27. package/docs/element-selection.md +125 -0
  28. package/docs/els.md +328 -0
  29. package/docs/examples.md +161 -0
  30. package/docs/heal.md +213 -0
  31. package/docs/helpers/ApiDataFactory.md +267 -0
  32. package/docs/helpers/Appium.md +1405 -0
  33. package/docs/helpers/Detox.md +665 -0
  34. package/docs/helpers/ExpectHelper.md +275 -0
  35. package/docs/helpers/FileSystem.md +152 -0
  36. package/docs/helpers/GraphQL.md +152 -0
  37. package/docs/helpers/GraphQLDataFactory.md +226 -0
  38. package/docs/helpers/JSONResponse.md +255 -0
  39. package/docs/helpers/Mochawesome.md +8 -0
  40. package/docs/helpers/MockRequest.md +377 -0
  41. package/docs/helpers/MockServer.md +212 -0
  42. package/docs/helpers/Playwright.md +2969 -0
  43. package/docs/helpers/Polly.md +44 -0
  44. package/docs/helpers/Protractor.md +1769 -0
  45. package/docs/helpers/Puppeteer-firefox.md +86 -0
  46. package/docs/helpers/Puppeteer.md +2690 -0
  47. package/docs/helpers/REST.md +289 -0
  48. package/docs/helpers/SoftExpectHelper.md +352 -0
  49. package/docs/helpers/WebDriver.md +2682 -0
  50. package/docs/hooks.md +339 -0
  51. package/docs/index.md +111 -0
  52. package/docs/installation.md +83 -0
  53. package/docs/internal-api.md +265 -0
  54. package/docs/internal-test-server.md +89 -0
  55. package/docs/locators.md +355 -0
  56. package/docs/mcp.md +485 -0
  57. package/docs/migration-4.md +556 -0
  58. package/docs/mobile.md +338 -0
  59. package/docs/pageobjects.md +399 -0
  60. package/docs/parallel.md +585 -0
  61. package/docs/playwright.md +714 -0
  62. package/docs/plugins.md +866 -0
  63. package/docs/puppeteer.md +314 -0
  64. package/docs/quickstart.md +120 -0
  65. package/docs/react.md +70 -0
  66. package/docs/reports.md +483 -0
  67. package/docs/retry.md +274 -0
  68. package/docs/secrets.md +150 -0
  69. package/docs/sessions.md +80 -0
  70. package/docs/shadow.md +68 -0
  71. package/docs/test-structure.md +275 -0
  72. package/docs/timeouts.md +183 -0
  73. package/docs/translation.md +247 -0
  74. package/docs/tutorial.md +271 -0
  75. package/docs/typescript.md +374 -0
  76. package/docs/web-element.md +251 -0
  77. package/docs/webdriver.md +708 -0
  78. package/docs/within.md +55 -0
  79. package/lib/ai.js +3 -2
  80. package/lib/aria.js +260 -0
  81. package/lib/assertions.js +18 -0
  82. package/lib/codecept.js +26 -23
  83. package/lib/command/check.js +2 -1
  84. package/lib/command/dryRun.js +24 -5
  85. package/lib/command/generate.js +2 -0
  86. package/lib/command/gherkin/snippets.js +5 -4
  87. package/lib/command/init.js +248 -269
  88. package/lib/command/list.js +150 -10
  89. package/lib/command/query.js +218 -0
  90. package/lib/command/run-multiple.js +2 -0
  91. package/lib/command/run-workers.js +2 -0
  92. package/lib/command/run.js +1 -1
  93. package/lib/command/workers/runTests.js +10 -10
  94. package/lib/config.js +77 -4
  95. package/lib/container.js +114 -17
  96. package/lib/effects.js +17 -0
  97. package/lib/element/WebElement.js +246 -2
  98. package/lib/els.js +12 -6
  99. package/lib/globals.js +32 -19
  100. package/lib/heal.js +4 -3
  101. package/lib/helper/ApiDataFactory.js +2 -1
  102. package/lib/helper/Appium.js +8 -8
  103. package/lib/helper/FileSystem.js +3 -2
  104. package/lib/helper/GraphQLDataFactory.js +2 -1
  105. package/lib/helper/Playwright.js +228 -162
  106. package/lib/helper/Puppeteer.js +208 -76
  107. package/lib/helper/WebDriver.js +173 -68
  108. package/lib/helper/errors/MultipleElementsFound.js +27 -110
  109. package/lib/helper/errors/NonFocusedType.js +8 -0
  110. package/lib/helper/extras/Download.js +45 -0
  111. package/lib/helper/extras/PlaywrightReactVueLocator.js +45 -36
  112. package/lib/helper/extras/elementSelection.js +58 -0
  113. package/lib/helper/extras/focusCheck.js +43 -0
  114. package/lib/helper/extras/richTextEditor.js +178 -0
  115. package/lib/helper/scripts/dropFile.js +11 -0
  116. package/lib/history.js +3 -2
  117. package/lib/html.js +103 -16
  118. package/lib/index.js +9 -1
  119. package/lib/listener/config.js +6 -4
  120. package/lib/listener/emptyRun.js +2 -1
  121. package/lib/listener/globalRetry.js +32 -6
  122. package/lib/listener/helpers.js +4 -1
  123. package/lib/listener/mocha.js +2 -1
  124. package/lib/listener/pageobjects.js +43 -0
  125. package/lib/listener/result.js +3 -2
  126. package/lib/locator.js +126 -3
  127. package/lib/mocha/cli.js +14 -2
  128. package/lib/mocha/factory.js +7 -2
  129. package/lib/mocha/inject.js +1 -1
  130. package/lib/mocha/scenarioConfig.js +2 -1
  131. package/lib/mocha/ui.js +5 -6
  132. package/lib/parser.js +2 -2
  133. package/lib/pause.js +38 -4
  134. package/lib/plugin/aiTrace.js +453 -0
  135. package/lib/plugin/analyze.js +1 -1
  136. package/lib/plugin/auth.js +3 -3
  137. package/lib/plugin/browser.js +77 -0
  138. package/lib/plugin/expose.js +159 -0
  139. package/lib/plugin/heal.js +44 -1
  140. package/lib/plugin/pageInfo.js +53 -49
  141. package/lib/plugin/pause.js +131 -0
  142. package/lib/plugin/pauseOnFail.js +10 -34
  143. package/lib/plugin/retryFailedStep.js +28 -19
  144. package/lib/plugin/screencast.js +287 -0
  145. package/lib/plugin/screenshot.js +563 -0
  146. package/lib/plugin/screenshotOnFail.js +8 -171
  147. package/lib/rerun.js +2 -1
  148. package/lib/result.js +2 -1
  149. package/lib/step/base.js +3 -2
  150. package/lib/step/config.js +15 -2
  151. package/lib/step/record.js +2 -2
  152. package/lib/store.js +72 -3
  153. package/lib/translation.js +2 -1
  154. package/lib/utils/mask_data.js +2 -1
  155. package/lib/utils/pluginParser.js +151 -0
  156. package/lib/utils/trace.js +297 -0
  157. package/lib/utils.js +77 -3
  158. package/lib/workers.js +52 -22
  159. package/package.json +19 -13
  160. package/typings/index.d.ts +19 -5
  161. package/docs/webapi/amOnPage.mustache +0 -11
  162. package/docs/webapi/appendField.mustache +0 -11
  163. package/docs/webapi/attachFile.mustache +0 -12
  164. package/docs/webapi/blur.mustache +0 -18
  165. package/docs/webapi/checkOption.mustache +0 -13
  166. package/docs/webapi/clearCookie.mustache +0 -9
  167. package/docs/webapi/clearField.mustache +0 -9
  168. package/docs/webapi/click.mustache +0 -29
  169. package/docs/webapi/clickLink.mustache +0 -8
  170. package/docs/webapi/closeCurrentTab.mustache +0 -7
  171. package/docs/webapi/closeOtherTabs.mustache +0 -8
  172. package/docs/webapi/dontSee.mustache +0 -11
  173. package/docs/webapi/dontSeeCheckboxIsChecked.mustache +0 -10
  174. package/docs/webapi/dontSeeCookie.mustache +0 -8
  175. package/docs/webapi/dontSeeCurrentPathEquals.mustache +0 -10
  176. package/docs/webapi/dontSeeCurrentUrlEquals.mustache +0 -10
  177. package/docs/webapi/dontSeeElement.mustache +0 -8
  178. package/docs/webapi/dontSeeElementInDOM.mustache +0 -8
  179. package/docs/webapi/dontSeeInCurrentUrl.mustache +0 -4
  180. package/docs/webapi/dontSeeInField.mustache +0 -11
  181. package/docs/webapi/dontSeeInSource.mustache +0 -8
  182. package/docs/webapi/dontSeeInTitle.mustache +0 -8
  183. package/docs/webapi/dontSeeTraffic.mustache +0 -13
  184. package/docs/webapi/doubleClick.mustache +0 -13
  185. package/docs/webapi/downloadFile.mustache +0 -12
  186. package/docs/webapi/dragAndDrop.mustache +0 -9
  187. package/docs/webapi/dragSlider.mustache +0 -11
  188. package/docs/webapi/executeAsyncScript.mustache +0 -24
  189. package/docs/webapi/executeScript.mustache +0 -26
  190. package/docs/webapi/fillField.mustache +0 -16
  191. package/docs/webapi/flushNetworkTraffics.mustache +0 -5
  192. package/docs/webapi/focus.mustache +0 -13
  193. package/docs/webapi/forceClick.mustache +0 -28
  194. package/docs/webapi/forceRightClick.mustache +0 -18
  195. package/docs/webapi/grabAllWindowHandles.mustache +0 -7
  196. package/docs/webapi/grabAttributeFrom.mustache +0 -10
  197. package/docs/webapi/grabAttributeFromAll.mustache +0 -9
  198. package/docs/webapi/grabBrowserLogs.mustache +0 -9
  199. package/docs/webapi/grabCookie.mustache +0 -11
  200. package/docs/webapi/grabCssPropertyFrom.mustache +0 -11
  201. package/docs/webapi/grabCssPropertyFromAll.mustache +0 -10
  202. package/docs/webapi/grabCurrentUrl.mustache +0 -9
  203. package/docs/webapi/grabCurrentWindowHandle.mustache +0 -6
  204. package/docs/webapi/grabDataFromPerformanceTiming.mustache +0 -20
  205. package/docs/webapi/grabElementBoundingRect.mustache +0 -20
  206. package/docs/webapi/grabGeoLocation.mustache +0 -8
  207. package/docs/webapi/grabHTMLFrom.mustache +0 -10
  208. package/docs/webapi/grabHTMLFromAll.mustache +0 -9
  209. package/docs/webapi/grabNumberOfOpenTabs.mustache +0 -8
  210. package/docs/webapi/grabNumberOfVisibleElements.mustache +0 -9
  211. package/docs/webapi/grabPageScrollPosition.mustache +0 -8
  212. package/docs/webapi/grabPopupText.mustache +0 -5
  213. package/docs/webapi/grabRecordedNetworkTraffics.mustache +0 -10
  214. package/docs/webapi/grabSource.mustache +0 -8
  215. package/docs/webapi/grabTextFrom.mustache +0 -10
  216. package/docs/webapi/grabTextFromAll.mustache +0 -9
  217. package/docs/webapi/grabTitle.mustache +0 -8
  218. package/docs/webapi/grabValueFrom.mustache +0 -9
  219. package/docs/webapi/grabValueFromAll.mustache +0 -8
  220. package/docs/webapi/grabWebElement.mustache +0 -9
  221. package/docs/webapi/grabWebElements.mustache +0 -9
  222. package/docs/webapi/moveCursorTo.mustache +0 -12
  223. package/docs/webapi/openNewTab.mustache +0 -7
  224. package/docs/webapi/pressKey.mustache +0 -12
  225. package/docs/webapi/pressKeyDown.mustache +0 -12
  226. package/docs/webapi/pressKeyUp.mustache +0 -12
  227. package/docs/webapi/pressKeyWithKeyNormalization.mustache +0 -60
  228. package/docs/webapi/refreshPage.mustache +0 -6
  229. package/docs/webapi/resizeWindow.mustache +0 -6
  230. package/docs/webapi/rightClick.mustache +0 -14
  231. package/docs/webapi/saveElementScreenshot.mustache +0 -10
  232. package/docs/webapi/saveScreenshot.mustache +0 -12
  233. package/docs/webapi/say.mustache +0 -10
  234. package/docs/webapi/scrollIntoView.mustache +0 -11
  235. package/docs/webapi/scrollPageToBottom.mustache +0 -6
  236. package/docs/webapi/scrollPageToTop.mustache +0 -6
  237. package/docs/webapi/scrollTo.mustache +0 -12
  238. package/docs/webapi/see.mustache +0 -11
  239. package/docs/webapi/seeAttributesOnElements.mustache +0 -9
  240. package/docs/webapi/seeCheckboxIsChecked.mustache +0 -10
  241. package/docs/webapi/seeCookie.mustache +0 -8
  242. package/docs/webapi/seeCssPropertiesOnElements.mustache +0 -9
  243. package/docs/webapi/seeCurrentPathEquals.mustache +0 -10
  244. package/docs/webapi/seeCurrentUrlEquals.mustache +0 -11
  245. package/docs/webapi/seeElement.mustache +0 -8
  246. package/docs/webapi/seeElementInDOM.mustache +0 -8
  247. package/docs/webapi/seeInCurrentUrl.mustache +0 -8
  248. package/docs/webapi/seeInField.mustache +0 -12
  249. package/docs/webapi/seeInPopup.mustache +0 -8
  250. package/docs/webapi/seeInSource.mustache +0 -7
  251. package/docs/webapi/seeInTitle.mustache +0 -8
  252. package/docs/webapi/seeNumberOfElements.mustache +0 -11
  253. package/docs/webapi/seeNumberOfVisibleElements.mustache +0 -10
  254. package/docs/webapi/seeTextEquals.mustache +0 -9
  255. package/docs/webapi/seeTitleEquals.mustache +0 -8
  256. package/docs/webapi/seeTraffic.mustache +0 -36
  257. package/docs/webapi/selectOption.mustache +0 -21
  258. package/docs/webapi/setCookie.mustache +0 -16
  259. package/docs/webapi/setGeoLocation.mustache +0 -12
  260. package/docs/webapi/startRecordingTraffic.mustache +0 -8
  261. package/docs/webapi/startRecordingWebSocketMessages.mustache +0 -8
  262. package/docs/webapi/stopRecordingTraffic.mustache +0 -5
  263. package/docs/webapi/stopRecordingWebSocketMessages.mustache +0 -7
  264. package/docs/webapi/switchTo.mustache +0 -9
  265. package/docs/webapi/switchToNextTab.mustache +0 -10
  266. package/docs/webapi/switchToPreviousTab.mustache +0 -10
  267. package/docs/webapi/type.mustache +0 -21
  268. package/docs/webapi/uncheckOption.mustache +0 -13
  269. package/docs/webapi/wait.mustache +0 -8
  270. package/docs/webapi/waitForClickable.mustache +0 -11
  271. package/docs/webapi/waitForCookie.mustache +0 -9
  272. package/docs/webapi/waitForDetached.mustache +0 -10
  273. package/docs/webapi/waitForDisabled.mustache +0 -6
  274. package/docs/webapi/waitForElement.mustache +0 -11
  275. package/docs/webapi/waitForEnabled.mustache +0 -6
  276. package/docs/webapi/waitForFunction.mustache +0 -17
  277. package/docs/webapi/waitForInvisible.mustache +0 -10
  278. package/docs/webapi/waitForNumberOfTabs.mustache +0 -9
  279. package/docs/webapi/waitForText.mustache +0 -13
  280. package/docs/webapi/waitForValue.mustache +0 -10
  281. package/docs/webapi/waitForVisible.mustache +0 -10
  282. package/docs/webapi/waitInUrl.mustache +0 -9
  283. package/docs/webapi/waitNumberOfVisibleElements.mustache +0 -10
  284. package/docs/webapi/waitToHide.mustache +0 -10
  285. package/docs/webapi/waitUrlEquals.mustache +0 -10
  286. package/lib/helper/AI.js +0 -214
  287. package/lib/listener/enhancedGlobalRetry.js +0 -110
  288. package/lib/plugin/enhancedRetryFailedStep.js +0 -99
  289. package/lib/plugin/htmlReporter.js +0 -3648
  290. package/lib/plugin/stepByStepReport.js +0 -427
  291. package/lib/plugin/subtitles.js +0 -89
  292. package/lib/retryCoordinator.js +0 -207
  293. package/typings/promiseBasedTypes.d.ts +0 -9469
  294. package/typings/types.d.ts +0 -11402
@@ -0,0 +1,563 @@
1
+ import crypto from 'crypto'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { mkdirp } from 'mkdirp'
5
+
6
+ import Container from '../container.js'
7
+ import recorder from '../recorder.js'
8
+ import event from '../event.js'
9
+ import output from '../output.js'
10
+ import store from '../store.js'
11
+
12
+ import { fileExists, deleteDir, template } from '../utils.js'
13
+ import Codeceptjs from '../index.js'
14
+ import { testToFileName } from '../mocha/test.js'
15
+ import {
16
+ parsePluginArgs,
17
+ resolveTrigger,
18
+ matchStepFile,
19
+ matchUrl,
20
+ getBrowserHelper,
21
+ } from '../utils/pluginParser.js'
22
+
23
+ const defaultConfig = {
24
+ on: 'fail',
25
+ slides: false,
26
+ uniqueScreenshotNames: false,
27
+ disableScreenshots: false,
28
+ fullPageScreenshots: false,
29
+ animateSlides: true,
30
+ deleteSuccessful: true,
31
+ ignoreSteps: [],
32
+ }
33
+
34
+ /**
35
+ * Saves screenshots from the browser at points triggered by `on=`.
36
+ *
37
+ * Replaces the legacy `screenshotOnFail` plugin. Default `on=fail` preserves the
38
+ * old behavior (screenshot when a test fails). Pass `slides=true` (with `on=step`)
39
+ * to generate a step-by-step slideshow report — replaces the legacy
40
+ * `stepByStepReport` plugin.
41
+ *
42
+ * #### Configuration
43
+ *
44
+ * ```js
45
+ * plugins: {
46
+ * screenshot: {
47
+ * enabled: true,
48
+ * on: 'fail',
49
+ * }
50
+ * }
51
+ * ```
52
+ *
53
+ * #### `on=` modes
54
+ *
55
+ * * **fail** — screenshot when a test fails (default)
56
+ * * **test** — screenshot at the end of every test
57
+ * * **step** — screenshot after every step
58
+ * * **file** — screenshot for steps in `path=...[;line=...]`
59
+ * * **url** — screenshot when the current browser URL matches `pattern=...`
60
+ *
61
+ * Other config options:
62
+ *
63
+ * * `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
64
+ * * `fullPageScreenshots`: make full page screenshots. Default: false.
65
+ * * `disableScreenshots`: legacy switch to skip the plugin entirely.
66
+ * * `slides`: generate a step-by-step slideshow report (requires `on=step`). Default: false.
67
+ * * `deleteSuccessful`: when `slides=true`, drop slideshow directories of passing tests. Default: true.
68
+ * * `animateSlides`: when `slides=true`, animate transitions between slides. Default: true.
69
+ * * `ignoreSteps`: when `slides=true`, RegExps of step names to skip in the slideshow.
70
+ *
71
+ * CLI examples:
72
+ *
73
+ * ```
74
+ * npx codeceptjs run -p screenshot
75
+ * npx codeceptjs run -p screenshot:on=step
76
+ * npx codeceptjs run -p screenshot:on=step;slides=true
77
+ * npx codeceptjs run -p screenshot:on=file:path=tests/login_test.js
78
+ * npx codeceptjs run -p screenshot:on=url:pattern=/users/*
79
+ * ```
80
+ */
81
+ export default function (config = {}) {
82
+ const helper = getBrowserHelper()
83
+ if (!helper) return
84
+
85
+ const cliArgs = parsePluginArgs(config._args)
86
+ const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, { name: 'screenshot' })
87
+ if (!trigger) return
88
+
89
+ const helpers = Container.helpers()
90
+ const options = Object.assign({}, defaultConfig, helper.options, config)
91
+ options.slides = cliArgs.slides ?? config.slides ?? defaultConfig.slides
92
+
93
+ if (helpers.Mochawesome?.config) {
94
+ options.uniqueScreenshotNames = helpers.Mochawesome.config.uniqueScreenshotNames
95
+ }
96
+
97
+ if (Codeceptjs.container.mocha()) {
98
+ options.reportDir = Codeceptjs.container.mocha()?.options?.reporterOptions
99
+ && Codeceptjs.container.mocha()?.options?.reporterOptions?.reportDir
100
+ }
101
+
102
+ if (options.disableScreenshots) return
103
+
104
+ if (options.slides) {
105
+ return wireSlides(options, trigger)
106
+ }
107
+
108
+ switch (trigger.on) {
109
+ case 'fail':
110
+ return wireOnFail(options)
111
+ case 'test':
112
+ return wireOnTest(options)
113
+ case 'step':
114
+ return wireOnStep(options, () => true)
115
+ case 'file':
116
+ return wireOnStep(options, step => matchStepFile(step, trigger.path, trigger.line))
117
+ case 'url':
118
+ return wireOnUrl(options, trigger.pattern)
119
+ }
120
+ }
121
+
122
+ function wireOnFail(options) {
123
+ let currentTest = null
124
+ event.dispatcher.on(event.test.before, test => {
125
+ currentTest = test
126
+ })
127
+ event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
128
+ if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return
129
+ const t = test || currentTest
130
+ if (!t) return
131
+ scheduleScreenshot(t, suffix(t, options, 'failed'), options)
132
+ })
133
+ }
134
+
135
+ function wireOnTest(options) {
136
+ event.dispatcher.on(event.test.after, test => {
137
+ if (!test) return
138
+ scheduleScreenshot(test, suffix(test, options, 'test'), options)
139
+ })
140
+ }
141
+
142
+ function wireOnStep(options, filter) {
143
+ let currentTest = null
144
+ let stepCount = 0
145
+ event.dispatcher.on(event.test.before, test => {
146
+ currentTest = test
147
+ stepCount = 0
148
+ })
149
+ event.dispatcher.on(event.step.after, step => {
150
+ if (!currentTest) return
151
+ if (!filter(step)) return
152
+ stepCount++
153
+ const name = `${testToFileName(currentTest, { suffix: '', unique: options.uniqueScreenshotNames })}.step_${stepCount}.png`
154
+ scheduleScreenshot(currentTest, name, options)
155
+ })
156
+ }
157
+
158
+ function wireOnUrl(options, pattern) {
159
+ let currentTest = null
160
+ let stepCount = 0
161
+ event.dispatcher.on(event.test.before, test => {
162
+ currentTest = test
163
+ stepCount = 0
164
+ })
165
+ event.dispatcher.on(event.step.after, () => {
166
+ if (!currentTest) return
167
+ const helper = getBrowserHelper()
168
+ if (!helper) return
169
+ recorder.add('screenshot:url check', async () => {
170
+ try {
171
+ const url = await helper.grabCurrentUrl()
172
+ if (!matchUrl(url, pattern)) return
173
+ stepCount++
174
+ const name = `${testToFileName(currentTest, { suffix: '', unique: options.uniqueScreenshotNames })}.url_${stepCount}.png`
175
+ await takeScreenshot(currentTest, name, options)
176
+ } catch (err) {
177
+ // page may not be ready
178
+ }
179
+ })
180
+ })
181
+ }
182
+
183
+ function suffix(test, options, kind) {
184
+ const base = testToFileName(test, { suffix: '', unique: options.uniqueScreenshotNames })
185
+ return `${base}.${kind}.png`
186
+ }
187
+
188
+ function scheduleScreenshot(test, fileName, options) {
189
+ recorder.add(
190
+ 'screenshot capture',
191
+ async () => takeScreenshot(test, fileName, options),
192
+ true,
193
+ )
194
+ }
195
+
196
+ async function takeScreenshot(test, fileName, options) {
197
+ const quietMode = !store.outputDir
198
+ if (!quietMode) {
199
+ output.plugin('screenshot', `Saving screenshot ${fileName}`)
200
+ }
201
+
202
+ const helper = getBrowserHelper()
203
+ if (!helper || typeof helper.saveScreenshot !== 'function') return
204
+
205
+ try {
206
+ if (options.reportDir) {
207
+ fileName = path.join(options.reportDir, fileName)
208
+ const mochaReportDir = path.resolve(process.cwd(), options.reportDir)
209
+ if (!fileExists(mochaReportDir)) fs.mkdirSync(mochaReportDir)
210
+ }
211
+
212
+ if (helper.page && helper.page.isClosed && helper.page.isClosed()) {
213
+ throw new Error('Browser page has been closed')
214
+ }
215
+ if (helper.browser && helper.browser.isConnected && !helper.browser.isConnected()) {
216
+ throw new Error('Browser has been disconnected')
217
+ }
218
+
219
+ const screenshotPromise = helper.saveScreenshot(fileName, options.fullPageScreenshots)
220
+ const timeoutPromise = new Promise((_, reject) => {
221
+ setTimeout(() => reject(new Error('Screenshot timeout after 5 seconds')), 5000)
222
+ })
223
+
224
+ await Promise.race([screenshotPromise, timeoutPromise])
225
+
226
+ if (!test.artifacts) test.artifacts = {}
227
+ const baseOutputDir = store.outputDir || null
228
+ if (baseOutputDir) {
229
+ test.artifacts.screenshot = path.join(baseOutputDir, fileName)
230
+ const mocha = Container.mocha()
231
+ const junit = mocha?.options?.reporterOptions?.['mocha-junit-reporter']
232
+ if (junit?.options?.attachments) {
233
+ test.attachments = [path.join(baseOutputDir, fileName)]
234
+ }
235
+ } else {
236
+ test.artifacts.screenshot = fileName
237
+ }
238
+ } catch (err) {
239
+ if (!quietMode) {
240
+ output.plugin('screenshot', `Failed to save screenshot: ${err.message}`)
241
+ }
242
+ if (
243
+ err
244
+ && ((err.message
245
+ && (err.message.includes('Target page, context or browser has been closed')
246
+ || err.message.includes('Browser page has been closed')
247
+ || err.message.includes('Browser has been disconnected')
248
+ || err.message.includes('was terminated due to')
249
+ || err.message.includes('no such window: target window already closed')
250
+ || err.message.includes('Screenshot timeout after')))
251
+ || (err.type && err.type === 'RuntimeError'))
252
+ ) {
253
+ output.log(`Can't make screenshot, ${err.message}`)
254
+ helper.isRunning = false
255
+ }
256
+ }
257
+ }
258
+
259
+ function wireSlides(options, trigger) {
260
+ const reportDir = options.output
261
+ ? path.resolve(store.codeceptDir, options.output)
262
+ : (store.outputDir || './_output')
263
+
264
+ const stepFilter = makeStepFilter(trigger, options)
265
+ const recordedTests = {}
266
+
267
+ let dir
268
+ let stepNum
269
+ let slides = {}
270
+ let savedStep = null
271
+ let currentTest = null
272
+ let scenarioFailed = false
273
+
274
+ event.dispatcher.on(event.suite.before, () => {
275
+ stepNum = -1
276
+ })
277
+
278
+ event.dispatcher.on(event.test.before, test => {
279
+ const hash = crypto.createHash('sha256').update(test.file + test.title).digest('hex')
280
+ dir = path.join(reportDir, `record_${hash}`)
281
+ mkdirp.sync(dir)
282
+ stepNum = 0
283
+ slides = {}
284
+ savedStep = null
285
+ currentTest = test
286
+ scenarioFailed = false
287
+ })
288
+
289
+ event.dispatcher.on(event.step.failed, step => {
290
+ recorder.add('slides: failed step', async () => persistStep(step), true)
291
+ })
292
+
293
+ event.dispatcher.on(event.step.after, step => {
294
+ recorder.add('slides: step', async () => persistStep(step), true)
295
+ })
296
+
297
+ event.dispatcher.on(event.test.passed, test => {
298
+ if (options.deleteSuccessful) {
299
+ deleteDir(dir)
300
+ return
301
+ }
302
+ persist(test)
303
+ })
304
+
305
+ event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
306
+ if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return
307
+ persist(test)
308
+ })
309
+
310
+ event.dispatcher.on(event.all.result, () => {
311
+ if (Object.keys(recordedTests).length === 0) return
312
+ writeIndex(reportDir, recordedTests)
313
+ })
314
+
315
+ if (event.workers && event.workers.result) {
316
+ event.dispatcher.on(event.workers.result, async () => {
317
+ await recorder.add(() => {
318
+ const tests = scanRecordDirs(reportDir)
319
+ if (Object.keys(tests).length) writeIndex(reportDir, tests)
320
+ })
321
+ })
322
+ }
323
+
324
+ async function persistStep(step) {
325
+ if (stepNum === -1) return
326
+ if (savedStep === step) return
327
+ if (scenarioFailed) return
328
+ if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
329
+ if (!currentTest) return
330
+ if (!stepFilter(step)) return
331
+ if (isStepIgnored(step, options.ignoreSteps)) return
332
+
333
+ const fileName = `${String(stepNum).padStart(4, '0')}.png`
334
+ if (step.status === 'failed') scenarioFailed = true
335
+ stepNum++
336
+ slides[fileName] = step
337
+
338
+ const helper = getBrowserHelper()
339
+ if (!helper || typeof helper.saveScreenshot !== 'function') return
340
+
341
+ try {
342
+ const screenshotPath = path.join(dir, fileName)
343
+ await helper.saveScreenshot(screenshotPath, options.fullPageScreenshots)
344
+ step.artifacts = step.artifacts || {}
345
+ step.artifacts.screenshot = screenshotPath
346
+
347
+ currentTest.artifacts = currentTest.artifacts || {}
348
+ currentTest.artifacts.screenshots = currentTest.artifacts.screenshots || []
349
+ currentTest.artifacts.screenshots.push(screenshotPath)
350
+ } catch (err) {
351
+ output.plugin('screenshot', `Can't save step screenshot: ${err.message}`)
352
+ } finally {
353
+ savedStep = step
354
+ }
355
+ }
356
+
357
+ function persist(test) {
358
+ if (!Object.keys(slides).length) return
359
+
360
+ const slideHtml = Object.keys(slides)
361
+ .sort()
362
+ .map((fileName, idx) => {
363
+ const step = slides[fileName]
364
+ const caption = step.toString().replace(/\[\d{2}m/g, '')
365
+ const failed = step.status === 'failed' ? ' is-failed' : ''
366
+ return template(SLIDE_TEMPLATE, {
367
+ image: fileName,
368
+ caption,
369
+ index: idx + 1,
370
+ activeClass: idx === 0 ? ' is-active' : '',
371
+ failed,
372
+ })
373
+ })
374
+ .join('')
375
+
376
+ const dotHtml = Object.keys(slides)
377
+ .map((_, idx) => `<button type="button" class="slides__dot${idx === 0 ? ' is-active' : ''}" data-slide="${idx}" aria-label="Step ${idx + 1}"></button>`)
378
+ .join('')
379
+
380
+ const html = template(SLIDESHOW_TEMPLATE, {
381
+ title: test.title,
382
+ feature: (test.parent && test.parent.title) || '',
383
+ slides: slideHtml,
384
+ dots: dotHtml,
385
+ animate: options.animateSlides ? 'true' : 'false',
386
+ })
387
+
388
+ const indexFile = path.join(dir, 'index.html')
389
+ fs.writeFileSync(indexFile, html)
390
+ recordedTests[`${(test.parent && test.parent.title) || ''}: ${test.title}`] = path.relative(reportDir, indexFile)
391
+ }
392
+ }
393
+
394
+ function makeStepFilter(trigger, options) {
395
+ if (trigger.on === 'file' && trigger.path) {
396
+ return step => matchStepFile(step, trigger.path, trigger.line)
397
+ }
398
+ if (trigger.on === 'fail') {
399
+ return step => step.status === 'failed'
400
+ }
401
+ return () => true
402
+ }
403
+
404
+ function isStepIgnored(step, patterns) {
405
+ if (!patterns || !patterns.length) return false
406
+ for (const pattern of patterns) {
407
+ if (step.name && step.name.match(pattern)) return true
408
+ }
409
+ return false
410
+ }
411
+
412
+ function scanRecordDirs(reportDir) {
413
+ const out = {}
414
+ try {
415
+ for (const item of fs.readdirSync(reportDir, { withFileTypes: true })) {
416
+ if (!item.isDirectory() || !item.name.startsWith('record_')) continue
417
+ const indexFile = path.join(reportDir, item.name, 'index.html')
418
+ if (!fs.existsSync(indexFile)) continue
419
+ const html = fs.readFileSync(indexFile, 'utf-8')
420
+ const titleMatch = html.match(/<title>([^<]*)<\/title>/)
421
+ const label = titleMatch ? titleMatch[1].replace(/^Slides — /, '') : item.name
422
+ out[label] = `${item.name}/index.html`
423
+ }
424
+ } catch (err) {
425
+ // ignore
426
+ }
427
+ return out
428
+ }
429
+
430
+ function writeIndex(reportDir, recordedTests) {
431
+ const items = Object.entries(recordedTests)
432
+ .map(([name, href]) => `<li><a href="${href}">${escapeHtml(name)}</a></li>`)
433
+ .join('\n')
434
+
435
+ const html = template(INDEX_TEMPLATE, {
436
+ time: new Date().toString(),
437
+ records: items,
438
+ })
439
+
440
+ const indexPath = path.join(reportDir, 'records.html')
441
+ fs.writeFileSync(indexPath, html)
442
+ output.print(`Step-by-step preview: file://${indexPath}`)
443
+ }
444
+
445
+ function escapeHtml(s) {
446
+ return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
447
+ }
448
+
449
+ const SLIDE_TEMPLATE = `
450
+ <figure class="slides__slide{{activeClass}}{{failed}}" data-index="{{index}}">
451
+ <img class="slides__image" src="{{image}}" alt="">
452
+ <figcaption class="slides__caption">
453
+ <span class="slides__step">{{index}}</span>
454
+ <span class="slides__text">{{caption}}</span>
455
+ </figcaption>
456
+ </figure>
457
+ `
458
+
459
+ const SLIDESHOW_TEMPLATE = `<!DOCTYPE html>
460
+ <html lang="en">
461
+ <head>
462
+ <meta charset="utf-8">
463
+ <meta name="viewport" content="width=device-width, initial-scale=1">
464
+ <title>Slides — {{feature}}: {{title}}</title>
465
+ <style>
466
+ :root { color-scheme: dark; --bg: #0b0d10; --panel: #14181d; --fg: #e7ecef; --muted: #8a96a0; --accent: #ff5b00; --error: #c0392b; }
467
+ * { box-sizing: border-box; }
468
+ html, body { height: 100%; margin: 0; }
469
+ body { background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Inter, sans-serif; display: flex; flex-direction: column; }
470
+ header { padding: 14px 20px; background: var(--panel); border-bottom: 1px solid #1f262d; display: flex; align-items: baseline; gap: 16px; }
471
+ header a { color: var(--muted); text-decoration: none; font-weight: 500; }
472
+ header a:hover { color: var(--fg); }
473
+ header .feature { color: var(--muted); }
474
+ header .test { font-weight: 600; }
475
+ .slides { flex: 1; position: relative; overflow: hidden; }
476
+ .slides__slide { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity .25s ease; }
477
+ .slides[data-animate="false"] .slides__slide { transition: none; }
478
+ .slides__slide.is-active { opacity: 1; pointer-events: auto; }
479
+ .slides__image { max-width: 100%; max-height: 100%; object-fit: contain; box-shadow: 0 10px 40px rgba(0,0,0,.4); }
480
+ .slides__caption { position: absolute; left: 20px; right: 20px; bottom: 24px; padding: 12px 16px; background: rgba(20,24,29,.92); border: 1px solid #1f262d; border-radius: 6px; display: flex; gap: 12px; align-items: baseline; }
481
+ .slides__slide.is-failed .slides__caption { background: var(--error); border-color: var(--error); }
482
+ .slides__step { font-variant-numeric: tabular-nums; color: var(--muted); font-weight: 600; min-width: 2ch; }
483
+ .slides__slide.is-failed .slides__step { color: #ffd9d4; }
484
+ .slides__text { word-break: break-word; }
485
+ .nav { position: absolute; top: 0; bottom: 0; width: 25%; background: transparent; border: 0; cursor: pointer; color: transparent; }
486
+ .nav--prev { left: 0; }
487
+ .nav--next { right: 0; }
488
+ .dots { display: flex; gap: 6px; justify-content: center; padding: 12px; background: var(--panel); border-top: 1px solid #1f262d; flex-wrap: wrap; }
489
+ .slides__dot { width: 10px; height: 10px; border-radius: 50%; border: 0; background: #2a323a; cursor: pointer; padding: 0; }
490
+ .slides__dot.is-active { background: var(--accent); }
491
+ .slides__dot:hover { background: #3d4751; }
492
+ .slides__dot.is-active:hover { background: var(--accent); }
493
+ .hint { color: var(--muted); font-size: 12px; padding: 8px 20px; text-align: center; background: var(--panel); border-top: 1px solid #1f262d; }
494
+ </style>
495
+ </head>
496
+ <body>
497
+ <header>
498
+ <a href="../records.html">&laquo; back</a>
499
+ <span class="feature">{{feature}}</span>
500
+ <span class="test">{{title}}</span>
501
+ </header>
502
+ <div class="slides" data-animate="{{animate}}">
503
+ {{slides}}
504
+ <button class="nav nav--prev" type="button" aria-label="Previous">&larr;</button>
505
+ <button class="nav nav--next" type="button" aria-label="Next">&rarr;</button>
506
+ </div>
507
+ <nav class="dots">{{dots}}</nav>
508
+ <p class="hint">Use &larr; / &rarr; to navigate, click sides of image, or use the dots below.</p>
509
+ <script>
510
+ (function () {
511
+ var slidesEl = document.querySelector('.slides');
512
+ var slides = Array.prototype.slice.call(slidesEl.querySelectorAll('.slides__slide'));
513
+ var dots = Array.prototype.slice.call(document.querySelectorAll('.slides__dot'));
514
+ var idx = 0;
515
+ function show(i) {
516
+ if (i < 0) i = slides.length - 1;
517
+ if (i >= slides.length) i = 0;
518
+ slides[idx].classList.remove('is-active');
519
+ dots[idx] && dots[idx].classList.remove('is-active');
520
+ idx = i;
521
+ slides[idx].classList.add('is-active');
522
+ dots[idx] && dots[idx].classList.add('is-active');
523
+ }
524
+ document.querySelector('.nav--prev').addEventListener('click', function () { show(idx - 1); });
525
+ document.querySelector('.nav--next').addEventListener('click', function () { show(idx + 1); });
526
+ dots.forEach(function (d, i) { d.addEventListener('click', function () { show(i); }); });
527
+ document.addEventListener('keydown', function (e) {
528
+ if (e.key === 'ArrowLeft') show(idx - 1);
529
+ if (e.key === 'ArrowRight') show(idx + 1);
530
+ });
531
+ })();
532
+ </script>
533
+ </body>
534
+ </html>
535
+ `
536
+
537
+ const INDEX_TEMPLATE = `<!DOCTYPE html>
538
+ <html lang="en">
539
+ <head>
540
+ <meta charset="utf-8">
541
+ <meta name="viewport" content="width=device-width, initial-scale=1">
542
+ <title>Step-by-step Reports</title>
543
+ <style>
544
+ :root { color-scheme: dark; --bg: #0b0d10; --panel: #14181d; --fg: #e7ecef; --muted: #8a96a0; --accent: #ff5b00; }
545
+ * { box-sizing: border-box; }
546
+ body { background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Inter, sans-serif; max-width: 880px; margin: 0 auto; padding: 32px 24px; }
547
+ h1 { margin: 0 0 4px; font-size: 22px; font-weight: 600; }
548
+ .meta { color: var(--muted); margin-bottom: 24px; font-size: 13px; }
549
+ ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 4px; }
550
+ li { background: var(--panel); border: 1px solid #1f262d; border-radius: 6px; }
551
+ li a { display: block; padding: 12px 16px; color: var(--fg); text-decoration: none; }
552
+ li a:hover { background: #1c2229; border-color: var(--accent); }
553
+ </style>
554
+ </head>
555
+ <body>
556
+ <h1>Step-by-step Reports</h1>
557
+ <div class="meta">{{time}}</div>
558
+ <ul>
559
+ {{records}}
560
+ </ul>
561
+ </body>
562
+ </html>
563
+ `