codeceptjs 4.2.0 → 4.2.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +2 -0
  2. package/bin/codecept.js +11 -0
  3. package/docs/agents.md +26 -1
  4. package/docs/ai.md +57 -0
  5. package/docs/alternative-browsers.md +175 -148
  6. package/docs/assertions.md +74 -0
  7. package/docs/bootstrap.md +2 -0
  8. package/docs/commands.md +45 -0
  9. package/docs/configuration.md +37 -0
  10. package/docs/debugging.md +1 -1
  11. package/docs/helpers/ApiDataFactory.md +20 -20
  12. package/docs/helpers/Appium.md +1615 -670
  13. package/docs/helpers/Decision.md +146 -0
  14. package/docs/helpers/GraphQL.md +31 -31
  15. package/docs/helpers/GraphQLDataFactory.md +20 -20
  16. package/docs/helpers/Kitesurf.md +1808 -25
  17. package/docs/helpers/Lightpanda.md +139 -0
  18. package/docs/helpers/Obscura.md +1799 -74
  19. package/docs/helpers/Playwright.md +490 -428
  20. package/docs/helpers/Puppeteer.md +317 -255
  21. package/docs/helpers/REST.md +43 -43
  22. package/docs/helpers/WebDriver.md +237 -175
  23. package/docs/installation.md +16 -0
  24. package/docs/mcp.md +7 -1
  25. package/docs/parallel.md +35 -1
  26. package/docs/plugins/pause.md +2 -1
  27. package/docs/plugins/sitemap.md +48 -0
  28. package/docs/plugins.md +4 -0
  29. package/lib/ai.js +81 -2
  30. package/lib/codecept.js +32 -2
  31. package/lib/command/init.js +22 -0
  32. package/lib/command/initAgent.js +78 -0
  33. package/lib/command/run-multiple.js +3 -0
  34. package/lib/command/run-workers.js +10 -3
  35. package/lib/command/run.js +3 -2
  36. package/lib/command/utils.js +15 -0
  37. package/lib/command/workers/runTests.js +10 -10
  38. package/lib/container.js +4 -2
  39. package/lib/effects.js +19 -1
  40. package/lib/helper/CDPBrowser.js +349 -1384
  41. package/lib/helper/Decision.js +256 -0
  42. package/lib/helper/Kitesurf.js +3 -21
  43. package/lib/helper/Lightpanda.js +137 -0
  44. package/lib/helper/Obscura.js +21 -238
  45. package/lib/helper/Playwright.js +6 -4
  46. package/lib/helper/Puppeteer.js +12 -7
  47. package/lib/helper/WebDriver.js +2 -1
  48. package/lib/listener/result.js +4 -0
  49. package/lib/mocha/bdd.js +19 -1
  50. package/lib/plugin/junitReporter.js +4 -1
  51. package/lib/plugin/pause.js +4 -1
  52. package/lib/plugin/sitemap.js +228 -0
  53. package/lib/recorder.js +19 -18
  54. package/lib/result.js +16 -0
  55. package/lib/step/base.js +12 -0
  56. package/lib/utils.js +2 -10
  57. package/lib/webServer.js +193 -0
  58. package/lib/workers.js +118 -31
  59. package/package.json +4 -2
  60. package/typings/index.d.ts +60 -7
  61. package/typings/promiseBasedTypes.d.ts +772 -824
  62. package/typings/types.d.ts +866 -833
  63. package/docs/helpers/CDPBrowser.md +0 -2138
@@ -1,43 +1,26 @@
1
- import { spawn } from 'child_process'
2
- import fs from 'fs'
3
- import net from 'net'
4
- import path from 'path'
5
- import axios from 'axios'
6
1
  import CDPBrowser from './CDPBrowser.js'
7
- import { isFile, isWindows } from '../utils.js'
8
2
 
