codeceptjs 4.0.0-rc.16 → 4.0.0-rc.18

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.
@@ -10,21 +10,30 @@ import output from '../output.js'
10
10
  import healModule from '../heal.js'
11
11
  const heal = healModule.default || healModule
12
12
  import store from '../store.js'
13
+ import {
14
+ parsePluginArgs,
15
+ resolveTrigger,
16
+ matchStepFile,
17
+ matchUrl,
18
+ getBrowserHelper,
19
+ } from '../utils/pluginParser.js'
13
20
 
14
21
 
15
22
  const defaultConfig = {
23
+ on: 'fail',
16
24
  healLimit: 2,
17
25
  }
18
26
 
19
27
  /**
20
28
  * Self-healing tests with AI.
21
29
  *
22
- * Read more about heaking in [Self-Healing Tests](https://codecept.io/heal/)
30
+ * Read more about healing in [Self-Healing Tests](https://codecept.io/heal/)
23
31
  *
24
32
  * ```js
25
33
  * plugins: {
26
34
  * heal: {
27
35
  * enabled: true,
36
+ * on: 'fail',
28
37
  * }
29
38
  * }
30
39
  * ```
@@ -32,7 +41,17 @@ const defaultConfig = {
32
41
  * More config options are available:
33
42
  *
34
43
  * * `healLimit` - how many steps can be healed in a single test (default: 2)
44
+ * * `on` - trigger mode. `fail` (default), `file` (filter to a path), `url` (filter to a URL pattern).
35
45
  *
46
+ * #### `on=` modes
47
+ *
48
+ * Heal always runs on step failures; `on=` narrows when it engages.
49
+ *
50
+ * * **fail** — heal any failing step (default)
51
+ * * **file** — heal only failures in `path=...[;line=...]`
52
+ * * **url** — heal only failures when the current URL matches `pattern=...`
53
+ *
54
+ * `on=step` and `on=test` are not supported and are rejected with an error.
36
55
  */