9
3
  /**
10
4
  * ## Configuration
11
5
  *
12
- * This helper should be configured in codecept.conf.js. It accepts everything `CDPBrowser`
13
- * accepts (see its config table), plus:
6
+ * This helper should be configured in codecept.conf.js
14
7
  *
15
8
  * @typedef ObscuraConfig
16
9
  * @type {object}
17
- * @prop {string} [endpoint] - explicit CDP endpoint. Setting this switches the helper to ATTACH
18
- * mode: it only connects, and never spawns or kills a process, no matter what else is configured.
19
- * Leave it unset for SELF-MANAGED mode (see below).
20
- * @prop {string} [binaryPath] - path to the `obscura` executable, used in SELF-MANAGED mode
21
- * (`endpoint` unset). Checked before `OBSCURA_PATH` and `PATH`.
22
- * @prop {number} [port] - port `obscura serve` listens on, in SELF-MANAGED mode. When unset, a
23
- * free port is picked automatically, which is what makes `run-workers` collision-free — every
24
- * worker gets its own instance on its own port with zero config.
25
- * @prop {number} [serverStartTimeout=15000] - milliseconds to wait for a spawned `obscura serve`
26
- * to answer `/json/version` before `_connect` gives up.
10
+ * @prop {string} [endpoint] - connect to a running Obscura instead of starting one (ATTACH mode).
11
+ * @prop {string} [binaryPath] - path to the `obscura` executable. Checked before `OBSCURA_PATH` and `PATH`.
12
+ * @prop {number} [port] - port for `obscura serve`. A free port is picked when unset, so parallel workers never collide.
13
+ * @prop {number} [serverStartTimeout=15000] - how long to wait for `obscura serve` to start, in milliseconds.
27
14
  */
28
15
  const config = {}
29
16
 
30
17
  /**
31
- * Obscura drives [Obscura](https://github.com/h4ckf0r0day/obscura), a minimal headless
32
- * browser exposed over the Chrome DevTools Protocol. From v0.2.0, default release builds ship a
33
- * real rendering engine (layout, paint, screenshots); `-no-render` variants and v0.1.x builds keep
34
- * the original single-V8-isolate, nothing-rendered mode. This helper does not hardcode which mode a
35
- * given binary is in — `CDPBrowser._probeCapabilities` detects `layout`/`screenshot` per binary at
36
- * runtime, so the same helper works against either.
18
+ * Obscura drives [Obscura](https://github.com/h4ckf0r0day/obscura), a lightweight headless
19
+ * browser controlled over the Chrome DevTools Protocol. Default builds from v0.2.0 render pages
20
+ * (layout, screenshots); `-no-render` builds and v0.1.x run JavaScript and the DOM only. The helper
21
+ * detects which build it runs against, so the same config works for both.
37
22
  *
38
- * This helper is a thin `CDPBrowser` subclass: it changes nothing about how locating or acting on
39
- * elements works, it only pins the config presets Obscura requires and manages the `obscura serve`
40
- * process lifecycle, the same way Playwright manages its own browser process.
23
+ * The helper starts and stops `obscura serve` for you, the same way Playwright manages its browser.
41
24
  *
42
25
  * > Obscura support is experimental in CodeceptJS 4.2. Pin the browser version in CI and keep a
43
26
  * > Playwright/WebDriver job for browser-compatibility coverage.
@@ -57,8 +40,8 @@ const config = {}
57
40
  * - **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in
58
41
  * the config, then the `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The helper
59
42
  * spawns `obscura serve --port <port> --allow-private-network --allow-file-access` (`port` from
60
- * the config, or a free port picked automatically), waits for it to answer, connects, and kills
61
- * it in `_finishTest`.
43
+ * the config, or a free port picked automatically), waits for it to answer, connects, and stops
44
+ * it when tests finish.
62
45
  * - **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already
63
46
  * answers `http://127.0.0.1:9222/json/version` (e.g. `obscura serve` started by hand, or by CI
64
47
  * before this process ever ran). The helper attaches to it and never kills it — it isn't the
@@ -97,13 +80,11 @@ const config = {}
97
80
  *
98
81
  * | option | value | why |
99
82
  * | --- | --- | --- |
100
- * | `input` | `synthetic` | coordinate-click navigation is unreliable over CDP on Obscura even on rendering builds (no `frameNavigated` event, stale `page.url()`); `click` always takes the `forceClick` path — on Obscura, `click` and `forceClick` are the same thing |
101
- * | `xpathPolyfill` | `auto` | probed per binary/page: Obscura's native `document.evaluate` still doesn't support attribute selection or `not()`, so the polyfill is used until that lands |
83
+ * | `input` | `synthetic` | clicks that navigate are unreliable in Obscura, so `click` behaves like `forceClick` |
84
+ * | `xpathPolyfill` | `auto` | Obscura's XPath lacks attribute selection and `not()`, so the polyfill is used when needed |
102
85
  *
103
- * `capabilities.layout`/`capabilities.screenshot`/`capabilities.xpath` are intentionally left
104
- * unset here — `CDPBrowser._probeCapabilities` detects them at runtime from the actual binary
105
- * (`'real'`/`true` on v0.2.0+ default builds, `'none'`/`false` on `-no-render` builds and v0.1.x).
106
- * Set them explicitly in your own config to skip probing or to force a mode.
86
+ * `capabilities` are detected at runtime from the Obscura build. Set them in your config to skip
87
+ * detection or to force a mode.
107
88
  *
108
89
  * ## Limitations
109
90
  *
@@ -153,216 +134,18 @@ class Obscura extends CDPBrowser {
153
134
  * @param {ObscuraConfig} config
154
135
  */
155
136
  constructor(config) {
156
- const explicitEndpoint = !!(config && config.endpoint)
157
137
  super({
158
138
  input: 'synthetic',
159
139
  xpathPolyfill: 'auto',
160
140
  ...config,
161
141
  })
162
- this.mode = explicitEndpoint ? 'attach' : 'self-managed'
163
- if (!explicitEndpoint) {
164
- this.options.endpoint = null
165
- }
166
- this.serverProcess = null
167
- this.serverError = null
168
- this.binaryPath = null
169
- this._selfManagedResolved = false
170
- }
171
-
172
- /**
173
- * In ATTACH mode, connects exactly as `CDPBrowser._connect` would. In SELF-MANAGED mode,
174
- * resolves and spawns `obscura serve` (or courtesy-attaches to an already-running one on
175
- * :9222) exactly once via `_resolveSelfManaged`, then connects.
176
- *
177
- * A spawn failure (e.g. a bad binary) is delivered asynchronously by Node as an `error`
178
- * event; it is recorded on `this.serverError` and surfaced as a rejection from `_waitForServer`
179
- * instead of crashing the process as an uncaught exception.
180
- *
181
- * @protected
182
- */
183
- async _connect() {
184
- if (this.mode !== 'attach' && !this._selfManagedResolved) {
185
- await this._resolveSelfManaged()
186
- this._selfManagedResolved = true
187
- }
188
- return super._connect()
189
- }
190
-
191
- /**
192
- * Resolves how to reach Obscura when no explicit `endpoint` was configured, trying, in order:
193
- * spawn a binary (`binaryPath` config, then `OBSCURA_PATH` env, then `obscura` on `PATH`),
194
- * courtesy-attach to `http://127.0.0.1:9222` if something already answers there, or throw a
195
- * loud, actionable error. Sets `this.options.endpoint` as a side effect.
196
- *
197
- * @protected
198
- */
199
- async _resolveSelfManaged() {
200
- const binaryPath = this._resolveBinary()
201
- if (binaryPath) {
202
- this.binaryPath = binaryPath
203
- const port = this.options.port || (await this._findFreePort())
204
- this.options.port = port
205
- this.serverError = null
206
- this.serverProcess = spawn(binaryPath, ['serve', '--port', String(port), '--allow-private-network', '--allow-file-access'], { stdio: 'ignore' })
207
- this.serverProcess.on('error', err => {
208
- this.serverError = err
209
- })
210
- await this._waitForServer()
211
- this.options.endpoint = `ws://127.0.0.1:${port}/devtools/browser`
212
- return
213
- }
214
- if (await this._probeUp('http://127.0.0.1:9222/json/version')) {
215
- this.debugSection('Obscura', 'no binaryPath/OBSCURA_PATH/obscura-on-PATH found — courtesy-attaching to an already-running obscura serve on 127.0.0.1:9222 (this helper will not manage or kill it)')
216
- this.options.endpoint = 'http://127.0.0.1:9222'
217
- return
218
- }
219
- throw new Error(
220
- [
221
- 'Obscura has no endpoint configured, no binary could be resolved (checked config.binaryPath, OBSCURA_PATH, and PATH), and nothing answered http://127.0.0.1:9222/json/version.',
222
- 'Start it yourself: obscura serve --port 9222 --allow-private-network',
223
- 'Or let this helper launch it: put a release binary on PATH, set OBSCURA_PATH=/path/to/obscura, or pass binaryPath in the config.',
224
- 'Releases: https://github.com/h4ckf0r0day/obscura/releases',
225
- ].join('\n '),
226
- )
227
- }
228
-
229
- /**
230
- * Resolves the `obscura` binary to spawn, in priority order: `options.binaryPath`, then the
231
- * `OBSCURA_PATH` environment variable, then `obscura` on `PATH`. The `PATH` lookup walks the
232
- * directories itself instead of shelling out to `which`, which does not exist on Windows: on
233
- * Windows every `PATHEXT` suffix is tried, so an `obscura.exe` on `PATH` is found too.
234
- *
235
- * @returns {string|null} an absolute or relative path to the binary, or null if none resolved.
236
- * @protected
237
- */
238
- _resolveBinary() {
239
- if (this.options.binaryPath) return this.options.binaryPath
240
- if (process.env.OBSCURA_PATH) return process.env.OBSCURA_PATH
241
- const windows = isWindows()
242
- const extensions = windows ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']
243
- for (const entry of (process.env.PATH || '').split(path.delimiter)) {
244
- const dir = windows ? entry.replace(/^"|"$/g, '') : entry
245
- if (!dir) continue
246
- for (const extension of extensions) {
247
- const candidate = path.join(dir, `obscura${extension}`)
248
- if (!isFile(candidate)) continue
249
- try {
250
- fs.accessSync(candidate, fs.constants.X_OK)
251
- return candidate
252
- } catch (e) {
253
- continue
254
- }
255
- }
256
- }
257
- return null
258
- }
259
-
260
- /**
261
- * Picks a free TCP port on 127.0.0.1 by briefly listening on port 0 and reading back the OS-assigned
262
- * port. Used as the SELF-LAUNCH default when `options.port` isn't explicitly set, so multiple
263
- * `run-workers` workers never collide on the same port.
264
- *
265
- * @returns {Promise<number>} a free port.
266
- * @protected
267
- */
268
- async _findFreePort() {
269
- return new Promise((resolve, reject) => {
270
- const srv = net.createServer()
271
- srv.unref()
272
- srv.on('error', reject)
273
- srv.listen(0, '127.0.0.1', () => {
274
- const { port } = srv.address()
275
- srv.close(() => resolve(port))
276
- })
142
+ this._manageServer(config, {
143
+ name: 'obscura',
144
+ envVar: 'OBSCURA_PATH',
145
+ args: port => ['serve', '--port', String(port), '--allow-private-network', '--allow-file-access'],
146
+ releases: 'https://github.com/h4ckf0r0day/obscura/releases',
277
147
  })
278
148
  }
279
-
280
- /**
281
- * Probes a `/json/version`-style URL with a short timeout, used for the COURTESY-ATTACH check.
282
- *
283
- * @param {string} url
284
- * @returns {Promise<boolean>} true if the URL answered.
285
- * @protected
286
- */
287
- async _probeUp(url) {
288
- try {
289
- await axios.get(url, { timeout: 1000 })
290
- return true
291
- } catch (e) {
292
- return false
293
- }
294
- }
295
-
296
- /**
297
- * Polls `http://127.0.0.1:<port>/json/version` until `obscura serve` responds, `this.serverError`
298
- * is set by the spawned process' `error` event, or `options.serverStartTimeout` elapses. The
299
- * process typically comes up within tens of milliseconds — a 20ms retry interval (down from a
300
- * previous 200ms) keeps the wasted tail after the server is actually ready small, since this cost
301
- * is paid once per run and counts directly toward real-world startup latency.
302
- *
303
- * @protected
304
- */
305
- async _waitForServer() {
306
- const timeout = this.options.serverStartTimeout || 15000
307
- const deadline = Date.now() + timeout
308
- while (Date.now() < deadline) {
309
- if (this.serverError) {
310
- throw new Error(`Failed to start obscura at ${this.binaryPath}: ${this.serverError.message}`)
311
- }
312
- try {
313
- await axios.get(`http://127.0.0.1:${this.options.port}/json/version`)
314
- return
315
- } catch (e) {
316
- await new Promise(r => setTimeout(r, 20))
317
- }
318
- }
319
- if (this.serverError) {
320
- throw new Error(`Failed to start obscura at ${this.binaryPath}: ${this.serverError.message}`)
321
- }
322
- throw new Error(`obscura serve did not start on port ${this.options.port} within ${timeout}ms`)
323
- }
324
-
325
- /**
326
- * Closes the CDP connection (via `CDPBrowser._finishTest`), then kills the `obscura serve`
327
- * process spawned by `_connect`, if any (never runs in ATTACH or COURTESY-ATTACH mode, since
328
- * `this.serverProcess` is only ever set in SELF-LAUNCH mode). Runs in a `finally` so the process
329
- * is always reaped even if closing the CDP connection throws. Sends `SIGTERM` first and waits for
330
- * the process to exit; a process that ignores `SIGTERM` is escalated to `SIGKILL` after 5s. The
331
- * promise only resolves once the child has actually exited (confirmed via the `exit` event, not
332
- * merely once `SIGKILL` was sent — the kernel needs a moment to reap it), with a final safety-net
333
- * timeout so a stuck child can never keep the event loop alive even if that confirmation is
334
- * somehow lost.
335
- *
336
- * @protected
337
- */
338
- async _finishTest() {
339
- try {
340
- await super._finishTest()
341
- } finally {
342
- if (this.serverProcess) {
343
- const proc = this.serverProcess
344
- this.serverProcess = null
345
- await new Promise(resolve => {
346
- if (proc.exitCode !== null || proc.signalCode !== null) {
347
- resolve()
348
- return
349
- }
350
- let settled = false
351
- const finish = () => {
352
- if (settled) return
353
- settled = true
354
- clearTimeout(killTimer)
355
- clearTimeout(safetyTimer)
356
- resolve()
357
- }
358
- proc.once('exit', finish)
359
- const killTimer = setTimeout(() => proc.kill('SIGKILL'), 5000)
360
- const safetyTimer = setTimeout(finish, 5500)
361
- proc.kill()
362
- })
363
- }
364
- }
365
- }
366
149
  }
367
150
 
368
151
  export default Obscura
@@ -27,6 +27,7 @@ import {
27
27
  normalizeSpacesInString,
28
28
  normalizePath,
29
29
  resolveUrl,
30
+ appendBaseUrl,
30
31
  relativeDir,
31
32
  getMimeType,
32
33
  base64EncodeFile,
@@ -1365,7 +1366,7 @@ class Playwright extends Helper {
1365
1366
  }
1366
1367
 
1367
1368
  if (!/^\w+\:(\/\/|.+)/.test(url)) {
1368
- url = this.options.url + (!this.options.url.endsWith('/') && url.startsWith('/') ? url : `/${url}`)
1369
+ url = appendBaseUrl(this.options.url, url)
1369
1370
  this.debug(`Changed URL to base url + relative path: ${url}`)
1370
1371
  }
1371
1372
 
@@ -3124,7 +3125,7 @@ class Playwright extends Helper {
3124
3125
 
3125
3126
  if (url.startsWith('/')) {
3126
3127
  // local url
3127
- url = this.options.url + url
3128
+ url = appendBaseUrl(this.options.url, url)
3128
3129
  this.debugSection('URL', url)
3129
3130
  }
3130
3131
 
@@ -4427,7 +4428,8 @@ async function proceedSee(assertType, text, context, strict = false) {
4427
4428
  description = `element ${locator.toString()}`
4428
4429
  const els = await this._locate(locator)
4429
4430
  assertElementExists(els, locator.toString())
4430
- allText = await Promise.all(els.map(el => el.innerText()))
4431
+ allText = (await Promise.all(els.map(el => el.evaluateAll(nodes => nodes.map(node => node.innerText ?? node.textContent))))).flat()
4432
+ assertElementExists(allText, locator.toString())
4431
4433
  }
4432
4434
 
4433
4435
  if (store?.currentStep?.opts?.ignoreCase === true) {
@@ -4945,7 +4947,7 @@ async function highlightActiveElement(element) {
4945
4947
  }
4946
4948
 
4947
4949
  async function elToString(el, numberOfElements) {
4948
- const html = await el.evaluate(node => node.outerHTML)
4950
+ const html = await el.evaluate(node => node.outerHTML, undefined, { timeout: 100 }).catch(() => '<unavailable>')
4949
4951
  return (
4950
4952
  html
4951
4953
  .replace(/\n/g, '')
@@ -30,6 +30,7 @@ import {
30
30
  normalizeSpacesInString,
31
31
  normalizePath,
32
32
  resolveUrl,
33
+ appendBaseUrl,
33
34
  getMimeType,
34
35
  base64EncodeFile,
35
36
  } from '../utils.js'
@@ -751,7 +752,7 @@ class Puppeteer extends Helper {
751
752
  */
752
753
  async amOnPage(url) {
753
754
  if (!/^\w+\:\/\//.test(url)) {
754
- url = this.options.url + url
755
+ url = appendBaseUrl(this.options.url, url)
755
756
  }
756
757
 
757
758
  if (this.options.basicAuth && this.isAuthenticated !== true) {
@@ -3216,14 +3217,18 @@ async function proceedSee(assertType, text, context, strict = false) {
3216
3217
  let description
3217
3218
  let allText
3218
3219
  if (!context) {
3219
- let el = await this.context
3220
+ if (this.withinLocator) {
3221
+ let el = await this.context
3220
3222
 
3221
- if (el && !el.getProperty) {
3222
- // Fallback to body
3223
- el = await this.context.$('body')
3224
- }
3223
+ if (el && !el.getProperty) {
3224
+ el = await this.context.$('body')
3225
+ }
3225
3226
 
3226
- allText = [await el.evaluate(node => node.innerText)]
3227
+ allText = [await el.evaluate(node => node.innerText)]
3228
+ } else {
3229
+ const ctx = await this._getContext()
3230
+ allText = [await ctx.evaluate(() => document.body.innerText)]
3231
+ }
3227
3232
  description = 'web application'
3228
3233
  } else {
3229
3234
  const locator = new Locator(context, 'css')
@@ -27,6 +27,7 @@ import {
27
27
  modifierKeys,
28
28
  normalizePath,
29
29
  resolveUrl,
30
+ appendBaseUrl,
30
31
  getMimeType,
31
32
  base64EncodeFile,
32
33
  } from '../utils.js'
@@ -1063,7 +1064,7 @@ class WebDriver extends Helper {
1063
1064
  let split_url
1064
1065
  if (this.options.basicAuth) {
1065
1066
  if (url.startsWith('/')) {
1066
- url = this.options.url + url
1067
+ url = appendBaseUrl(this.options.url, url)
1067
1068
  }
1068
1069
  split_url = url.split('//')
1069
1070
  url = `${split_url[0]}//${this.options.basicAuth.username}:${this.options.basicAuth.password}@${split_url[1]}`
@@ -9,4 +9,8 @@ export default function () {
9
9
  event.dispatcher.on(event.test.before, test => {
10
10
  container.result().addTest(test)
11
11
  })
12
+
13
+ event.dispatcher.on(event.test.failed, test => {
14
+ container.result().addTest(test)
15
+ })
12
16
  }
package/lib/mocha/bdd.js CHANGED
@@ -145,15 +145,33 @@ const Before = fn => {
145
145
  event.dispatcher.on(event.test.started, fn)
146
146
  }
147
147
 
148
+ const GherkinBefore = Before
149
+
148
150
  const After = fn => {
149
151
  event.dispatcher.on(event.test.finished, fn)
150
152
  }
151
153
 
154
+ const GherkinAfter = After
155
+
152
156
  const Fail = fn => {
153
157
  event.dispatcher.on(event.test.failed, fn)
154
158
  }
155
159
 
156
- export { Given, When, Then, And, Before, After, Fail, matchStep, getSteps, clearSteps, defineParameterType }
160
+ export {
161
+ Given,
162
+ When,
163
+ Then,
164
+ And,
165
+ Before,
166
+ GherkinBefore,
167
+ After,
168
+ GherkinAfter,
169
+ Fail,
170
+ matchStep,
171
+ getSteps,
172
+ clearSteps,
173
+ defineParameterType
174
+ }
157
175
 
158
176
  export default {
159
177
  Given: addStep,
@@ -122,7 +122,10 @@ export default function (config = {}) {
122
122
 
123
123
  function buildXml(result, config, hookFailures = []) {
124
124
  const doc = new DOMImplementation().createDocument(null, null, null)
125
- const allTests = result.tests.concat(hookFailures)
125
+ const failureKey = (suiteTitle, message) => `${suiteTitle || ''}::${String(message || '').replace(/\s+/g, ' ').trim()}`
126
+ const hookFailureKeys = new Set(hookFailures.map(hf => failureKey(hf.parent && hf.parent.title, hf.err && hf.err.message)))
127
+ const tests = result.tests.filter(test => !hookFailureKeys.has(failureKey(test.parent && test.parent.title, test.err && test.err.message)))
128
+ const allTests = tests.concat(hookFailures)
126
129
  const suites = groupBySuite(allTests)
127
130
 
128
131
  const root = doc.createElement('testsuites')
@@ -2,6 +2,7 @@ import event from '../event.js'
2
2
  import pause from '../pause.js'
3
3
  import recorder from '../recorder.js'
4
4
  import output from '../output.js'
5
+ import { isEffectActive } from '../effects.js'
5
6
  import {
6
7
  parsePluginArgs,
7
8
  resolveTrigger,
@@ -27,7 +28,8 @@ import {
27
28
  *
28
29
  * #### `on=` modes
29
30
  *
30
- * * **fail** — pause when a step fails (default)
31
+ * * **fail** — pause when a step fails (default). Failures inside `tryTo` and
32
+ * non-final `retryTo` attempts are ignored
31
33
  * * **test** — pause after each test
32
34
  * * **step** — pause before the first step (interactive walk-through)
33
35
  * * **file** — pause when execution reaches `path=...[;line=...]`
@@ -69,6 +71,7 @@ function initFailMode() {
69
71
  })
70
72
 
71
73
  event.dispatcher.on(event.step.failed, () => {
74
+ if (isEffectActive()) return
72
75
  failed = true
73
76
  })
74
77