37
56
  export default function (config = {}) {
38
57
  if (store.debugMode && !process.env.DEBUG) {
@@ -42,6 +61,13 @@ export default function (config = {}) {
42
61
  return
43
62
  }
44
63
 
64
+ const cliArgs = parsePluginArgs(config._args)
65
+ const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, {
66
+ name: 'heal',
67
+ validModes: ['fail', 'file', 'url'],
68
+ })
69
+ if (!trigger) return
70
+
45
71
  let currentTest = null
46
72
  let currentStep = null
47
73
  let healedSteps = 0
@@ -65,6 +91,8 @@ export default function (config = {}) {
65
91
 
66
92
  if (!heal.hasCorrespondingRecipes(step)) return
67
93
 
94
+ if (trigger.on === 'file' && !matchStepFile(step, trigger.path, trigger.line)) return
95
+
68
96
  recorder.catchWithoutStop(async err => {
69
97
  isHealing = true
70
98
  if (caughtError === err) throw err // avoid double handling
@@ -72,6 +100,21 @@ export default function (config = {}) {
72
100
 
73
101
  const test = currentTest
74
102
 
103
+ if (trigger.on === 'url') {
104
+ try {
105
+ const helper = getBrowserHelper()
106
+ const url = helper && helper.grabCurrentUrl ? await helper.grabCurrentUrl() : null
107
+ if (!matchUrl(url, trigger.pattern)) {
108
+ isHealing = false
109
+ throw err
110
+ }
111
+ } catch (e) {
112
+ if (e === err) throw e
113
+ isHealing = false
114
+ throw err
115
+ }
116
+ }
117
+
75
118
  recorder.session.start('heal')
76
119
 
77
120
  debug('Self-healing started', step.toCode())
@@ -1,14 +1,15 @@
1
1
  import path from 'path'
2
2
  import fs from 'fs'
3
3
  import Container from '../container.js'
4
- const supportedHelpers = Container.STANDARD_ACTING_HELPERS
5
4
  import recorder from '../recorder.js'
6
5
  import event from '../event.js'
7
6
  import { scanForErrorMessages } from '../html.js'
7
+ import { captureSnapshot, pickActingHelper } from '../utils/trace.js'
8
8
  import { output } from '../index.js'
9
9
  import store from '../store.js'
10
10
  import { humanizeString, ucfirst } from '../utils.js'
11
11
  import { testToFileName } from '../mocha/test.js'
12
+
12
13
  const defaultConfig = {
13
14
  errorClasses: ['error', 'warning', 'alert', 'danger'],
14
15
  browserLogs: ['error'],
@@ -37,57 +38,58 @@ const defaultConfig = {
37
38
  *
38
39
  */
39
40
  export default function (config = {}) {
40
- const helpers = Container.helpers()
41
- let helper
42
-
43
41
  config = Object.assign(defaultConfig, config)
44
42
 
45
- for (const helperName of supportedHelpers) {
46
- if (Object.keys(helpers).indexOf(helperName) > -1) {
47
- helper = helpers[helperName]
48
- }
49
- }
50
-
51
- if (!helper) return // no helpers for screenshot
43
+ const helper = pickActingHelper(Container.helpers())
44
+ if (!helper) return
52
45
 
53
46
  event.dispatcher.on(event.test.failed, test => {
54
47
  const pageState = {}
55
48
 
56
- recorder.add('URL of failed test', async () => {
57
- try {
58
- const url = await helper.grabCurrentUrl()
59
- pageState.url = url
60
- } catch (err) {
61
- // not really needed
62
- }
63
- })
64
- recorder.add('HTML snapshot failed test', async () => {
49
+ recorder.add('pageInfo capture', async () => {
65
50
  try {
66
- const html = await helper.grabHTMLFrom('body')
67
-
68
- if (!html) return
69
-
70
- const errors = scanForErrorMessages(html, config.errorClasses)
71
- if (errors.length) {
72
- output.debug('Detected errors in HTML code')
73
- errors.forEach(error => output.debug(error))
74
- pageState.htmlErrors = errors
51
+ const prefix = `${testToFileName(test)}.pageInfo`
52
+ const captured = await captureSnapshot(helper, {
53
+ dir: store.outputDir,
54
+ prefix,
55
+ captureScreenshot: false,
56
+ })
57
+
58
+ if (captured.url) pageState.url = captured.url
59
+
60
+ if (captured.html) {
61
+ const htmlPath = path.join(store.outputDir, captured.html)
62
+ pageState.htmlSnapshot = htmlPath
63
+ // Scan raw HTML (pre-cleanHtml) so error classes containing digits
64
+ // or trash-class prefixes aren't stripped before detection.
65
+ const htmlForScan = captured.htmlRaw || (() => {
66
+ try { return fs.readFileSync(htmlPath, 'utf8') } catch { return '' }
67
+ })()
68
+ if (htmlForScan) {
69
+ try {
70
+ const errors = scanForErrorMessages(htmlForScan, config.errorClasses)
71
+ if (errors.length) {
72
+ output.debug('Detected errors in HTML code')
73
+ errors.forEach(error => output.debug(error))
74
+ pageState.htmlErrors = errors
75
+ }
76
+ } catch {}
77
+ }
75
78
  }
76
- } catch (err) {
77
- // not really needed
78
- }
79
- })
80
-
81
- recorder.add('Browser logs for failed test', async () => {
82
- try {
83
- const logs = await helper.grabBrowserLogs()
84
79
 
85
- if (!logs) return
80
+ if (captured.aria) {
81
+ pageState.ariaSnapshot = path.join(store.outputDir, captured.aria)
82
+ }
86
83
 
87
- pageState.browserErrors = getBrowserErrors(logs, config.browserLogs)
88
- } catch (err) {
89
- // not really needed
90
- }
84
+ if (captured.console) {
85
+ const consolePath = path.join(store.outputDir, captured.console)
86
+ pageState.consoleSnapshot = consolePath
87
+ try {
88
+ const logs = JSON.parse(fs.readFileSync(consolePath, 'utf8'))
89
+ pageState.browserErrors = getBrowserErrors(logs, config.browserLogs)
90
+ } catch {}
91
+ }
92
+ } catch {}
91
93
  })
92
94
 
93
95
  recorder.add('Save page info', () => {
@@ -127,15 +129,16 @@ function pageStateToMarkdown(pageState) {
127
129
  }
128
130
 
129
131
  function getBrowserErrors(logs, type = ['error']) {
130
- // Playwright & WebDriver console messages
131
- let errors = logs
132
+ // Accepts Playwright ConsoleMessage objects, normalized {type, text}, or strings
133
+ return logs
132
134
  .map(log => {
133
135
  if (typeof log === 'string') return log
134
- if (!log.type) return null
135
- return { type: log.type(), text: log.text() }
136
+ if (!log) return null
137
+ const t = typeof log.type === 'function' ? log.type() : log.type
138
+ const text = typeof log.text === 'function' ? log.text() : log.text
139
+ if (!t) return null
140
+ return { type: t, text }
136
141
  })
137
142
  .filter(l => l && (typeof l === 'string' || type.includes(l.type)))
138
143
  .map(l => (typeof l === 'string' ? l : l.text))
139
-
140
- return errors
141
144
  }
@@ -0,0 +1,131 @@
1
+ import event from '../event.js'
2
+ import pause from '../pause.js'
3
+ import recorder from '../recorder.js'
4
+ import output from '../output.js'
5
+ import {
6
+ parsePluginArgs,
7
+ resolveTrigger,
8
+ matchStepFile,
9
+ matchUrl,
10
+ getBrowserHelper,
11
+ } from '../utils/pluginParser.js'
12
+
13
+ /**
14
+ * Pauses test execution interactively. Replaces the legacy `pauseOnFail`
15
+ * plugin. The default `on=fail` matches the old `pauseOnFail` behavior.
16
+ *
17
+ * #### Configuration
18
+ *
19
+ * ```js
20
+ * plugins: {
21
+ * pause: {
22
+ * enabled: false,
23
+ * on: 'fail',
24
+ * }
25
+ * }
26
+ * ```
27
+ *
28
+ * #### `on=` modes
29
+ *
30
+ * * **fail** — pause when a step fails (default)
31
+ * * **test** — pause after each test
32
+ * * **step** — pause before the first step (interactive walk-through)
33
+ * * **file** — pause when execution reaches `path=...[;line=...]`
34
+ * * **url** — pause when the browser URL matches `pattern=...`
35
+ *
36
+ * CLI examples:
37
+ *
38
+ * ```
39
+ * npx codeceptjs run -p pause
40
+ * npx codeceptjs run -p pause:on=step
41
+ * npx codeceptjs run -p pause:on=file:path=tests/login_test.js;line=43
42
+ * npx codeceptjs run -p pause:on=url:pattern=/users/*
43
+ * ```
44
+ */
45
+ export default function (config = {}) {
46
+ const cliArgs = parsePluginArgs(config._args)
47
+ const trigger = resolveTrigger(cliArgs, config, { on: 'fail' }, { name: 'pause' })
48
+ if (!trigger) return
49
+
50
+ switch (trigger.on) {
51
+ case 'fail':
52
+ return initFailMode()
53
+ case 'test':
54
+ return initTestMode()
55
+ case 'step':
56
+ return initStepMode()
57
+ case 'file':
58
+ return initFileMode(trigger.path, trigger.line)
59
+ case 'url':
60
+ return initUrlMode(trigger.pattern)
61
+ }
62
+ }
63
+
64
+ function initFailMode() {
65
+ let failed = false
66
+
67
+ event.dispatcher.on(event.test.started, () => {
68
+ failed = false
69
+ })
70
+
71
+ event.dispatcher.on(event.step.failed, () => {
72
+ failed = true
73
+ })
74
+
75
+ event.dispatcher.on(event.test.after, () => {
76
+ if (failed) pause()
77
+ })
78
+ }
79
+
80
+ function initTestMode() {
81
+ event.dispatcher.on(event.test.after, () => pause())
82
+ }
83
+
84
+ function initStepMode() {
85
+ let activated = false
86
+
87
+ event.dispatcher.on(event.test.before, () => {
88
+ if (activated) return
89
+ activated = true
90
+ recorder.add('pause:step', () => pause())
91
+ })
92
+ }
93
+
94
+ function initFileMode(targetPath, targetLine) {
95
+ let paused = false
96
+
97
+ event.dispatcher.on(event.step.before, step => {
98
+ if (paused) return
99
+ if (!matchStepFile(step, targetPath, targetLine)) return
100
+ paused = true
101
+ recorder.add('pause:file', () => pause())
102
+ })
103
+ }
104
+
105
+ function initUrlMode(pattern) {
106
+ const helper = getBrowserHelper()
107
+
108
+ if (!helper) {
109
+ output.error('pause:on=url requires a browser helper (Playwright, WebDriver, Puppeteer, Appium)')
110
+ return
111
+ }
112
+
113
+ let paused = false
114
+
115
+ event.dispatcher.on(event.step.after, () => {
116
+ if (paused) return
117
+
118
+ recorder.add('pause:url check', async () => {
119
+ if (paused) return
120
+ try {
121
+ const currentUrl = await helper.grabCurrentUrl()
122
+ if (matchUrl(currentUrl, pattern)) {
123
+ paused = true
124
+ return pause()
125
+ }
126
+ } catch (err) {
127
+ // page may not be loaded yet
128
+ }
129
+ })
130
+ })
131
+ }
@@ -1,39 +1,15 @@
1
- import event from '../event.js'
1
+ import output from '../output.js'
2
+ import pause from './pause.js'
2
3
 
3
- import pause from '../pause.js'
4
+ let warned = false
4
5
 
5
6
  /**
6
- * Automatically launches [interactive pause](/basics/#pause) when a test fails.
7
- *
8
- * Useful for debugging flaky tests on local environment.
9
- * Add this plugin to config file:
10
- *
11
- * ```js
12
- * plugins: {
13
- * pauseOnFail: {},
14
- * }
15
- * ```
16
- *
17
- * Unlike other plugins, `pauseOnFail` is not recommended to be enabled by default.
18
- * Enable it manually on each run via `-p` option:
19
- *
20
- * ```
21
- * npx codeceptjs run -p pauseOnFail
22
- * ```
23
- *
7
+ * @deprecated Use the `pause` plugin with `on: 'fail'` (the default).
24
8
  */
25
- export default function() {
26
- let failed = false
27
-
28
- event.dispatcher.on(event.test.started, () => {
29
- failed = false
30
- })
31
-
32
- event.dispatcher.on(event.step.failed, () => {
33
- failed = true
34
- })
35
-
36
- event.dispatcher.on(event.test.after, () => {
37
- if (failed) pause()
38
- })
9
+ export default function (config = {}) {
10
+ if (!warned) {
11
+ output.error('pauseOnFail is deprecated; use the `pause` plugin (default on=fail).')
12
+ warned = true
13
+ }
14
+ return pause({ ...config, on: 'fail' })
39
15
  }
@@ -0,0 +1,287 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { mkdirp } from 'mkdirp'
4
+ import { v4 as uuidv4 } from 'uuid'
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 { testToFileName } from '../mocha/test.js'
13
+ import { parsePluginArgs, resolveTrigger, getBrowserHelper } from '../utils/pluginParser.js'
14
+
15
+ const defaultConfig = {
16
+ on: 'fail',
17
+ captions: true,
18
+ subtitles: false,
19
+ video: true,
20
+ }
21
+
22
+ /**
23
+ * Records WebM video of tests using Playwright's screencast API.
24
+ *
25
+ * When `captions` is enabled, action annotations are burned into the video;
26
+ * when `subtitles` is enabled, a standalone `.srt` is also produced. Default
27
+ * `on=fail` keeps videos for failed tests only; `on=test` keeps every test's
28
+ * video.
29
+ *
30
+ * Note: enabling Playwright's helper-level `video: true` together with this
31
+ * plugin produces two independent recordings (`output/videos/*.webm` from the
32
+ * helper, `output/screencast/*.webm` from this plugin).
33
+ *
34
+ * #### Configuration
35
+ *
36
+ * ```js
37
+ * plugins: {
38
+ * screencast: {
39
+ * enabled: true,
40
+ * on: 'fail',
41
+ * }
42
+ * }
43
+ * ```
44
+ *
45
+ * #### `on=` modes
46
+ *
47
+ * * **fail** — record while running; delete on pass, keep on fail (default)
48
+ * * **test** — record and keep every test's video
49
+ *
50
+ * Other config options:
51
+ *
52
+ * * `captions`: burn-in action overlays via `page.screencast.showActions()`. Default: true.
53
+ * * `subtitles`: also write a standalone `.srt` file alongside the video. Default: false.
54
+ * * `video`: record a video. With `video=false, subtitles=true`, only the `.srt` is produced. Default: true.
55
+ * * `size`: pass-through `{ width, height }` for `screencast.start`.
56
+ * * `quality`: pass-through 0–100 for `screencast.start`.
57
+ *
58
+ * CLI examples:
59
+ *
60
+ * ```
61
+ * npx codeceptjs run -p screencast
62
+ * npx codeceptjs run -p screencast:on=test
63
+ * npx codeceptjs run -p screencast:on=test;captions=false;subtitles=true
64
+ * ```
65
+ */
66
+ export default function (config = {}) {
67
+ const helper = getBrowserHelper()
68
+ if (!helper) return
69
+
70
+ const cliArgs = parsePluginArgs(config._args)
71
+ const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, {
72
+ name: 'screencast',
73
+ validModes: ['fail', 'test'],
74
+ })
75
+ if (!trigger) return
76
+
77
+ const options = Object.assign({}, defaultConfig, config)
78
+ options.captions = cliArgs.captions ?? config.captions ?? defaultConfig.captions
79
+ options.subtitles = cliArgs.subtitles ?? config.subtitles ?? defaultConfig.subtitles
80
+ options.video = cliArgs.video ?? config.video ?? defaultConfig.video
81
+
82
+ return wireScreencast(trigger.on, options)
83
+ }
84
+
85
+ function wireScreencast(mode, options) {
86
+ const state = {
87
+ test: null,
88
+ webmPath: null,
89
+ srtPath: null,
90
+ steps: null,
91
+ startedAt: null,
92
+ failed: false,
93
+ startQueued: false,
94
+ started: false,
95
+ warnedNoApi: false,
96
+ }
97
+
98
+ event.dispatcher.on(event.test.before, test => {
99
+ state.test = test
100
+ state.failed = false
101
+ state.webmPath = null
102
+ state.srtPath = null
103
+ state.startQueued = false
104
+ state.started = false
105
+ state.steps = options.subtitles ? {} : null
106
+ state.startedAt = options.subtitles ? Date.now() : null
107
+ })
108
+
109
+ event.dispatcher.on(event.step.started, step => {
110
+ if (state.steps) {
111
+ const at = Date.now()
112
+ step.id = step.id || uuidv4()
113
+ state.steps[step.id] = {
114
+ start: formatTimestamp(at - state.startedAt),
115
+ startedAt: at,
116
+ title: stepTitle(step),
117
+ }
118
+ }
119
+ if (!options.video || state.startQueued || !state.test) return
120
+ state.startQueued = true
121
+ const test = state.test
122
+ recorder.add('screencast:start', async () => startScreencast(test, options, state), true)
123
+ })
124
+
125
+ if (options.subtitles) {
126
+ event.dispatcher.on(event.step.finished, step => {
127
+ if (!state.steps || !step?.id || !state.steps[step.id]) return
128
+ state.steps[step.id].end = formatTimestamp(Date.now() - state.startedAt)
129
+ })
130
+ }
131
+
132
+ event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
133
+ if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return
134
+ state.failed = true
135
+ })
136
+
137
+ event.dispatcher.on(event.test.after, () => {
138
+ if (!state.test) return
139
+ recorder.add('screencast:stop', async () => finalizeScreencast({
140
+ test: state.test,
141
+ webmPath: state.webmPath,
142
+ srtPath: state.srtPath,
143
+ steps: state.steps,
144
+ failed: state.failed,
145
+ started: state.started,
146
+ options,
147
+ mode,
148
+ }), true)
149
+ })
150
+ }
151
+
152
+ async function startScreencast(test, options, state) {
153
+ const helper = getBrowserHelper()
154
+ if (!helper?.page?.screencast) {
155
+ if (!state.warnedNoApi) {
156
+ output.plugin('screencast', 'page.screencast not available — requires Playwright >= 1.59. Skipping.')
157
+ state.warnedNoApi = true
158
+ }
159
+ return
160
+ }
161
+
162
+ const baseDir = path.join(store.outputDir || '_output', 'screencast')
163
+ mkdirp.sync(baseDir)
164
+ const baseName = testToFileName(test, { suffix: '', unique: true })
165
+ state.webmPath = path.join(baseDir, `${baseName}.webm`)
166
+ state.srtPath = path.join(baseDir, `${baseName}.srt`)
167
+
168
+ const startOpts = { path: state.webmPath }
169
+ if (options.size) startOpts.size = options.size
170
+ if (options.quality != null) startOpts.quality = options.quality
171
+
172
+ try {
173
+ await helper.page.screencast.start(startOpts)
174
+ state.started = true
175
+ } catch (err) {
176
+ output.plugin('screencast', `Failed to start: ${err.message}`)
177
+ state.webmPath = null
178
+ state.srtPath = null
179
+ state.started = false
180
+ return
181
+ }
182
+
183
+ if (options.captions && typeof helper.page.screencast.showActions === 'function') {
184
+ try { await helper.page.screencast.showActions() }
185
+ catch (err) { output.plugin('screencast', `showActions failed: ${err.message}`) }
186
+ }
187
+ if (typeof helper.page.screencast.showChapter === 'function') {
188
+ try { await helper.page.screencast.showChapter(String(test.title || '')) }
189
+ catch (err) { output.plugin('screencast', `showChapter failed: ${err.message}`) }
190
+ }
191
+ }
192
+
193
+ async function finalizeScreencast(snapshot) {
194
+ const { test, options, mode, steps } = snapshot
195
+ let { webmPath, srtPath } = snapshot
196
+
197
+ const helper = getBrowserHelper()
198
+ if (snapshot.started && helper?.page?.screencast) {
199
+ try {
200
+ await helper.page.screencast.stop()
201
+ } catch (err) {
202
+ output.plugin('screencast', `stop failed: ${err.message}`)
203
+ }
204
+ }
205
+
206
+ const shouldKeep = mode === 'test' || (mode === 'fail' && snapshot.failed)
207
+
208
+ if (options.video && webmPath) {
209
+ if (!shouldKeep) {
210
+ try { fs.unlinkSync(webmPath) } catch { /* file may not exist yet */ }
211
+ webmPath = null
212
+ } else {
213
+ ensureArtifactsObject(test)
214
+ test.artifacts.screencast = webmPath
215
+ attachJUnitArtifact(test, webmPath)
216
+ }
217
+ }
218
+
219
+ if (options.subtitles && steps) {
220
+ if (options.video && !shouldKeep) {
221
+ try { srtPath && fs.unlinkSync(srtPath) } catch { /* nothing to delete */ }
222
+ return
223
+ }
224
+
225
+ let target = srtPath
226
+ if (!options.video) {
227
+ if (test.artifacts && test.artifacts.video) {
228
+ const { dir, name } = path.parse(test.artifacts.video)
229
+ target = path.join(dir, `${name}.srt`)
230
+ } else {
231
+ const baseDir = path.join(store.outputDir || '_output', 'screencast')
232
+ mkdirp.sync(baseDir)
233
+ const baseName = testToFileName(test, { suffix: '', unique: true })
234
+ target = path.join(baseDir, `${baseName}.srt`)
235
+ }
236
+ }
237
+
238
+ if (!target) return
239
+ try {
240
+ await fs.promises.writeFile(target, buildSrt(steps))
241
+ ensureArtifactsObject(test)
242
+ test.artifacts.subtitle = target
243
+ } catch (err) {
244
+ output.plugin('screencast', `failed to write SRT: ${err.message}`)
245
+ }
246
+ }
247
+ }
248
+
249
+ function formatTimestamp(timestampInMs) {
250
+ const date = new Date(0, 0, 0, 0, 0, 0, timestampInMs)
251
+ const hours = date.getHours()
252
+ const minutes = date.getMinutes()
253
+ const seconds = date.getSeconds()
254
+ const ms = timestampInMs - (hours * 3600000 + minutes * 60000 + seconds * 1000)
255
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`
256
+ }
257
+
258
+ function stepTitle(step) {
259
+ let title = `${step.actor}.${step.name}(${step.args ? step.args.join(',') : ''})`
260
+ if (title.length > 100) title = `${title.substring(0, 100)}...`
261
+ return title
262
+ }
263
+
264
+ function buildSrt(steps) {
265
+ const sorted = Object.values(steps).sort((a, b) => a.startedAt - b.startedAt)
266
+ let out = ''
267
+ let index = 1
268
+ for (const step of sorted) {
269
+ if (!step.end) continue
270
+ out += `${index}\n${step.start} --> ${step.end}\n${step.title}\n\n`
271
+ index++
272
+ }
273
+ return out
274
+ }
275
+
276
+ function ensureArtifactsObject(test) {
277
+ if (!test.artifacts || Array.isArray(test.artifacts)) test.artifacts = {}
278
+ }
279
+
280
+ function attachJUnitArtifact(test, filePath) {
281
+ const mocha = Container.mocha?.()
282
+ const junit = mocha?.options?.reporterOptions?.['mocha-junit-reporter']
283
+ if (junit?.options?.attachments) {
284
+ test.attachments = test.attachments || []
285
+ test.attachments.push(filePath)
286
+ }
287
+ }